3

本文会讲解一下Three.js(r105)的Sprite,主要包括以下几个方面:

  1. 简单介绍和使用
  2. Sprite的Geometry
  3. 始终朝向相机原理解析
  4. 大小不变原理解析

简单介绍和使用

在项目中,我主要会使用Sprite创建一些三维场景中的标签。下面举个简单的例子来了解下Sprite的基本用法:

const map = new THREE.TextureLoader().load("sprite.png")
const material = new THREE.SpriteMaterial({ map })
const sprite = new THREE.Sprite(material)
scene.add(sprite)

效果如下(画布大小300px * 400px,灰色背景区域是画布区域,下同):
 title=

Sprite有几个特性:

是一个平面

Sprite是一个平面,也就是Sprite的Geometry描述的是一个平面矩形。下面讲解源码的时候会说到。

始终朝向相机

我们知道,3D场景中的物体是由一个个三角形组合出来的,每个三角形都有一个法线。法线方向和相机视线方向可以是任意关系。Sprite的特性就是这个平面矩形的法线方向和相机视线方向始终是平行的,方向相反。

最后的渲染效果就是绘制出来的Sprite始终是矩形,而不会存在变形

比如把一个普通平面沿着X轴旋转45度:

const geometry = new THREE.PlaneGeometry(1, 1)
const planeMaterial = new THREE.MeshBasicMaterial({ color: 0x00ff00 })
const plane = new THREE.Mesh(geometry, planeMaterial)
plane.rotation.x = Math.PI / 4
scene.add(plane)

效果如下图所示:
 title=

而给Sprite做同样的操作(为了对比,把贴图换成了纯色):

const material = new THREE.SpriteMaterial({ color: 0x00ff00 })
const sprite = new THREE.Sprite(material)
sprite.rotation.x = Math.PI / 4
scene.add(sprite)

效果如下图所示:
 title=

可以设置取消透视相机近大远小的效果

透视相机(PerspectiveCamera)模拟了人眼看世界的效果:近大远小。

Sprite默认也是近大远小的,但是你可以通过SpriteMaterial的sizeAttenuation属性来取消这个效果。后面会详细讲解sizeAttenuation的实现原理。

Sprite的Geometry

先看下Sprite构造函数的源码(Sprite.js):

var geometry; // 注释1:全局geometry

function Sprite( material ) {

    Object3D.call( this );

    this.type = 'Sprite';

    if ( geometry === undefined ) { // 注释1:全局geometry

        geometry = new BufferGeometry(); // 注释1:全局geometry

        var float32Array = new Float32Array( [ // 注释2:顶点信息和贴图信息,一共四个顶点
            - 0.5, - 0.5, 0, 0, 0,
            0.5, - 0.5, 0, 1, 0,
            0.5, 0.5, 0, 1, 1,
            - 0.5, 0.5, 0, 0, 1
        ] );

        var interleavedBuffer = new InterleavedBuffer( float32Array, 5 ); // 注释2:每个顶点信息包括5个数据

        geometry.setIndex( [ 0, 1, 2,    0, 2, 3 ] ); // 注释2:两个三角形
        geometry.addAttribute( 'position', new InterleavedBufferAttribute( interleavedBuffer, 3, 0, false ) ); // 注释2:顶点信息,取前三项
        geometry.addAttribute( 'uv', new InterleavedBufferAttribute( interleavedBuffer, 2, 3, false ) ); // 注释2:贴图信息,取后两项

    }

    this.geometry = geometry; // 注释1:全局geometry
    this.material = ( material !== undefined ) ? material : new SpriteMaterial();

    this.center = new Vector2( 0.5, 0.5 ); // 注释3:center默认是(0.5, 0.5)

}

从上面的代码我们看出两个信息:

  1. 注释1:所有的Sprite共享一个Geometry;
  2. 注释2:

    1. 每个顶点信息的长度是5,前三项是顶点信息的x、y、z值,后两项是贴图信息,这两个信息存储在了一个数组中;
    2. 一共定义了四个顶点,两个三角形。四个顶点的坐标分别是 A(-0.5, -0.5, 0)B(0.5, -0.5, 0)C(0.5, 0.5, 0)D(-0.5, 0.5, 0) 。两个三角形是 T1(0, 1, 2)T2(0, 2, 3) ,也就是 T1(A, B, C)T2(A, C, D) 。这两个三角形组成的矩形的中心点 O 的坐标是 (0, 0, 0) 。这两个三角形组成了一个 1 X 1 的正方形。如下图所示(Z轴都是0,此处不显示):
      geometry

始终朝向相机原理解析

对于3D场景中的一个点,最后位置的计算方式一般如下:

gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4( position, 1.0 );

其中,position是3D场景中的坐标,这个坐标要经过

  1. 物体自身坐标系的矩阵变换(位移、旋转、缩放等)(modelMatrix)
  2. 相机坐标系的矩阵变换(viewMatrix)
  3. 投影矩阵变换(projectionMatrix)

也就是,最后使用的坐标是3D场景中的坐标经过一系列固有的变换得到的。其中,上述相机坐标系的矩阵变换和相机是有关系的,也就是相机的信息会影响最后的坐标。

但是,Sprite是始终朝向相机的。我们可以推测,Sprite位置的计算肯定不是走的上面这个固有变换。下面让我们看下Sprite的实现方式。

这块的逻辑是在shader里面实现的(sprite_vert.glsl.js):

void main() {
    // ...
    vec4 mvPosition = modelViewMatrix * vec4( 0.0, 0.0, 0.0, 1.0 ); // 注释1:应用模型和相机矩阵在点O上

    // 注释6:缩放相关

    vec2 alignedPosition = ( position.xy - ( center - vec2( 0.5 ) ) ) * scale;
    // 注释2:center默认值是vec2(0.5),scale是模型的缩放,简单情况下是1,所以,此处可以简化为:
    // vec2 alignedPosition = position.xy;

    vec2 rotatedPosition;
    rotatedPosition.x = cos( rotation ) * alignedPosition.x - sin( rotation ) * alignedPosition.y;
    rotatedPosition.y = sin( rotation ) * alignedPosition.x + cos( rotation ) * alignedPosition.y;
    // 注释3:应用旋转,没有旋转的情况下,rotatedPosition = alignedPosition
    // 其实就是把rotation等于0带入上述计算过程

    mvPosition.xy += rotatedPosition; // 注释4:在点O的基础上,重新计算每个顶点的坐标,Z分量不变,保证相机视线和Sprite是垂直的

    gl_Position = projectionMatrix * mvPosition; // 注释5:应用投影矩阵
    // ...
}

顶点坐标的计算过程如下:

  1. 注释1:计算点 O 在相机坐标系中的坐标;
  2. 注释2-4:以 O 为中心,在垂直相机视线的平面Plane1上,直接求取各个顶点在相机坐标系的坐标;
  3. 注释5:上述求取的坐标直接就在相机坐标系了,所以不用再应用modelMatrix和viewMatrix,直接应用projectionMatrix就行了;

ABCD在空间的实际位置和实际绘制的ABCD的位置A'B'C'D'如下图所示:
toCamera

大小不变原理解析

前面我们提到,可以通过设置 SpriteMaterialsizeAttenuation 属性来取消透视相机近大远小的效果。这块的实现逻辑还是在shader里面实现的(sprite_vert.glsl.js):

void main() {

    // ...

    vec4 mvPosition = modelViewMatrix * vec4( 0.0, 0.0, 0.0, 1.0 );

    vec2 scale; // 注释1:根据模型矩阵计算缩放
    scale.x = length( vec3( modelMatrix[ 0 ].x, modelMatrix[ 0 ].y, modelMatrix[ 0 ].z ) );
    scale.y = length( vec3( modelMatrix[ 1 ].x, modelMatrix[ 1 ].y, modelMatrix[ 1 ].z ) );

    #ifndef USE_SIZEATTENUATION

        bool isPerspective = ( projectionMatrix[ 2 ][ 3 ] == - 1.0 ); // 注释2:判断是否是透视相机

        if ( isPerspective ) scale *= - mvPosition.z; // 注释2:根据相机距离应用不同的缩放值

    #endif

    vec2 alignedPosition = ( position.xy - ( center - vec2( 0.5 ) ) ) * scale;
    // 注释2:顶点信息计算考虑缩放因子,此处,同样不考虑center的影响,简化后如下:
    // vec2 alignedPosition = position.xy * scale;

    // ... 注释3:同上,计算顶点位置过程

    #include <logdepthbuf_vertex>
    #include <clipping_planes_vertex>
    #include <fog_vertex>

}

透视相机有近大远小的效果。如果要消除这个效果,可以给物体在不同的相机深度的时候,设置不同的缩放比例。显然,这个缩放比例和相机的深度相关。Sprite也是这样实现的:

  1. 注释1:计算模型本身应用的缩放,包括水平方向和垂直方向。在没有设置的情况下,这两个方向上的缩放比例都是1;
  2. 注释2:把缩放比例和相机距离关联上;
  3. 注释3:计算A'B'C'D'的位置时,加上缩放的影响。

接下来,我们看下关键代码 scale *= - mvPosition.z; 为什么是合理的?

首先,介绍下物体实际渲染大小和相机的关系。这里,我们只考虑最简单的情况:在和相机视线垂直的平面上的一条竖直线段 L 实际渲染的大小是多少?

计算过程如下图所示:

 title=

在垂直方向上,实际渲染的大小为:

PX = L / (2 * Z * tan(fov / 2)) * canvasHeight

其中,L 是物体的实际大小,Z 是物体距离相机的远近,fov 是弧度值,canvasHeight是画布的高度。

显然,实际显示的大小是和Z相关的。Z越大,PX的值越小,Z越小,PX的值越大。那么,要想消除Z的影响,我们可以给L乘上一个Z,也就是L' = L * Z

PX = L' / (2 * Z * tan(fov / 2)) * canvasHeight
PX = (L * Z) / (2 * Z * tan(fov / 2)) * canvasHeight
PX = L / (2 * tan(fov / 2)) * canvasHeight

在物体大小固定,相机视角固定,画布固定的情况下,实际显示的大小PX就是一个固定的值,也就实现了Sprite大小不变的效果。

这也是上面 scale *= - mvPosition.z; 的作用。mvPosition.z 就是我们上述公式中的 Z 。之所以还有一个负号,是因为在相机坐标系下,相机看向的方向是Z轴负方向,所以出现在相机视线内的物体的Z值是负的,所以加了一个负号变成正数。

那么,如何设置Sprite的显示大小呢,比如让Sprite的显示高度为100px

其实,从上面的公式我们就可以得出:

PX = L / (2 * tan(fov / 2)) * canvasHeight
L = PX * (2 * tan(fov / 2)) / canvasHeight

我们以 fov90度 为例,因为这个时候的 tan(PI / 2 / 2) 正好是 1 ,所以计算起来和看起来都比较直观,此时:

L = PX * (2 * tan(fov / 2)) / canvasHeight
L = PX * 2 / canvasHeight

在Sprite的Geometry部分,我们知道Geometry是一个 1 X 1 的矩形。所以 L 是多少,我们给物体添加 L 倍的缩放即可。

比如当相机视角是90度,画布大小是300px * 400px,想要Sprite显示的高度是100px的话,设置scale为 100 * 2 / 400 = 0.5 即可:

const material = new THREE.SpriteMaterial({
  color: 0xff0000, // 使用纯色的材质举例,纯色的容易判断边界,可以通过截图的方式验证实际渲染的像素大小是否正确
  sizeAttenuation: false // 关闭大小跟随相机距离变化的特性
})
const sprite = new THREE.Sprite(material)
sprite.scale.x = 0.5 // 注释1:X轴方向也设置为0.5
sprite.scale.y = 0.5
scene.add(sprite)

效果截图如下:
 title=

上面的代码注释1部分,我们也使用了和Y轴缩放一样的缩放比例,最后实际显示的X轴的像素大小也是 100px 。如果我们想要X轴方向显示不同的像素大小怎么办呢?其实和计算垂直方向是一样的道理。
 title=

通过上图,可以发现X轴像素大小的计算方法和Y轴一致。主要原因在于上图中的注释①②都应用了一个相机的宽高比,所以抵消了。这也就是为什么 sprite.scale.x = 0.5 渲染的X轴的像素大小也是 100px 的原因。

比如,还是上面那个例子,如果想让X轴显示 75px ,可以设置 scale.x = 75 * 2 / 400 = 0.375 ,效果如下图所示:
 title=

总结

本文介绍了Sprite的简单使用和一些特性的实现原理,希望大家有所收获!

如有错误,欢迎留言讨论。


luckness
6.2k 声望5.1k 粉丝