CesiumJS PrimitiveAPI 高級着色入門 - 從參數化幾何與 Fabric 材質到着色器 - 下篇


書接上文 https://www.cnblogs.com/onsummer/p/cesium-primitive-api-tutorial.html

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

效果:

image

文中還介紹了 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 函數),簡單放個圖吧:

image

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

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

文末小結

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

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

    • Geometry API 的自定義幾何、參數內置幾何

    • Appearance + Material API 所表達的 CesiumJS Fabric 材質規範

  • 提出似 Primitive 的概念,爲之後自定義 Primitive 學習擋在 WebGL 原生接口之前的最底層 API 打下基礎

  • 簡單思考了 CesiumJS 的着色器設計和應用

希望對讀者有用。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章