使用 Three.js 绘制三维树模型

2011-10-25 03:47

使用 Three.js 绘制三维树模型

by admin

at 2011-10-24 19:47:35

original http://www.hiwebgl.com/?p=509

编者按:qiao是一名大三学生,目前就读于中山大学计算机系,热爱计算机科学与计算机技术,沉迷编程,推崇开源社区的自由精神,系 Linux 用户及其传播者。这是他在今年8月发表在自己博客上的一篇使用Three.js绘制一棵树木的文章。虽然目前Three.js的接口发生了变化,文章中提到的某些API在调用时会提示“已废弃”,但是思路仍然是值得借鉴的!推荐给大家!

另外感谢作者qiao,他的博客地址是:http://typedef.me/

前言

最近读了一本关于使用 Javascript 构建 Web 图形应用的新书(今年8月才出版的):Supercharged JavaScript Graphics。虽然只有短短200多页,但收获不少。其中有一节展示了如何使用 HTML5 的 canvas 来绘制一颗树。虽然其中使用的递归方法已经是老生常谈了,但其颇为美丽的结果和短小精悍的代码却依然令人眼前一亮。

正好自己最近关注了下Three.js这款 3D 引擎,于是就想拿它做一颗三维的树来试试。经过两天的各种折腾,终于得到了一个令人满意的成果。这里就简要的讲讲如何用 Three.js 来制作三维的树模型吧。

目录

1. 基本介绍

首先,Three.js 是一款运行在浏览器中的 3D 引擎,你可以用它创建各种三维场景,包括了摄影机、光影、材质等各种对象。你可以在它的主页上看到许多精采的演示。不过,这款引擎目前还处在比较不成熟的开发阶段,其不够丰富的 API 以及匮乏的文档增加了初学者的学习难度(尤其是文档的匮乏),你可以看下它的官方API文档,这玩意简直是坑爹么,怒 >_<。我当时不知道读了多少示例代码还有这个引擎的源码(源码里面也基本没啥注释)才大致的了解了几个常用类的用法。如果你也有兴趣学习这个引擎,希望这篇文章可以对你能有所帮助。

2. 浏览器支持

Three.js 支持三种图像渲染/输出的方式,分别为 SVG,Canvas 以及 WebGL,其中 WebGL 的性能最优秀,它可以使用 GPU 而不是 CPU 来进行图像运算。但这项技术目前还处于实验性阶段,许多浏览器如 IE 和 Opera 尚不支持。另外,在运行 Three.js 的展示的时候,推荐使用 Chrome,因为它的 V8 引擎是目前所有 Js 引擎中性能最为出色的,很多演示只有在 Chrome 下才可流畅的运行。

3. 基本元素的搭建

创建一个 html 文件并导入下载好的Three.js,这一步就不必多说了吧。

一个标准的 Three.js 演示包含以下四种元素。

  1. scene(场景)
    场景是用户所创建的各种物体以及光源的容器
  2. renderer(渲染器)
    用户可以根据上文提到的三种渲染方式选择对应的渲染器,用于将场景绘制到屏幕上。
  3. camera(摄像机)
    使用渲染器渲染场景时,我们需要提供一个摄像机来指定视线的位置、方向和视野等参数。
  4. object(对象)
    此处的对象泛指由用户创建的各种物体和光源等。

首先,我们创建一个场景, 平淡无奇的一行代码:

1
var scene = new THREE.Scene();

然后,创建 renderer:

1
2
var renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);

上面代码的第一行处可选 CanvasRenderer 以及 SVGRenderer, 如果想用其它的渲染方式的话,改个名字就行了。这里不得不赞一下 Three.js 的模块化设计,非常完美的将界面与数据解耦了。

第二行处是声明图像容器的大小,这里我就直接全屏了。你可按照自己的需求的改动数值。

接下来,我们在页面内创建一个容器,把 renderer 绑定上去,用于显示图形。

1
document.body.appendChild(renderer.domElement);

之后,创建 camera

1
var camera = new THREE.Camera(45, window.innerWidth / window.innerHeight, 1, 5000);

Camera 的四个参数从左至右以此为:

  1. FOV(视野广度), 以度为单位。
  2. Aspect(长宽比), 摄像机视野的长宽比,一般与 scene 保持一致
  3. Near(近点),如果一个物体距离摄像机镜头的的距离小于这个值,则这个物体不可见
  4. Far(远点),距离大于这个值则不可见

另外注意,所有物体生成时默认的位置都为 < 0, 0, 0 >,所以我们先将摄像机的位置调整一下:

1
camera.position.set(400, 600, 800);

OK,现在我们的初始化工作完成了。

4. 添加物体

现在让我们往场景塞些物体吧,在 Three.js 里面,我们可以使用?THREE.Mesh?这个类来生成多面体,此外还有BoneLineParticle?等其它类用于生成相应的其它对象。

THREE.Mesh 接收两个参数:

  1. geometry: THREE.Geometry 的实例,储存了物体的点线面的信息。
  2. material: THREE.Material 的实例,也即物体的材质,包括颜色、透明度、反光率等属性。

我们先来生成一个立方体:

1
2
3
var geometry = new THREE.CubeGeometry(200, 200, 200);
var material = new THREE.MeshLambertMaterial({ color: 0xff0000 });
var cube = new THREE.Mesh(geometry, material);

然后将这个立方体添加到场景里面

1
scene.addObject(cube);

接着我们创建一个平行光,并将其加入场景中

1
2
3
var light = new THREE.DirectionalLight(0xffffff);
light.position.set(500, 1000, 1500);
scene.addLight(light);

光线默认是往 < 0, 0, 0 > 的方向照射。另外注意,添加光线到场景中时用的是 addLight 方法而不是 addObject 方法。

之后,执行下面的代码,你就可以看到渲染后的结果了

1
renderer.render(scene, camera);

将上面的过程整合一下并略加修改,得到如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
var scene, renderer, camera, 
 
    WIDTH = window.innerWidth,
    HEIGHT = window.innerHeight,
 
    FOV = 45,
    NEAR = 1,
    FAR = 3000;
 
scene = new THREE.Scene();
renderer = new THREE.WebGLRenderer();
 
renderer.setSize(WIDTH, HEIGHT);
document.body.appendChild(renderer.domElement);
 
camera = new THREE.Camera(FOV, WIDTH / HEIGHT, NEAR, FAR);
camera.position.set(400, 600, 800); 
 
var cube = new THREE.Mesh(
    new THREE.CubeGeometry(200, 200, 200),
    new THREE.MeshLambertMaterial({ color: 0xff0000 })
);
scene.addObject(cube);
 
var light = new THREE.DirectionalLight(0xffffff);
light.position.set(500, 1000, 1500);
scene.addLight(light);
 
renderer.render(scene, camera);

上面的代码生成的便是如下的效果:

生成三维树

OK,现在进入重点了, 下面的便是我这次生成三维树模型的核心代码,我来一段一段讲解:

首先是一些变量的定义,不必细看,只要知道有这么个变量就行了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
var renderer, scene, camera,
 
    sin = Math.sin,
    cos = Math.cos,
    tan = Math.tan,
    rand = Math.random,
    floor = Math.floor,
    round = Math.round,
    PI = Math.PI,
 
    SCREEN_WIDTH = window.innerWidth,
    SCREEN_HEIGHT = window.innerHeight,
 
    // tree params
    MAX_BRANCHES = 4,
    MIN_BRANCHES = 3,
 
    RADIUS_SHRINK = 0.6,
 
    MIN_LENGTH_FACTOR = 0.5,
    MAX_LENGTH_FACTOR = 0.8,
 
    MIN_OFFSET_FACTOR = 0.7,
 
    MAX_SPREAD_RADIAN = PI / 4,
    MIN_SPREAD_RADIAN = PI / 10,
 
    BASE_LEAF_SCALE = 5;

首先是函数头,

1
function drawTree(start_position, direction, length, depth, radius) {

这个函数接收5个参数,分别为树枝的起点,生长的方向,长度,递归的深度以及枝干的半径。
调用的示例如下,注意那个8代表的即是用8层递归来生成这棵树。

1
2
3
4
5
drawTree(new THREE.Vector3(0, 0, 0), // start position
         new THREE.Vector3(0, 1, 0), // direction
         150,                        // length
         8,                          // depth
         10);                        // radius

回到函数中来,下面是一些局部变量,其中 cylinder(柱体)便是树的每个枝干。

1
2
3
4
    var cylinder, half_length_offset,
        new_position, new_direction, new_length, new_depth, new_radius,
        new_base_position, offset_vector,
        num_branches, color, num_segs;

下面这段代码根据递归深度的不同来产生不同的颜色,注意我们的深度是从8一直往下递减,如果深度小于2,我们就生成一个随机的绿色,代表树叶,否则生成一个随机的褐色,代表树枝或树干。

1
2
3
4
5
6
    // determine branch color
    if (depth &lt; 3) {
        color = (rand() * 128 + 64) &lt;&lt; 8; // random green color
    } else {
        color = ((rand() * 48 + 64) &lt;&lt; 16) | 0x3311; // random brown color
    }

下面这里 num_segs 这个变量表示树干的 cylinder 的圆是由多少条线段逼近的,值越高则越圆,但同时渲染速度也越低。我这里直接使用了 depth + 2 的值。这样一来,越细的树干其精细度就越低,可以稍微提升些性能。

1
    num_segs = depth + 2; // min num_segs = 3

然后,我们生成当前的树枝, 并将其转动,使其面向 direction 这个变量指定的方向。

1
2
3
4
5
6
7
8
9
10
11
    cylinder = new THREE.Mesh(
           new THREE.CylinderGeometry(num_segs, // numSegs
                                      radius, // topRad
                                      radius * RADIUS_SHRINK, // botRad
                                      length, // height
                                      0,    // topOffset
                                      0),   // botOffset
           new THREE.MeshLambertMaterial({ color: color })
    );
    // rotate the cylinder to follow the direction
    cylinder.lookAt(direction);

接下来,指定 cylinder 的位置。注意,它是以 cylinder 的中心点为依据的。所以我们在计算位置时需要将 direction 这个向量乘以一半的树枝长度,再加上起点位置的坐标。

1
2
3
4
5
6
7
    // get the offset from start position to cylinder center position
    half_length_offset = direction.clone();
    half_length_offset.setLength(length / 2);
 
    // calculate center position
    cylinder.position = start_position.clone();
    cylinder.position.addSelf(half_length_offset);

然后是一些光影的设定。castShadow 表示这个物体是否会在其它物体上产生影子,receiveShadow 表示这个物体是否会接收其它物体在它身上投下的影子。这里出于效率的考虑,我把 receiveShadow 给关了。

1
2
3
4
    cylinder.castShadow = true;
    cylinder.receiveShadow = false;
 
    scene.addObject(cylinder);

下面这里的代码便是递归终止的 base case

1
2
3
4
    // stop recursion if depth reached 1
    if (depth == 1) {
        return;
    }

如果继续的话,我们就必须为接下来的递归调用生成新的参数。

首先是计算新的枝干的生长点,注意一个枝干上可以生成多个新的子枝,生长点从当前枝干的长度乘以 MIN_OFFSET_FACTOR 一直到 1 之间随机分配。

1
2
3
4
5
    // calculate the base start position for next branch
    // a random offset will be added to it later
    new_base_position = start_position.clone();
    new_base_position.addSelf(
            half_length_offset.clone().multiplyScalar(2 * MIN_OFFSET_FACTOR));

然后是新的递归深度,枝干半径和随机的枝干个数。

1
2
3
4
5
6
    new_depth = depth - 1;
    new_radius = radius * RADIUS_SHRINK;
 
    // get a random branch number
    num_branches = round((rand() * (MAX_BRANCHES - MIN_BRANCHES)))
                   + MIN_BRANCHES;

接下来便开始枚举每个新的枝干了

1
2
    // recursively draw the children branches
    for (var i = 0; i &lt; num_branches; ++i) {

每个新的枝干都会在当前枝干的生长方向的基础上,往旁边旋转一定的角度,以形成分散的效果。这里用了些向量的计算。

首先是生成一个随机的分散的角度。

1
2
3
        // random spread radian
        var spread_radian = rand() * (MAX_SPREAD_RADIAN - MIN_SPREAD_RADIAN) +
                            MIN_SPREAD_RADIAN;

然后是通过当前的方向向量和 (1,0,0) 这个单位向量的叉乘产生一个垂直于当前方向的向量。记其为 perp_vec
并通过三角函数计算这个垂直的向量的长度

1
2
3
        // generate a vector which is prependicular to the original direction
        var perp_vec = (new THREE.Vector3(1, 0, 0)).crossSelf(direction);
        perp_vec.setLength(direction.length() * tan(spread_radian));

接下来将这个垂直的向量加上原先的方向向量产生一个新的方向向量: new_direction。

1
2
3
        // the new direction equals to the sum of the perpendicular vector
        // and the original direction
        new_direction = direction.clone().addSelf(perp_vec).normalize();

之后,我们以原先的方向向量为旋转轴,在每个循环中将刚才生成的新的方向向量绕着这根轴旋转一定的角度,以产生数个分散的方向。

1
2
3
4
5
        // generate a rotation matrix to rotate the new direction with
        // the original direction being the rotation axis
        var rot_mat = new THREE.Matrix4();
        rot_mat.setRotationAxis(direction, PI * 2 / num_branches * i);
        rot_mat.rotateAxis(new_direction);

并生成一个随机的枝干长度

1
2
3
        // random new length for the next branch
        new_length = (rand() * (MAX_LENGTH_FACTOR - MIN_LENGTH_FACTOR) +
                     MIN_LENGTH_FACTOR) * length;

然后计算新的枝干生长点。

1
2
3
4
5
6
        // caculate the position of the new branch
        new_position = new_base_position.clone();
        offset_vector = half_length_offset.clone();
        new_position.addSelf(
                offset_vector.multiplyScalar(
                    2 * i / (num_branches - 1) * (1 - MIN_OFFSET_FACTOR)));

在循环的最后递归调用 drawTree 来生成子树干。

1
2
3
4
5
6
7
8
9
        // using setTimeout to make the drawing procedure non-blocking
        setTimeout((function(a, b, c, d, e) {
            return function() {
                drawTree(a, b, c, d, e);
            };
        })(new_position, new_direction, new_length, new_depth, new_radius), 0);
 
    }
}

最后那段代码中我使用了 setTimeout 来防止绘图的过程出现阻塞的行为。

另外上面的闭包写法也值得注意,由于闭包对自由变量是按引用访问的,且我们依然处在循环中,所以如果写成下面这样的话每次函数调用的参数都是一样的,即都为参数在循环结束时的值,造成非预期的结果。

1
2
3
setTimeout(function() {
    drawTree(new_position, new_direction, new_length, new_depth, new_radius);
}, 0);

截图和演示

OK,再加上雾化、阴影、抗锯齿等诸多效果后,最后的成品如下:

 

点击上面的截图便可进入演示地址,不过首先注意:

  1. 这个演示是基于 WebGL 的,在 Firefox 6.0 和 Chromium 15.0.854.0 中测试通过(最好使用 Chromium/Chrome, 因为它们的 Javascript 虚拟机性能最好)。IE 还有 Opera 目前不支持 WebGL, 所以无法运行这个演示
  2. 在进入网址后,生成树的过程有可能会花 5-10 秒,你的浏览器可能暂时会出现未响应的状态,此外,CPU 使用率将会维持在一个很高的水平。
  3. 由于生成的算法含有随机参数,所以每次刷新都会取得不同的结果。

源码获取

查看网页源代码自然是可以的啦,另外,也可以在github上看到源码。(以后会在这个 repo 里面添加更多的演示的)