Three.js Sprite原始碼解析

luckness發表於2022-04-03

本文會講解一下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的簡單使用和一些特性的實現原理,希望大家有所收穫!

如有錯誤,歡迎留言討論。

相關文章