CesiumJS PrimitiveAPI 高階著色入門 - 從引數化幾何與 Fabric 材質到著色器 - 下篇

四季留歌發表於2023-02-19

書接上文。

https://segmentfault.com/a/11...

3. 使用 GLSL 著色器

明確一個定義,在 Primitive API 中應用著色器,實際上是給 AppearancevertexShaderSourcefragmentShaderSourceMaterial 中的 fabric.source 設定著色器程式碼,它們所能控制的層級不太一樣。但是他們的共同目的都是為了 Geometry 服務的,它們會隨著 CesiumJS 的每幀 update 過程,建立 ShaderProgram,建立 DrawCommand,最終去到 WebGL 的底層渲染中。

3.1. 為 Fabric 材質新增自定義著色程式碼 - Fabric 材質的本質

有了之前的 fabric.uniformsfabric.materialsfabric.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. 社群實現案例 - 泛光牆體和流動線材質

參考 前端3D引擎-Cesium自定義動態材質 - 掘金

有了 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:

image.png

效果:

image.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() 函式將 position3DHighposition3DLow 合成為 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 例項的 depthMaskstencilMask 欄位。

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,以及 glTF3DTiles 等資料

Entity 和 DataSource 實際上底層也是在呼叫 Primitive 家族,只不過這兩個屬於 Viewer;中間的 Globe 與 ImageryLayer 和最後的 Primitive 家族,屬於 Scene 容器。

既然這篇是介紹的 Primitive,那麼就重點介紹 Primitive 家族。

你一定注意過可以向 scene.primitives 這個 PrimitiveCollection 中新增好幾種物件:ModelCesium3DTilesetPrimitiveCollection(是的,可以巢狀新增)、PointPrimitiveGroundPrimitiveClassificationPrimitive 以及本篇介紹的 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 的文章,列舉如下:

著名的 Cesium 3D 風場案例就是一個非常經典的應用:

4.3. Primitive 在 Scene 中的大致圖示

如果讀過我寫的原始碼系列,應該知道 Primitive 在 Scene 的更新位置(Scene 模組下的 render 函式),簡單放個圖吧:

primitive-in-where.jpg

這樣就能大致看到在什麼時候更新的 PrimitiveCollection 了。

有興趣瞭解原始碼渲染架構的可以去補補我之前寫的系列。

文末小結

這兩篇文章集合了我三年前的幾篇不成熟的文章,我終於系統地寫出了這幾個內容:

  • 一般性的 Primitive API 用法,包括

    • Geometry API 的自定義幾何、引數內建幾何
    • Appearance + Material API 所表達的 CesiumJS Fabric 材質規範
  • 提出似 Primitive 的概念,為之後自定義 Primitive 學習擋在 WebGL 原生介面之前的最底層 API 打下基礎
  • 簡單思考了 CesiumJS 的著色器設計和應用

希望對讀者有用。

相關文章