本文會講解一下Three.js(r105)的Sprite,主要包括以下幾個方面:
- 簡單介紹和使用
- Sprite的Geometry
- 始終朝向相機原理解析
- 大小不變原理解析
簡單介紹和使用
在專案中,我主要會使用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,灰色背景區域是畫布區域,下同):
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)
效果如下圖所示:
而給Sprite做同樣的操作(為了對比,把貼圖換成了純色):
const material = new THREE.SpriteMaterial({ color: 0x00ff00 })
const sprite = new THREE.Sprite(material)
sprite.rotation.x = Math.PI / 4
scene.add(sprite)
效果如下圖所示:
可以設定取消透視相機近大遠小的效果
透視相機(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:所有的Sprite共享一個Geometry;
註釋2:
- 每個頂點資訊的長度是5,前三項是頂點資訊的x、y、z值,後兩項是貼圖資訊,這兩個資訊儲存在了一個陣列中;
- 一共定義了四個頂點,兩個三角形。四個頂點的座標分別是
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,此處不顯示):
始終朝向相機原理解析
對於3D場景中的一個點,最後位置的計算方式一般如下:
gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4( position, 1.0 );
其中,position
是3D場景中的座標,這個座標要經過
- 物體自身座標系的矩陣變換(位移、旋轉、縮放等)(modelMatrix)
- 相機座標系的矩陣變換(viewMatrix)
- 投影矩陣變換(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:計算點
O
在相機座標系中的座標; - 註釋2-4:以
O
為中心,在垂直相機視線的平面Plane1上,直接求取各個頂點在相機座標系的座標; - 註釋5:上述求取的座標直接就在相機座標系了,所以不用再應用modelMatrix和viewMatrix,直接應用projectionMatrix就行了;
ABCD在空間的實際位置和實際繪製的ABCD的位置A'B'C'D'如下圖所示:
大小不變原理解析
前面我們提到,可以通過設定 SpriteMaterial
的 sizeAttenuation
屬性來取消透視相機近大遠小的效果。這塊的實現邏輯還是在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;
- 註釋2:把縮放比例和相機距離關聯上;
- 註釋3:計算A'B'C'D'的位置時,加上縮放的影響。
接下來,我們看下關鍵程式碼 scale *= - mvPosition.z;
為什麼是合理的?
首先,介紹下物體實際渲染大小和相機的關係。這裡,我們只考慮最簡單的情況:在和相機視線垂直的平面上的一條豎直線段 L
實際渲染的大小是多少?
計算過程如下圖所示:
在垂直方向上,實際渲染的大小為:
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
我們以 fov
是 90度
為例,因為這個時候的 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)
效果截圖如下:
上面的程式碼註釋1部分,我們也使用了和Y軸縮放一樣的縮放比例,最後實際顯示的X軸的畫素大小也是 100px
。如果我們想要X軸方向顯示不同的畫素大小怎麼辦呢?其實和計算垂直方向是一樣的道理。
通過上圖,可以發現X軸畫素大小的計算方法和Y軸一致。主要原因在於上圖中的註釋①②都應用了一個相機的寬高比,所以抵消了。這也就是為什麼 sprite.scale.x = 0.5
渲染的X軸的畫素大小也是 100px
的原因。
比如,還是上面那個例子,如果想讓X軸顯示 75px
,可以設定 scale.x = 75 * 2 / 400 = 0.375
,效果如下圖所示:
總結
本文介紹了Sprite的簡單使用和一些特性的實現原理,希望大家有所收穫!
如有錯誤,歡迎留言討論。