書接上文。
https://segmentfault.com/a/11...
3. 使用 GLSL 著色器
明確一個定義,在 Primitive API
中應用著色器,實際上是給 Appearance
的 vertexShaderSource
、fragmentShaderSource
或 Material
中的 fabric.source
設定著色器程式碼,它們所能控制的層級不太一樣。但是他們的共同目的都是為了 Geometry 服務的,它們會隨著 CesiumJS 的每幀 update 過程,建立 ShaderProgram,建立 DrawCommand,最終去到 WebGL 的底層渲染中。
3.1. 為 Fabric 材質新增自定義著色程式碼 - Fabric 材質的本質
有了之前的 fabric.uniforms
、fabric.materials
、fabric.components
基礎,你可能迫不及待想寫自定義著色器程式碼了。需要知道的一點是,有了 fabric.source
,就不相容 fabric.components
了,只能二選一。
關於 fabric.uniforms
,它的所有鍵名都可以在著色器程式碼中作為 GLSL Uniform 變數使用;關於 fabric.materials
,它的所有鍵名都可以在著色器程式碼中作為 GLSL 變數使用,也就是一個計算完成的 czm_material
結構體變數。
編寫 fabric.source
,實際上就是寫一個函式,它必須返回一個 czm_material
結構體,且輸入一些特定的、當前片元的資訊:
czm_material czm_getMaterial(czm_materialInput materialInput) {
czm_material material = czm_getDefaultMaterial(materialInput);
// ... 一系列處理
return material;
}
czm_material
已經在之前提及過了,它包含了實時渲染所需的一些基本材質引數。而 materialInput
這個變數,它是 czm_materialInput
型別的結構體,定義如下:
struct czm_materialInput {
float s;
vec2 st;
vec3 str;
mat3 tangentToEyeMatrix;
vec3 positionToEyeEC;
vec3 normalEC;
};
其中:
s
- 一維紋理座標st
- 二維紋理座標str
- 三維紋理座標。注意,materialInput.str.st
不一定就是materialInput.st
,也不能保證materialInput.st.s == materialInput.s
,例如對於橢球體而言,s
是底部到頂部的紋理座標,st
是經緯度,str
可能是範圍框的軸向值,這要參考原始碼tangentToEyeMatrix
- 片元切線空間到眼座標系的轉換矩陣,用於法線計算等positionToEyeEC
- 片元座標到觀察座標系(眼座標系)原點的向量,模長為片元到原點的距離,單位是米,可以用於反射或者折射計算normalEC
- 可用於凹凸對映、反射、折射計算中的眼睛座標系下的標準化法線
那個 czm_getDefaultMaterial
函式就是獲取預設的材質結構,這個函式很簡單:
czm_material czm_getDefaultMaterial(czm_materialInput materialInput) {
czm_material material;
material.diffuse = vec3(0.0);
material.specular = 0.0;
material.shininess = 1.0;
material.normal = materialInput.normalEC;
material.emission = vec3(0.0);
material.alpha = 1.0;
return material;
}
有了上面這些基礎,你就可以在這個 czm_getMaterial()
函式體裡寫你想要的片元著色內容了,注意任意 CesiumJS 的內建變數、自動 Uniform、結構體、內建函式都可以用。
3.2. 社群實現案例 - 泛光牆體和流動線材質
有了 3.1 的基礎,我們直接參考網上的一些案例。
const polylinePulseLinkFabric = {
type: 'PolylinePulseLink',
uniforms: {
color: Color.fromCssColorString('rgba(0, 255, 255, 1)'),
speed: 0,
image: 'http:/localhost:3000/images/bell.png', // 可以自己指定泛光牆體漸變材質
},
source: `czm_material czm_getMaterial(czm_materialInput materialInput) {
czm_material material = czm_getDefaultMaterial(materialInput);
// 獲取紋理座標
vec2 st = materialInput.st;
// 對 uniforms.image 的紋理圖片進行取樣
// 這裡需要根據時間來取樣,公式含義讀者自行研究,czm_frameNumber * 0.005 * speed 就是根據內建的
// czm_frameNumber,即當前幀數來代表大致時間
vec4 colorImage = texture2D(image, vec2(fract((st.t - speed * czm_frameNumber * 0.005)), st.t));
vec4 fragColor;
fragColor.rgb = color.rgb / 1.0;
fragColor = czm_gammaCorrect(fragColor); // 伽馬校正
material.alpha = colorImage.a * color.a;
material.diffuse = (colorImage.rgb + color.rgb) / 2.0;
material.emission = fragColor.rgb;
return material;
}`,
}
// 使用
const wallInstance = new GeometryInstance({
geometry: WallGeometry.fromConstantHeights({
positions: Cartesian3.fromDegreesArray([
97.0, 43.0,
107.0, 43.0,
107.0, 40.0,
97.0, 40.0,
97.0, 43.0,
]),
maximumHeight: 100000.0,
vertexFormat: MaterialAppearance.VERTEX_FORMAT,
}),
})
new Primitive({
geometryInstances: wallInstance,
appearance: new MaterialAppearance({
material: new Material({ fabric: polylinePulseLinkFabric }),
}),
})
其用到的漸變紋理可以是任意的一個橫向顏色至透明的漸變 png:
效果:
文中還介紹了 Entity
使用自定義 MaterialProperty
的方法,實際上底層也是 Material
:
class PolylineTrailMaterialProperty {
// ...
getType() {
return 'PolylineTrail'
}
getValue(time, result) {
if (!defined(result)) {
result = {}
}
result.color = Property.getValueOrClonedDefault(
this._color,
time,
Color.WHITE,
result.color
)
result.image = this.trailImage
result.time = ((performance.now() - this._time) % this.duration) / this.duration
return result
}
// ... 其餘封裝參考原文
}
const shader = `czm_material czm_getMaterial(czm_materialInput materialInput) {
czm_material material = czm_getDefaultMaterial(materialInput);
vec2 st = materialInput.st;
// 簡化版,顯然紋理取樣的 time 就來自 PolylineTrailMaterialProperty 了,不需要自己控制
vec4 colorImage = texture2D(image, vec2(fract(st.s - time), st.t));
material.alpha = colorImage.a * color.a;
material.diffuse = (colorImage.rgb + color.rgb) / 2.0;
return material;
}`
// 建立一個 'PolylineTrail' 型別的材質物件,並快取起來:
const polylineTrailMaterial = new Material({
fabric: {
type: 'PolylineTrail',
uniforms: {
color: new Color(1.0, 0.0, 0.0, 0.5),
image: 'http:/localhost:3000/images/bell.png',
time: 0,
},
source: shader,
}
})
詳細的完整封裝呼叫就不列舉了,需要有 Entity API
的使用經驗,不在本篇範圍。想知道 Property 是如何呼叫底層的,也需要自己研究 EntityAPI 的底層。
3.3. 直接定義外觀物件的兩個著色器
fabric.source
只能作用於材質的片元著色,當然也可以透過編寫外觀物件的兩個著色器實現更大自由。
預設情況下,MaterialAppearance
的頂點著色器與片元著色器是這樣的:
// GLSL 300 語法,頂點著色器
in vec3 position3DHigh;
in vec3 position3DLow;
in vec3 normal;
in vec2 st;
in float batchId;
out vec3 v_positionEC;
out vec3 v_normalEC;
out vec2 v_st;
void main() {
vec4 p = czm_computePosition();
v_positionEC = (czm_modelViewRelativeToEye * p).xyz; // position in eye coordinates
v_normalEC = czm_normal * normal; // normal in eye coordinates
v_st = st;
gl_Position = czm_modelViewProjectionRelativeToEye * p;
}
頂點著色器呼叫 czm_computePosition()
函式將 position3DHigh
和 position3DLow
合成為 vec4
的模型座標,然後乘以 czm_modelViewProjectionRelativeToEye
這個內建的矩陣,得到裁剪座標。然後是片元著色器:
in vec3 v_positionEC;
in vec3 v_normalEC;
in vec2 v_st;
void main()
{
vec3 positionToEyeEC = -v_positionEC;
vec3 normalEC = normalize(v_normalEC);
#ifdef FACE_FORWARD
normalEC = faceforward(normalEC, vec3(0.0, 0.0, 1.0), -normalEC);
#endif
czm_materialInput materialInput;
materialInput.normalEC = normalEC;
materialInput.positionToEyeEC = positionToEyeEC;
materialInput.st = v_st;
czm_material material = czm_getMaterial(materialInput);
#ifdef FLAT
out_FragColor = vec4(material.diffuse + material.emission, material.alpha);
#else
out_FragColor = czm_phong(normalize(positionToEyeEC), material, czm_lightDirectionEC);
#endif
}
如果想完全定製 Primitive 的著色行為,需要十分熟悉你所定製的 Geometry 的 VertexBuffer,也要控制好兩大著色器之間相互傳遞的值。
可以看得出來,Primitive API 使用的材質光照模型是馮氏(Phong)光照模型,可參考基本光照。
案例就不放了,有能力的可以直接參考 CesiumJS 曾經推過的一個 3D 風場視覺化的案例,它不僅自己寫了一個頂點著色器、片元著色器都是自定義的 Appearance,還寫了自定義的 Primitive(不是原生 Primitive,是連 DrawCommand 都自己建立的似 Primitive,似 Primitive 將在下文解釋)。
3.4. *原始碼中如何合併著色器
這段要講講原始碼,定位到 Primitive.prototype.update()
方法:
Primitive.prototype.update = function (frameState) {
const appearance = this.appearance;
const material = appearance.material;
let createRS = false;
let createSP = false;
// 一系列判斷是否需要重新建立 ShaderProgram,會修改 createSP 的值
if (createSP) {
const spFunc = defaultValue(
this._createShaderProgramFunction,
createShaderProgram
);
// 預設情況下,會使用 createShaderProgram 函式建立新的 ShaderProgram
spFunc(this, frameState, appearance);
}
};
使用 createShaderProgram
函式會用到外觀物件。
function createShaderProgram(primitive, frameState, appearance) {
// ...
// 裝配頂點著色器
let vs = primitive._batchTable.getVertexShaderCallback()(
appearance.vertexShaderSource
);
// 從這開始,是給外觀物件的片元著色器新增一系列 Buff
vs = Primitive._appendOffsetToShader(primitive, vs);
vs = Primitive._appendShowToShader(primitive, vs);
vs = Primitive._appendDistanceDisplayConditionToShader(
primitive,
vs,
frameState.scene3DOnly
);
vs = appendPickToVertexShader(vs);
vs = Primitive._updateColorAttribute(primitive, vs, false);
vs = modifyForEncodedNormals(primitive, vs);
vs = Primitive._modifyShaderPosition(primitive, vs, frameState.scene3DOnly);
// 裝配片元著色器
let fs = appearance.getFragmentShaderSource();
fs = appendPickToFragmentShader(fs); // 為片元著色器新增 pick 所需的 vec4 顏色 in(varying) 變數
// 生成 ShaderProgram,並予以校驗匹配情況
primitive._sp = ShaderProgram.replaceCache({
context: context,
shaderProgram: primitive._sp,
vertexShaderSource: vs,
fragmentShaderSource: fs,
attributeLocations: attributeLocations,
});
validateShaderMatching(primitive._sp, attributeLocations);
// ...
}
總之,外觀的兩個著色器也僅僅是 CesiumJS 這個龐大的著色器系統中的一部分,仍有非常多的狀態需要新增到著色器物件(ShaderProgram
)上。
可能通用的 Primitive 就是需要這麼多狀態附加吧,讀者可以自行研究其它似 Primitive 的著色器建立過程。似 Primitive 將於本文的最後一大節說明。
4. 底層知識
4.1. 渲染狀態物件
注意到一個東西:appearance.renderState
,在建立外觀物件時可以傳入一個物件字面量:
new MaterialAppearance({
// ...
renderState: {},
})
也可以不傳遞,預設會生成這樣一個物件:
{
depthTest: {
enabled: true,
},
depthMask: false,
blending: BlendingState.ALPHA_BLEND, // 來自 BlendingState 的靜態常量成員 ALPHA_BLEND
}
這個物件會記錄在外觀物件上,伴隨著 Primitive 的更新過程,還會增增減減、修改狀態值,在 Primitive 的 createRenderStates
函式中,用這個物件的即時值建立或取得快取的 RenderState
例項,等待著在 createCommands
函式中傳遞給 DrawCommand
。
RenderState
的狀態值和 WebGL 最終渲染有關,在 Context
模組的 beginDraw
函式、applyRenderState
函式中,就有大量使用渲染狀態的程式碼(還要往裡進去兩三層),舉例:
function applyDepthMask(gl, renderState) {
gl.depthMask(renderState.depthMask);
}
function applyStencilMask(gl, renderState) {
gl.stencilMask(renderState.stencilMask);
}
這兩個函式就是在修改 WebGL 全域性狀態的值,值來自 RenderState
例項的 depthMask
和 stencilMask
欄位。
CesiumJS 漫長的一幀的更新過程中,有兩個狀態物件可以關注一下,一個是掛載在 Scene
上的 幀狀態 物件(FrameState 例項),另一個就是身處於各個實際三維物件上的 渲染狀態 物件(RenderState 例項)。前者記錄一些整裝待發的資源,例如 DrawCommand
清單等,後者則為三維物件標記在實際渲染時要更改 WebGL 全域性狀態的狀態值。兩大狀態的連結橋樑是 DrawCommand
。
還有一個貫穿於幀更新過程的狀態物件:統一值物件(UniformState 例項),是 Context 的成員欄位,作用同其名,用於更新要傳給著色器的統一值。
4.2. 似 Primitive 物件與建立似 Primitive 物件
這一節介紹的內容將有助於理解 CesiumJS 單幀更新的核心思路。別看 CesiumJS 擁有這麼多載入資料、模型的 API 類,實際上是可以根據它們在場景結構中的層級,做個簡單的分類:
- Entity 與 DataSource,高層級的資料 API,是高階的人類友好的資料格式載入封裝,還能與時間關聯
- Globe 與 ImageryLayer,負責地球本身的渲染,含皮膚(影像 Provider)和肌肉(地形 Provider)
- Primitive 家族,含本篇介紹的
Primitive
,以及glTF
、3DTiles
等資料
Entity 和 DataSource 實際上底層也是在呼叫 Primitive 家族,只不過這兩個屬於 Viewer;中間的 Globe 與 ImageryLayer 和最後的 Primitive 家族,屬於 Scene 容器。
既然這篇是介紹的 Primitive,那麼就重點介紹 Primitive 家族。
你一定注意過可以向 scene.primitives
這個 PrimitiveCollection
中新增好幾種物件:Model
、Cesium3DTileset
、PrimitiveCollection
(是的,可以巢狀新增)、PointPrimitive
、GroundPrimitive
、ClassificationPrimitive
以及本篇介紹的 Primitive
均可以,在 1.101 版本的更新中還新增了一個體素:VoxelPrimitive
(仍在測試)。
我將這類 Primitive 家族類,稱為 PrimitiveLike
類,即“似 Primitive”。
這些似 Primitive 有一個共同點,才能新增到 PrimitiveCollection 中,伴隨著場景的單幀更新過程進入 WebGL 渲染。它們的共同點:
- 有
update
例項方法 - 有
destroy
例項方法
在 update
方法中,它接受 FrameState
物件傳入,然後經過自己的渲染邏輯,建立出一系列的指令物件(主要是 DrawCommand
),並送入幀狀態物件的指令陣列中,待更新完畢最終進入 WebGL 的渲染。
所以知道這些有什麼用呢?
Cesium 團隊是一個求穩的團隊,2012 年還在內測的時候,ES5 標準才落地沒多久,哪怕現在的程式碼也仍然是使用函式來建立類,而不是用 ES6 的 Class(儘管現在切換過去已經沒什麼技術難點了)。ES6 實現類繼承是很簡單的,但是在那個時候就比較困難了。像這種似 Primitive 的情況,ES6 來寫實際上就是有一個共同的父類罷了,如果是 TypeScript,那更是可以抽象為更輕量的 interface
:
interface PrimitiveLike {
update(frameState: FrameState): void
destroy(): void
}
這構成了編寫自定義 Primitive 的基礎,CesiumJS 團隊和 CesiumLab 核心成員 vtxf 均有一些古早的資料,告訴你如何編寫自定義的 Primitive 類。我之前也寫有一篇較為相近的、介紹 DrawCommand
並建立簡易自定義三角形 Primitive 的文章,列舉如下:
- CesiumJS Wiki - Geometry and Appearance
- cesiumlab vtxf - cesium-custom-primitive
- [知乎 - Cesium DrawCommand [1] 不談地球 畫個三角形](https://zhuanlan.zhihu.com/p/...)
著名的 Cesium 3D 風場案例就是一個非常經典的應用:
4.3. Primitive 在 Scene 中的大致圖示
如果讀過我寫的原始碼系列,應該知道 Primitive 在 Scene 的更新位置(Scene
模組下的 render
函式),簡單放個圖吧:
這樣就能大致看到在什麼時候更新的 PrimitiveCollection 了。
有興趣瞭解原始碼渲染架構的可以去補補我之前寫的系列。
文末小結
這兩篇文章集合了我三年前的幾篇不成熟的文章,我終於系統地寫出了這幾個內容:
一般性的
Primitive API
用法,包括Geometry API
的自定義幾何、引數內建幾何Appearance + Material API
所表達的 CesiumJS Fabric 材質規範
- 提出似 Primitive 的概念,為之後自定義 Primitive 學習擋在 WebGL 原生介面之前的最底層 API 打下基礎
- 簡單思考了 CesiumJS 的著色器設計和應用
希望對讀者有用。