Cesium DrawCommand [1] 不談地球 畫個三角形

語言: CN / TW / HK

目錄

    • 0.1. 原始碼中的 DrawCommand
    • 1.1. 構成要素 - VertexArray
    • 1.2. 構成要素 - ShaderProgram
    • 1.3. 構成要素 - WebGL 的統一值
    • 1.4. 渲染狀態物件 - RenderState
      • ① 繪製的通道型別 - Pass
      • ② 繪製的圖元型別 - WebGL 繪製常數
      • ③ 離屏繪製容器 - Framebuffer
      • ④ 模型座標變換矩陣 - Matrix4
    • 1.6. 我們來實踐一發純色三角形
  • 2. 意義 - 自定義 Primitive(PrimitiveLike)

0. 前言

Primitive API 是公開的 API 的最底層了,它面向的場景是高效能、可自定義材質著色器( Appearance API + FabricMaterial Specification )、靜態三維物體。

儘管如此, Primitive API 仍然封裝了大量幾何體類、材質類、WebWorker,而且目前開放自定義著色器 API 的只有三維模型類的新架構,還沒下放到 Primitive API

如果 API 包袱不想那麼重,又希望可以使用自己的模型格式(必須是三角面),那麼私有的 DrawCommand + VertexArray 介面就非常合適了,它的風格已經是最接近 CesiumJS WebGL 底層的一類 API 了。

DrawCommand ,是 Cesium 封裝 WebGL 的一個優秀設計,它把繪圖資料( VertexArray )和繪圖行為( ShaderProgram )作為一個物件,待時機合適,也就是 Scene 執行 executeCommand 函式時,幀狀態物件上所有的指令物件就會使用 WebGL 函式執行,要什麼就 bind 什麼,做到了在繪圖時的用法一致,上層應用介面只需生成指令物件。

0.1. 原始碼中的 DrawCommand

譬如在 Primitive.js 模組中的 createCommands 函式,它就是負責把 Primitive 物件的引數化資料或 WebWorker 計算來的資料合併生成 DrawCommand 的地方:

function createCommands(/* 引數省略 */) {
  // ...
  const length = colorCommands.length;
  let vaIndex = 0;
  for (let i = 0; i < length; ++i) {
    let colorCommand;

    // ...

    colorCommand = colorCommands[i];
    if (!defined(colorCommand)) {
      colorCommand = colorCommands[i] = new DrawCommand({
        owner: primitive, // 入參,即 Primitive 物件
        primitiveType: primitive._primitiveType,
      });
    }
    colorCommand.vertexArray = primitive._va[vaIndex]; // VertexArray
    colorCommand.renderState = primitive._frontFaceRS; // 渲染狀態
    colorCommand.shaderProgram = primitive._sp; // ShaderProgram
    colorCommand.uniformMap = uniforms; // 統一值
    colorCommand.pass = pass; // 該指令的通道順序
  }
  // ...
}

1. 建立

1.1. 構成要素 - VertexArray

Cesium 把 WebGL 的頂點緩衝和索引緩衝包裝成了 Buffer ,然後為了方便,將這些頂點相關的緩衝繫結在了一個物件裡,叫做 VertexArray ,內部會啟用 WebGL 的 VAO 功能。

最快速建立 VertexArray 的辦法,就是呼叫其靜態方法 VertexArray.fromGeometry() ,但是這需要 Geometry API 來幫忙。

這裡想直接使用 Buffer 來說明,那麼就得先建立 Buffer

const positionBuffer = Buffer.createVertexBuffer({
  context: context,
  sizeInBytes: 12,
  usage: BufferUsage.STATIC_DRAW,
  typedArray: new Float32Array([/* ... */])
})
const attributes = [
  {
    index: 0,
    enabled: true,
    vertexBuffer: positionBuffer,
    componentsPerAttribute: 3,
    componentDatatype: ComponentDatatype.FLOAT,
    normalize: false,
    offsetInBytes: 0,
    strideInBytes: 0, // 緊密組合在一起,沒有 byteStride
    instanceDivisor: 0 // 不例項化繪製
  }
]

呼叫 Buffer 私有類的靜態方法 createVertexBuffer() ,即可建立內建了 WebGLBuffer 的頂點緩衝物件 positionBuffer ,然後使用普通的物件陣列創建出 頂點屬性 attributes ,每個物件就描述了一個頂點屬性。接下來就可以拿這些簡單的材料建立 VertexArray 了:

const va = new VertexArray({
  context: context,
  attributes: attributes
})

Context 封裝了 WebGL 的各種函式呼叫,你可以從 Scene 中或直接從 FrameState 上獲取到。

這一步建立的 Buffer ,頂點座標是直角座標系下的,是最原始的座標值,除非在著色器裡做矩陣變換,或者這些直角座標就在世界座標系的地表附近。它是一堆沒有具體語義的、純粹數學幾何的座標,與渲染管線無關。所以,對於地表某處的座標點,通常要配合 ENU 轉換矩陣 + 內建的 MVP 轉換矩陣來使用,見 1.6 的例子。

這裡還有一個例子,使用了兩個頂點屬性(VertexAttribute):

const positionBuffer = Buffer.createVertexBuffer({
  context: context,
  sizeInBytes: 12,
  usage: BufferUsage.STATIC_DRAW
})
const normalBuffer = Buffer.createVertexBuffer({
  context: context,
  sizeInBytes: 12,
  usage: BufferUsage.STATIC_DRAW
})
const attributes = [
  {
    index: 0,
    vertexBuffer: positionBuffer,
    componentsPerAttribute: 3,
    componentDatatype: ComponentDatatype.FLOAT
  },
  {
    index: 1,
    vertexBuffer: normalBuffer,
    componentsPerAttribute: 3,
    componentDatatype: ComponentDatatype.FLOAT
  }
]
const va = new VertexArray({
  context: context,
  attributes: attributes
})

這裡把座標緩衝和法線緩衝分開存到兩個物件裡了,其實 WebGL 可以用位元組交錯的格式,把全部頂點屬性的緩衝都合併成一個的方式的,就不具體講了,讀者可以自行查閱 WebGL 中 WebGLBuffer 的用法。

1.2. 構成要素 - ShaderProgram

WebGL 的著色器也被 CesiumJS 封裝了,自帶快取機制,並使用大量正則等手段做了著色器原始碼匹配、解析、管理。

著色器程式碼由 ShaderSource 管理, ShaderProgram 則管理起多個著色器原始碼,也就是著色器本身。使用 ShaderCache 作為著色器程式的快取容器。它們的層級關係如下:

Context
  ┖ ShaderCache
    ┖ ShaderProgram
      ┖ ShaderSource

你可以自己建立 ShaderSourceShaderProgram ,並通過 Context 新增到 ShaderCache 中。

舉例:

new ShaderSource({
  sources : [GlobeFS]
})

new ShaderProgram({
  gl: context._gl,
  logShaderCompilation: context.logShaderCompilation,
  debugShaders: context.debugShaders,
  vertexShaderSource: vertexShaderSource,
  vertexShaderText: vertexShaderText,
  fragmentShaderSource: fragmentShaderSource,
  fragmentShaderText: fragmentShaderText,
  attributeLocations: attributeLocations,
})

但是通常會選擇更直接的方式:

const vertexShaderText = `attribute vec3 position;
void main() {
  gl_Position = czm_projection * czm_modelView * vec4(position, 1.0);
}`
const fragmentShaderText = `uniform vec3 color;
void main() {
  gl_FragColor=vec4( color , 1. );
}`

const program = ShaderProgram.fromCache({
  context: context,
  vertexShaderSource: vertexShaderText,
  fragmentShaderSource: fragmentShaderText,
  attributeLocations: attributeLocations
})

使用 ShaderProgram.fromCache 靜態方法會自動幫你把著色器快取到 ShaderCache 容器中。

著色器程式碼可以直接使用內建的常量和自動統一值,這是預設會加上去的。

attributeLocation 是什麼?它是一個很普通的 JavaScript 物件:

{
  "position": 0,
  "normal": 1,
  "st": 2,
  "bitangent": 3,
  "tangent": 4,
  "color": 5
}

它指示頂點屬性在著色器中的位置。

1.3. 構成要素 - WebGL 的統一值

這個比較簡單:

const uniforms = {
  color() {
    return Cesium.Color.HONEYDEW 
  }
}

使用一個 JavaScript 物件即可,每個成員必須得是 方法 ,返回的值符合 Uniform 的要求即可:

  • Cesium.Matrix2/3/4mat2/3/4
  • Cesium.Cartesian2/3/4vec2/3/4
  • Cesium.Numberfloat
  • Cesium.Colorvec4
  • Cesium.Texturesampler2D
  • ...

請查閱 Renderer/createUniform.js 中的程式碼,例如 UniformFloatVec3 就可以對應 ColorCartesian4 等等。

這個 uniforms 物件最終會在 Context 執行繪製時,與系統的自動統一值( AutomaticUniforms )合併。

Context.prototype.draw = function (/*...*/) {
  // ...
  continueDraw(this, drawCommand, shaderProgram, uniformMap);
  // ...
}

1.4. 渲染狀態物件 - RenderState

渲染狀態物件是必須傳遞給 DrawCommand 的。渲染狀態物件型別是 RenderState ,它與 ShaderProgram 類似,都提供了靜態方法來“快取式”建立:

const renderState = RenderState.fromCache({
  depthTest: {
    enabled: true
  }
})

哪怕什麼都不傳遞: RenderState.fromCache() ,內部也會返回一個渲染狀態。

它傳遞渲染資料之外一切參與 WebGL 渲染的狀態值,在 RenderState 中有詳細的預設列表參考,上述程式碼顯式指定要進行深度測試。

1.5. 其它構成因子

建立繪圖指令除了 1.1 ~ 1.3 成分之外,還有其它可選項。

① 繪製的通道型別 - Pass

CesiumJS 不是粗暴地把幀狀態物件上的 Command 遍歷一遍就繪製了的,在 Scene 的渲染過程中,除了生成三大 Command,還有一步要對 Command 進行通道排序。

通道,是一個列舉型別,儲存在 Pass.js 模組中。不同通道有不同的優先順序,譬如在 1.6 中指定的通道是 Cesium.Pass.OPAQUE ,即不透明通道。在 1.93 版本,通道的順序為列舉值:

const Pass = {
  ENVIRONMENT: 0,
  COMPUTE: 1,
  GLOBE: 2,
  TERRAIN_CLASSIFICATION: 3,
  CESIUM_3D_TILE: 4,
  CESIUM_3D_TILE_CLASSIFICATION: 5,
  CESIUM_3D_TILE_CLASSIFICATION_IGNORE_SHOW: 6,
  OPAQUE: 7,
  TRANSLUCENT: 8,
  OVERLAY: 9,
  NUMBER_OF_PASSES: 10,
}

可見, OPAQUE (不透明通道)的優先順序比 TRANSLUCENT (透明通道)高。

這個通道與其它圖形 API 的通道可能略不一樣,因為你只能使用這個值去指定順序,而不是自己寫一個通道來合成渲染(例如 ThreeJS 或 WebGPU)。

② 繪製的圖元型別 - WebGL 繪製常數

即指定 VertexArray 中頂點的拓撲格式,在 WebGL 中是通過 drawArrays 指定的:

gl.drawArrays(gl.TRIANGLES, 0, 3)

這個 gl.TRIANGLES 就是圖元型別,是一個常數。Cesium 全部封裝在 PrimitiveType.js 模組匯出的列舉中了:

console.log(PrimitiveType.TRIANGLES) // 4

預設就是 PrimitiveType.TRIANGLES ,所以在 1.6 程式碼中我們並不需要傳遞。

③ 離屏繪製容器 - Framebuffer

CesiumJS 支援把結果畫到 Renderbuffer ,也就是 RTR(Render to RenderBuffer) 離屏繪製。繪製到渲染緩衝,是需要幀緩衝容器的,CesiumJS 把 WebGL 1/2 中幀緩衝相關的 API 都封裝好了(嚴格來說,把 WebGL 中的 API 基本都封裝了一遍)。

本文只簡單提一提,關於幀緩衝離屏繪製,以後有機會再介紹,法克雞絲的部落格有比較系統的介紹(雖然比較舊,不過思路還是在的)。

④ 模型座標變換矩陣 - Matrix4

Matrix4 型別的變數在建立 DrawCommand 時傳遞進去,它最終會傳遞到 CesiumJS 的內部統一值: czm_model (模型矩陣)上,而無需你在 uniform 中指定,你可以在頂點著色器中使用它來對 VertexArray 中的頂點進行模型矩陣變換。見 1.6 中的頂點著色器經典的 MVP 相乘。

⑤ 其它

  • cull/occlude: 視錐剔除 + 地平線剔除組合技,Boolean
  • orientedBoundingBox/boundingVolume: 範圍框
  • count: number,WebGL 繪製時要畫多少個點
  • offset: number,WebGL 繪製時從多少偏移量開始用頂點資料
  • instanceCount: number,例項繪製有關
  • castShadows/receiveShadows: Boolean,陰影相關
  • pickId: string,若沒定義,在 Pick 通道的繪製中將使用深度資料;若定義了將在 GLSL 中轉化為 pick id
  • ...

這些都可以在 DrawCommand 中找到對應的欄位,按需設定即可。

1.6. 我們來實踐一發純色三角形

萬事俱備,直接硬搓一個能產生三角形繪製指令的 StaticTrianglePrimitive ,為了便於在官方沙盒中使用,我給官方 API 加上了名稱空間:

const modelCenter = Cesium.Cartesian3.fromDegrees(112, 23, 0)
const modelMatrix = Cesium.Transforms.eastNorthUpToFixedFrame(modelCenter)

const vertexShaderText = `attribute vec3 position;
void main() {
  gl_Position = czm_projection * czm_view * czm_model * vec4(position, 1.0);
}`
const fragmentShaderText = `uniform vec3 u_color;
void main(){
  gl_FragColor = vec4(u_color, 1.0);
}`

const createCommand = (frameState, matrix) => {
  const attributeLocations = {
    "position": 0,
  }
  const uniformMap = {
    u_color() {
      return Cesium.Color.HONEYDEW
    },
  }
  const positionBuffer = Cesium.Buffer.createVertexBuffer({
    usage: Cesium.BufferUsage.STATIC_DRAW,
    typedArray: new Float32Array([
      10000, 50000, 5000,
      -20000, -10000, 5000,
      50000, -30000, 5000,
    ]),
    context: frameState.context,
  })
  const vertexArray = new Cesium.VertexArray({
    context: frameState.context,
    attributes: [{
      index: 0, // 等於 attributeLocations['position']
      vertexBuffer: positionBuffer,
      componentsPerAttribute: 3,
      componentDatatype: Cesium.ComponentDatatype.FLOAT
    }]
  })
  const program = Cesium.ShaderProgram.fromCache({
    context: frameState.context,
    vertexShaderSource: vertexShaderText,
    fragmentShaderSource: fragmentShaderText,
    attributeLocations: attributeLocations,
  })
  const renderState = Cesium.RenderState.fromCache({
    depthTest: {
      enabled: true
    }
  })
  return new Cesium.DrawCommand({
    modelMatrix: matrix,
    vertexArray: vertexArray,
    shaderProgram: program,
    uniformMap: uniformMap,
    renderState: renderState,
    pass: Cesium.Pass.OPAQUE,
  })
}

/* ----- See Here ↓ ------ */

class StaticTrianglePrimitive {
  /**
   * @param {Matrix4} modelMatrix matrix to WorldCoordinateSystem
   */
  constructor(modelMatrix) {
    this._modelMatrix = modelMatrix
  }
  
  /**
   * @param {FrameState} frameState
   */
  update(frameState) {
    const command = createCommand(frameState, this._modelMatrix)
    frameState.commandList.push(command)
  }
}

// try!
const viewer = new Cesium.Viewer('cesiumContainer', {
  contextOptions: {
    requestWebgl2: true
  }
})
viewer.scene.globe.depthTestAgainstTerrain = true
viewer.scene.primitives.add(new StaticTrianglePrimitive(modelMatrix))

顯示出來的效果就是一個白綠色的三角形:

圖中為大灣區,因為我設的 ENU 座標中心就是大灣區附近。三角形的高度被我設為了 5000 米。

2. 意義 - 自定義 Primitive(PrimitiveLike)

如果有一個物件或者一個函式,返回的是可繪製的 DrawCommand ,那麼只需把返回的指令物件傳遞給 FrameState 就可以在這一幀把上面的資料和繪圖邏輯展示出來。

仔細想想,具備建立 DrawCommand 的物件其實不少。有 PrimitiveBillboardCollectionSkyAtmosphereSkyBoxSunModel 等(3DTiles 瓦片上的模型是通過 Model 繪製的)。

我這裡就直接給結論了:

  • 具備建立 DrawCommand 功能的,無論是函式,還是物件,都可以直接參與 Cesium 最底層的繪圖;
  • 原型鏈上具備 update 方法的類,且 update 方法接受一個 FrameState 物件,並在執行過程中向這個幀狀態物件新增 DrawCommand 的,就能新增至 scene.primitives 這個 PrimitiveCollection 中。

前一種有具體的 API,即 Globe 下的 GlobeSurfaceTileProvider (由 QuadtreePrimitive 使用)建立 DrawCommand ;後面的就多了。

能精確控制 DrawCommand ,就可以在 Cesium 場景中做你想做的繪圖。

點到為止

DrawCommand 是 CesiumJS 渲染器之前的最後一道資料封裝,後面就是對這些指令物件上的資源進行分發、繫結、執行。讀者有興趣的話,還可以自行研究 ClearCommandComputeCommand ,也許以後會寫寫,不過本篇點到為止~

3. 參考資料