在Three.js中,一个可见的物体是由几何体和材料构成的。在这个教程中,我们将学习如何从头开始创建新的网格几何体,研究Three.js为处理几何对象和材质所提供的相关支持。

1、索引面集/Indexed Face Sets

Three.js中的Mesh网格对象是索引面的集合。Three.js网格对象类型为THREE.Geometry,包含一系列的顶点(其类型为THREE.Vector3)。除了顶点,Mesh网格还包含一系列的三角面(其类型为THREE.Face3),每个Face3对象都指定了Mesh几何体的一个三角面。三角面的三个顶点由三个整数指定,这些整数值都表示该顶点在Mesh对象的顶点数组的索引。例如:

var f = new THREE.Face3( 0, 7, 2 );

‌这三个索引值存储为Face3面对象的属性 f.a、f.bf.c。 例如,让我们来看看如何直接为这个金字塔创建一个对应的Three.js几何体:

请注意,金字塔的下部是一个正方形,因此需要拆分为两个三角形,才能将金字塔表示为Mesh网格对象。假设我们用pyramidGeom表示这个金字塔的几何对象,那么pyramidGeom.vertices是顶点数组,金pyramidGeom.faces是索引面数组。考虑到这一点,我们可以定义:

var pyramidGeom = new THREE.Geometry();

pyramidGeom.vertices = [  // array of Vector3 giving vertex coordinates
        new THREE.Vector3( 1, 0, 1 ),    // vertex number 0
        new THREE.Vector3( 1, 0, -1 ),   // vertex number 1
        new THREE.Vector3( -1, 0, -1 ),  // vertex number 2
        new THREE.Vector3( -1, 0, 1 ),   // vertex number 3
        new THREE.Vector3( 0, 1, 0 )     // vertex number 4
    ];
    
pyramidGeom.faces = [  // array of Face3 giving the triangular faces
        new THREE.Face3( 3, 2, 1 ),  // first half of the bottom face
        new THREE.Face3 3, 1, 0 ),   // second half of the bottom face
        new THREE.Face3( 3, 0, 4 ),  // remaining faces are the four sides
        new THREE.Face3( 0, 1, 4 ),
        new THREE.Face3( 1, 2, 4 ),
        new THREE.Face3( 2, 3, 4 )
    ];

请注意,三角面的顶点顺序并非完全任意:它们应该按从三角面前方查看的逆时针顺序排列,即从金字塔外面观察三角面。

这个金字塔几何体,当使用MeshBasicMaterial时可以正常工作,但如果要使用MeshLambertMaterial或MeshPhongMaterial,就需要为该几何体指定法线向量。如果一个Mesh几何体没有设置法线向量,那么使用Lambert或Phong材质时该集合体将呈现为黑色。可以手工设置几何对象的法线向量,但也可以使用Three.js中Geometry类的方法进行计算,例如:

pyramidGeom.computeFaceNormals();

此方法计算每个面的法线矢量,其中法线向量垂直于面。如果使用平面着色(flat shading)的材质,这就足够了,也就是说将材质的flatShading属性设置为True

Flat Shading适合金字塔这样的几何体的着色,但是当一个物体看起来光滑而不是面片时,它需要每个顶点的法线向量,而不是每个面的法线向量。Face3包含了一个顶点法线数组,我们可以手动设置,three.js也可以通过计算三角面的法线的平均值来得到光滑表面的顶点法线的合理估值。只需调用

geom.computeVertexNormals();

‌ 其中geom表示一个几何对象。请注意,在computeVertexNormals被调用之前,必须已经存在面法线,因此通常在调用geom.computeFaceNormals()后会立即调用geom.computeVertexNormals()方法。具有表面法线但没有顶点法线的几何体将无法使使其flatShading属性为false的材质,要在金字塔的表面使用平滑着色(Smooth Shading),应将每个三角面各顶点法线设置为与该三角面的面法线一致。在这种情况下,即使使用了平滑着色,金字塔的侧面看起来还是平坦的。标准的three.js几何形状,如BoxGeometry则内置了正确的表面和顶点法线。

一个对象的面法线保存在THREE.Face3对象的normal属性中,顶点法线则保存在THREE.Face3对象的vertexNormal属性中,该属性为Vector3数组。

我们的金字塔几何体目前包含了完整的法线矢量,可以使用任何mesh材质,但看起来还是有点乏味,因为只有一种颜色。在一个网格上实际可以使用多种颜色。为此,需要向网格对象构造函数传入一组材质,这使得将不同的材质应用于不同的面成为可能。例如,下面的代码将六种不同材质应用于立方体的六个面:

var cubeGeom = new THREE.BoxGeometry(10,10,10);
var cubeMaterials =  [
    new THREE.MeshPhongMaterial( { color: "red" } ),     // for the +x face
    new THREE.MeshPhongMaterial( { color: "cyan" } ),    // for the -x face
    new THREE.MeshPhongMaterial( { color: "green" } ),   // for the +y face
    new THREE.MeshPhongMaterial( { color: "magenta" } ), // for the -y face
    new THREE.MeshPhongMaterial( { color: "blue" } ),    // for the +z face
    new THREE.MeshPhongMaterial( { color: "yellow" } )   // for the -z face
];
var cube = new THREE.Mesh( cubeGeom, cubeMaterials );

要使这一点与几何对象配合使用,几何体的每个三角面都需要一个"材质索引"。三角面的材质索引是一个整数,表示所使用的材质在材质数组中的索引。BoxGeometry的面具备正确的索引。请注意,一个Box几何体有 12 个面,因为每个矩形侧面需要被拆分成两个三角面。构成矩形侧面的两个三角面具有相同的材质索引。

假设我们希望在金字塔的每一个侧面使用上面创建的不同材质。要使之发挥作用,每个面都需要一个材质索引,该索引存储在名为MaterialIndex的属性中。对于金字塔来说,面数组中的前两个面组成了金字塔的方形基座。他们可能应该有相同的材质索引。以下代码将材质索引 0 分配给前两个面,将材质索引 1、2、3 和 4 分配给其他四个面:

pyramidGeom.faces[0].materialIndex = 0;
for (var i = 1; i <= 5; i++) {
    pyramidGeom.faces[i].materialIndex = i-1;
}

‌‌上面的代码摘自示例程序threejs/MeshFaceMaterial.html。该程序使用每个对象上的多个材质显示一个立方体和一个金字塔。以下是显示结果:

还有另一种方法可以将不同的颜色分配给Mesh对象的每个面:可以将颜色存储为几何中面对象的属性。然后,就可以在对象上使用普通材质,而不是一系列材质。但你也必须告诉材质使用几何体的颜色代替材质的color属性。

有几种方法可以将颜色分配给网格中的面。一是简单地将每个面设置为不同的纯色。每个面对象都有一个color属性,可用于实现此想法。color属性的值是THREE.Color类型的对象,代表整个面的颜色。例如,我们可以设置金字塔的面颜色:

pyramidGeom.faces[0].color = new THREE.Color(0xCCCCCC);
pyramidGeom.faces[1].color = new THREE.Color(0xCCCCCC);
pyramidGeom.faces[2].color = new THREE.Color("green");
pyramidGeom.faces[3].color = new THREE.Color("blue");
pyramidGeom.faces[4].color = new THREE.Color("yellow");
pyramidGeom.faces[5].color = new THREE.Color("red");

要使用这些颜色,材料的vertexColors属性必须设置为THREE.FaceColors。例如:

material = new THREE.MeshLambertMaterial({
        vertexColors: THREE.FaceColors,
        shading: THREE.FlatShading
    });

FaceColors属性的默认值为THREE.NoColors,它告诉渲染器使用材质的color属性着色每一个面。

将颜色应用于面的第二种方法是将不同的颜色应用于三角面的每个顶点。然后,WebGL 将插值顶点颜色值以计算面内部各像素的颜色。每个面对象都有一个名为vertexColors的属性其值应该是一个THREE.Color对象数组,每个顶点一个。要使用这些颜色,材料的顶点vertexColors属性必须设置为三THREE.VertexColors。

下图展示了在球体的二十面体近似表示上使用顶点颜色和面颜色:

2、曲线和表面/Curves and Surfaces

除了支持构建索引三角面集外,Three.js还支持处理数学定义的曲线和表面。演示程序threejs/curves-and-surfaces.htm中提供了一些展示,下面我们讨论其中的一些示例。

参数化表面是最容易处理的。参数化表面由数学函数f(u,v)定义,其中 uv是数字,该函数的每个值都是空间中的一个点。表面由指定范围内uv函数值的所有点组成。对于Three.js,该函数就是返回THREE.Vector3类型值的常规 JavaScript 函数。参数化表面几何形状是通过在uv点阵中计算函数值而创建的。给出表面上的点阵,然后连接这些点,从而给出表面的多边形近似。在three.js,uv的值始终在 0.0 到 1.0 之间。几何形状是由以下构造器创建:

new THREE.ParametricGeometry( func, slices, stacks )

其中 func是 JavaScript 函数,slicesstacks决定网格中的点数;slicesu方向上给出了间隔从 0 到 1 的细分数,stacks在v方向上进行细分。一旦有了几何形状,就可以用它以通常的方式创建mesh对象。下面是一个示例:

上面的对象使用以下函数‌

function surfaceFunction( u, v ) {
    var x,y,z;  // A point on the surface, calculated from u,v.
                // u  and v range from 0 to 1.
    x = 20 * (u - 0.5);  // x and z range from -10 to 10
    z = 20 * (v - 0.5);
    y = 2*(Math.sin(x/2) * Math.cos(z));
    return new THREE.Vector3( x, y, z );
}

由以下代码创建:

var surfaceGeometry = new THREE.ParametricGeometry(surfaceFunction, 64, 64);
var surface = new THREE.Mesh( surfaceGeometry, material );

曲线(Curve)在three.js中更为复杂(不幸的是,用于处理曲线的 API 不是很一致)。THREE.Curve代表二维或三维的参数化曲线的抽象,它不是three.js几何形状。参数化曲线由包含一个数字变量t的函数定义。该函数返回的值为THREE.Vector2或THREE.Vector3,分别用于2D曲线和3D曲线。对于THREE.Curve对象,其getPoint(t)方法应返回与参数t值相对应的曲线上的点。但是,在Curve类中并未定义此方法。因此要获得实际曲线,你需要自己进行定义。例如:

var helix = new THREE.Curve();
helix.getPoint = function(t) {
   var s = (t - 0.5) * 12*Math.PI;
         // As t ranges from 0 to 1, s ranges from -6*PI to 6*PI
   return new THREE.Vector3(
        5*Math.cos(s),
        s,
        5*Math.sin(s)
   );
}

定义getPoint后,就将获得可用的曲线。你可以用它做的一件事是创建一个管状几何体,它定义了一个由管沿着曲线中心扫过运动扫过的几何体。示例程序使用上述定义的helix曲线创建两个管装几何体:

几何形状使用如下代码创建:

tubeGeometry1 = new THREE.TubeGeometry( helix, 128, 2.5, 32 );

构造器的第二个参数是沿曲线长度的表面细分数,第三个是管状横截面的半径,第四个是横截面周长周围的细分数。

要制作管状几何体,需要 3D 曲线。也有几种方法可以从2D曲线上制作表面。一种方法是围绕一个轴线旋转曲线,产生一个旋转的表面。表面由曲线旋转时通过的所有点组成。这叫做lathing。此示例程序中的图像显示了lathing一个余弦曲线产生的表面,曲线本身显示在表面之上:

‌‌表面用three.js的THREE.LatheGeometry创建。LatheGeometry不是从曲线上构建的,而是从曲线上的一系列点构建的。点是Vector2型的对象,曲线位于xy平面中。表面是通过围绕y轴旋转曲线生成的。LatheGeometry构造器形式如下:

new THREE.LatheGeometry( points, slices )

第一个参数是Vector2数组。第二个是当一个点围绕轴旋转时沿圆产生的表面细分的数量。在示例程序中,通过调用cosine.getPoints(128) 从余弦类型的曲线对象创建点阵列。此功能使用范围从 0.0 到 1.0 的参数值在曲线上创建 128 点的数组。

你可以用 2D 曲线完成的另一件事就是简单地填充曲线内部,从而提供 2D 填充形状。要使用three.js做到这一点你可以使用THREE.Shape类型,这是THREE.Curve的子类。Shape的定义方式与 2D Canvas API 中的路径相同。THREE.Shape对象moveTo、lineTo、quadraticCurveTo和bezierCurveTo等方法例如,我们可以创建泪滴形状:

var path = new THREE.Shape();
path.moveTo(0,10);
path.bezierCurveTo( 0,5, 20,-10, 0,-10 );
path.bezierCurveTo( -20,-10, 0,5, 0,10 );

要使用路径创建一个填充形状我们需要一个ShapeGeometry对象‌:

var shapeGeom = new THREE.ShapeGeometry( path );

下图左侧显示了上述代码创建的 2D 形状:

图片中的另外两个对象是通过挤压(extrude)形状创建的。在挤压中,填充的 2D 形状沿 3D 路径移动。形状经过的点构成 3D 实体。在这种情况下,形状沿着垂直于形状的线条挤压,这是最常见的情况。基本挤压的形状显示在上图的右侧。中间的对象则同时进行了圆角处理。

3、纹理/Textures

纹理可用于向对象添加视觉兴趣和细节。在three.js中,图像纹理由THREE.Texture对象表示。由于我们谈论的是网页,因此three.js纹理的图像通常从 Web 地址加载。图像纹理通常使用THREE.TextureLoader对象中的load方法创建。该方法以 URL(网址,通常是相对地址)为参数,并返回Texture纹理对象:

var loader = new THREE.TextureLoader();
var texture = loader.load( imageURL );

three.js的纹理被认为是材质的一部分。要将纹理应用于网格,只需将Texure对象分配给网格材质的map属性:

material.map = texture;

map属性也可以在材料构造器中设置。所有三种类型的网格材质(Basic、Lamber和 Phong)都可以使用纹理。一般来说,材质基色为白色,因为材质颜色将乘以纹理上的颜色。非白色的材质颜色将为纹理颜色添加"色调"。将图像映射到网格所需的纹理坐标是网格几何体的一部分。标准网格几何形状,如THREE.SphereGeometry已经定义了纹理坐标。

这就是基本的思路——从图像URL创建纹理对象,并将其赋值给材质的map属性。然而,其中也有一些复杂的细节。首先,图像加载是"异步的"。即调用加载功能仅启动加载图像的过程,并且该过程可以在功能返回后的某个时间完成。在图像完成加载之前在对象上使用纹理不会导致错误,但对象将呈现为完全黑色。加载图像后,必须再次渲染场景以显示图像纹理。如果运行了动画,这一切将自动发生:图像在完成加载后将显示在第一帧中。但是,如果没有启动动画,则需要一种方法在图像加载后渲染场景。事实上,纹理加载器中的load函数几个可选参数:

loader.load( imageURL, onLoad, undefined, onError );

‌此处的第三个参数被赋予undefined,因为该参数不再使用。onLoadonError参数是回调功能。如果定义了onLoad参数,则一旦图像成功加载该参数函数将被调用。如果加载图像的尝试失败,将调用onError函数。例如,如果存在一个自定义的渲染场景的函数 render(),则render()本身可作为onLoad参数:

var texture = new THREE.TextureLoader().load( "brick.png", render );

另一个可能的onLoad用法是将纹理延迟直到图像完成加载再分配给材质。如果你修改了material.map的值,记得设置:

material.needsUpdate = true;

以确保更改在重新绘制对象时生效。

Texture纹理对象具有许多可以设置的属性,包括为纹理设置最小化和放大过滤器的属性,以及用于控制mipmaps生成的属性,这些属性默认情况下会自动定义,最有可能要更改的属性是范围 0 到 1 之外的纹理坐标的包装模式和纹理转换。

对于一个纹理对象tex,属性tex.wrapStex.wrapT控制在范围 0 到 1 之外处理st纹理坐标的方式。默认值是"clamp to edge"。你很可能希望通过将属性值设置为THREE.RepeatWrapping来使纹理在两个方向上重复,例如:

tex.wrapS = THREE.RepeatWrapping;
tex.wrapT = THREE.RepeatWrapping;

重复包装最适合"无缝"纹理,图像的上边缘与下边缘匹配,左边缘与右边缘匹配。three.js还提供了一个有趣的变体称为"镜像重复",其中重复图像的所有其他副本被翻转。这消除了图像副本之间的接缝。对于镜像重复,请使用属性值三THREE.MirroredRepeatWrapping

tex.wrapS = THREE.MirroredRepeatWrapping;
tex.wrapT = THREE.MirroredRepeatWrapping;

纹理对象的属性repeatoffset控制应用于纹理的缩放和转换作为纹理转换(不支持旋转)。这些属性的值为THREE.Vector2,每个属性有xy成员。对于纹理对象,tex.offset的两个长远在水平和垂直方向上提供纹理转换。要将纹理水平偏移 0.5,可以使用如下代码:

tex.offset.x = 0.5;

或如下等效代码:

tex.offset.set( 0.5, 0 );

请记住,正的水平偏移量会将纹理移动到对象的左侧,因为偏移应用于纹理坐标而不是纹理图像本身。

属性tex.repeat在水平和垂直方向上提供纹理缩放。例如:

tex.repeat.set(2,3);

将横向和垂直扩展 2 倍和 3 倍的纹理坐标。同样,对图像的影响是反向的,因此图像被水平收缩 2 倍和垂直 3 倍。结果是在水平方向获得两个图像副本,垂直方向三个。这解释了名称"重复",但请注意,值不限于整数。

下面的演示允许查看一些设置了纹理的three.js对象。顺便说一下,演示中的"Pill"对象是一个由圆柱体和两个半球组成的复合对象:

假设我们希望在本节开头创建的金字塔上应用图像纹理。为了将纹理图像应用于对象,WebGL 需要该对象的纹理坐标。当我们从头开始构建网格时,我们必须提供纹理坐标作为网格几何对象的一部分。

示例中的pyramidGeom等几何对象具有名为faceVertexUv 的属性来保存纹理坐标。"UV"是指映射到纹理中的st坐标的对象上的坐标。faceVertexUvs的值是一个数组,其中每个元素本身又是一个数组的数组:在大多数情况下,仅使用元素faceVertexUvs[0],但在某些高级应用程序中使用了额外的uv坐标集。faceVertexUvs[0] 的值本身就是一个数组,每个成员对应几何体的一个面。每个面存储的数据还是一个数组:faceVertexUVs[0][N] 是一个数组,表示三角面N的三个顶点的坐标。最后,该数组中的每对纹理坐标都是THREE.Vector2类型。

金字塔有六个三角面,每个面需要一个包含三个Vector2对象的数组来表示。必须以合理的方式选择将纹理坐标映射到三角面上。我们将整个纹理图像映射到金字塔的地面,它从图像中切出一块三角形以便应用于每个侧面。需要仔细处理以便得到正确的左边。我们为此金字塔定义的纹理坐标如下:

pyramidGeometry.faceVertexUvs = [[
  [ new THREE.Vector2(0,0), new THREE.Vector2(0,1), new THREE.Vector2(1,1) ],
  [ new THREE.Vector2(0,0), new THREE.Vector2(1,1), new THREE.Vector2(1,0) ],
  [ new THREE.Vector2(0,0), new THREE.Vector2(1,0), new THREE.Vector2(0.5,1) ],
  [ new THREE.Vector2(1,0), new THREE.Vector2(0,0), new THREE.Vector2(0.5,1) ],
  [ new THREE.Vector2(0,0), new THREE.Vector2(1,0), new THREE.Vector2(0.5,1) ],
  [ new THREE.Vector2(1,0), new THREE.Vector2(0,0), new THREE.Vector2(0.5,1) ],
]];

请注意,这是一个三维阵列。

示例程序threejs/textured-pyramid.html显示具有砖块纹理的金字塔。以下是来自程序的图像:

4、变换/Transforms

为了在three.js中有效地处理对象,深入其变换的实现机制是非常有必要的。对于一个Object3D类型的对象obj,其属性包括obj.position,obj.scaleobj.rotation,指定了在本地坐标系中的模型变换。 但是,在渲染对象时,不会直接使用这些属性。相反,它们被组合起来计算另一个属性,obj.matrix,它将对象的变换表示为一个矩阵。默认情况下,每次渲染场景时,都会自动重新计算此矩阵。如果转换永远不变,这种做法就是低效的,所以obj有另一个属性,obj.matrixAutoUpdate控制obj.matrix是否自动计算。如果将obj.matrixAutoUpdate设置为false,则不会自动更新变换矩阵。在这种情况下,如果确实需要更新变换矩阵,可以调用obj.updateMatrix()以利用当前的obj.position、obj.scaleobj.rotation值计算矩阵。

我们已经看到了如何通过直接改变属性obj.position、obj.scale和obj.rotation的值来更新obj的模型变换。 不过,也可以通过调用函数obj.translate X(dx)、obj.translateY(dy)obj.translateZ(dz)来改变位置,以便将对象沿指定坐标轴的方向移动。还有一个函数obj.translateOnAxis(axis, amount),其中axis是Vector3类型,amount是一个数字,表示要移动的距离。物体沿axis指定的方向移动,axis矢量必须是归一化的:即它必须有长度1。例如,沿(1,1,1)方向移动 5 个单位,可以使用如下代码:

obj.translateOnAxis( new THREE.Vector3(1,1,1).normalize(), 5 );


没有用于缩放变换的方法。但是,你可以使用obj.rotateX(angle)、obj.rotateY(angle)obj.rotateZ(angle)来围绕指定坐标轴旋转对象。请记住角度单位是弧度。调用obj.rotateX(angle)与在obj.rotation.x值上增加角度不同,因为它在其他可能已有旋转之上应用了关于 x 轴的旋转。

还有一个函数obj.rotateOnAxis(axis,angle),其中axis是Vector3,此方法绕指定适量旋转对象一定的角度。axis参数必须是归一化矢量。

需要强调的是,平移和旋转功能会修改对象的positionrotation属性。即它们应用于对象坐标,而不是世界坐标,当对象呈现时,它们作为对象上的第一个模型转换应用。例如,如果对象不是定位在原点,那么旋转是世界坐标可以改变物体的位置。但是,更改对象的rotation属性值永远不会更改其位置。

有一个更有用的方法来设置旋转:obj.lookAt(vec),它旋转对象,使其朝向给定点。参数vec是Vector3类型,必须在对象自己的本地坐标系中表示。对象也旋转,使其"观察"方向等于属性obj.up的值默认值为 (0,1,0)。此功能可用于任何对象,但它对相机最有用。


原文链接:Introduction to Computer Graphics, Section 5.2 -- Building Objects

BimAnt翻译整理,转载请标明出处