CesiumJS 2022^ 原理[5] - 著色器相關的封裝設計

四季留歌發表於2022-05-15


本篇涉及到的所有介面在公開文件中均無,需要下載 GitHub 上的原始碼,自己建立私有類的文件。

npm run generateDocumentation -- --private
yarn generateDocumentation -- --private
pnpm generateDocumentation -- --private

本篇當然不會涉及著色器演算法講解。

1. 對 WebGL 介面的封裝

任何一個有追求的 WebGL 3D 庫都會封裝 WebGL 原生介面。CesiumJS 從內部封測到現在,已經有十年了,WebGL 自 2011 年釋出以來也有 11 年了,這期間小修小補不可避免。

更何況 CesiumJS 是一個 JavaScript 的地理 3D 框架,它在原始碼設計上具備兩大特徵:

  • 物件導向
  • 模組化

關於模組化策略,CesiumJS 在 1.63 版本已經從 require.js 切換到原生 es-module 格式了。而 WebGL 是一種使用全域性狀態的指令式風格介面,改為物件導向風格就必須做封裝。ThreeJS 是通用 Web3D 庫中做 WebGL 封裝的代表作品。

封裝有另外的好處,就是底層 WebGL 介面在這十多年中的變化,可以在封裝後遮蔽掉這些變化,上層應用呼叫封裝後的 API 方式基本不變。

1.1. 緩衝物件封裝

CesiumJS 封裝了 WebGLBuffer 以及 WebGL 2.0 才正式支援(1.0 中用擴充套件)的 VAO,分別封裝成了 Buffer 類和 VertexArray 類。

Buffer 類比較簡單,提供了簡單工廠模式的靜態建立方法:

// 建立儲存頂點緩衝物件
Buffer.createVertexBuffer = function (options) {
  // ...

  return new Buffer({
    context: options.context,
    bufferTarget: WebGLConstants.ARRAY_BUFFER,
    typedArray: options.typedArray,
    sizeInBytes: options.sizeInBytes,
    usage: options.usage,
  });
};

// 建立頂點索引緩衝物件
Buffer.createIndexBuffer = function (options) {
  // ...
};

Buffer 物件在例項化時,就會建立 WebGLBuffer 並將型別陣列上載:

// Buffer 建構函式中
const buffer = gl.createBuffer();
gl.bindBuffer(bufferTarget, buffer);
gl.bufferData(bufferTarget, hasArray ? typedArray : sizeInBytes, usage);
gl.bindBuffer(bufferTarget, null);

除了這兩個用於建立的靜態方法,還有一些拷貝緩衝物件的方法,就不一一列舉了。

注意一點:Buffer 物件不儲存原始頂點型別陣列資料。這一點是出於節約 JavaScript 記憶體考慮。

而頂點陣列物件 VertexArray,封裝的則是 OpenGL 系中的一個資料模型 VertexArrayObject,在 WebGL 中是意圖節約設定多個頂點緩衝到全域性狀態物件的效能損耗。

建立 CesiumJS 的頂點陣列物件也很簡單,只需按 WebGL 的頂點屬性(Vertex Attribute)的格式去裝配 Buffer 物件即可:

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
})

你如果在對著上述程式碼練習,你肯定沒法成功建立,並發現一個問題:沒有 context 引數傳遞給 BufferVertexArray,因為 context 物件(型別 Context)是 WebGL 渲染上下文物件等底層介面的的封裝物件,沒有它無法建立 WebGLBuffer 等原始介面物件。

所以,BufferVertexArray 並不是孤立的 API,必須與其它封裝一起搭配來用,它們兩個至少要依賴 Context 物件才行,在 1.4 中會介紹如何使用 Context 類封裝 WebGL 底層介面並如何訪問 Context 物件的。

很少有需要直接建立 BufferVertexArray 的時候,使用這兩個介面,就意味著你獲得的資料符合 VBO 格式,其它人類閱讀友好型的資料格式必須轉換為 VBO 格式才能直接用這倆類。如果你需要使用第 2 節中提及的指令物件,這兩個類就能派上用場了。

1.2. 紋理與取樣引數封裝

紋理是 WebGL 中一個非常複雜的話題。

先說採用引數吧,在 WebGL 1.0 時還沒有原生的取樣器 API,到 2.0 才推出的 WebGLSampler 介面。所以,CesiumJS 封裝了一個簡單的 Sampler 類:

function Sampler(options) {
  // ...
  this._wrapS = wrapS;
  this._wrapT = wrapT;
  this._minificationFilter = minificationFilter;
  this._magnificationFilter = magnificationFilter;
  this._maximumAnisotropy = maximumAnisotropy;
}

其實就是把 WebGL 1.0 中的紋理取樣引數做成了一個物件,沒什麼難的。

紋理類 Texture 則是對 WebGLTexture 的封裝,它不僅封裝了 WebGLTexture,還封裝了資料上載的功能,只需安心地把貼圖資料傳入即可。

BufferVertexArrayTexture 也要 context 引數。

import {
  Texture, Sampler,
} from 'cesium'

new Texture({
  context: context,
  width: 1920,
  height: 936,
  source: new Float32Array([/* ... */]), // 0~255 灰度值的 RGBA 影像資料
  // 可選取樣引數
  sampler: new Sampler()
})

你可以在 ImageryLayer.js 模組中找到建立影像瓦片紋理的程式碼:

ImageryLayer.prototype._createTextureWebGL = function (context, imagery) {
  // ...
  return new Texture({
    context: context,
    source: image,
    pixelFormat: this._imageryProvider.hasAlphaChannel
      ? PixelFormat.RGBA
      : PixelFormat.RGB,
    sampler: sampler,
  });
}

除了建立紋理,CesiumJS 還提供了紋理的拷貝工具函式,譬如從幀緩衝物件中拷貝出一個紋理:

Texture.fromFramebuffer = function (/* ... */) { /* ... */ }

Texture.prototype.copyFromFramebuffer = function (/* ... */) { /* ... */ }

或者建立 mipmap:

Texture.prototype.generateMipmap = function (/* ... */) { /* ... */ }

1.3. 著色器封裝

眾所周知,WebGL 的著色器相關 API 是 WebGLShaderWebGLProgram,頂點著色器和片元著色器共同構成一個著色器程式物件。在一幀的渲染中,由多個通道構成,每個通道在觸發 draw 動作之前,通常要切換著色器程式,以達到不同的計算效果。

CesiumJS 的渲染遠遠複雜於通用 Web3D,意味著有大量著色器程式。物件多了,就要管理。CesiumJS 封裝了有關底層 API 的同時,還設計了快取機制。

CesiumJS 使用 ShaderSource 類來管理著色器程式碼文字,使用 ShaderProgram 類來管理 WebGLProgramWebGLShader,使用 ShaderCache 類來快取 ShaderProgram,再使用 ShaderFunctionShaderStructShaderDestination 來輔助 ShaderSource 處理著色器程式碼文字中的 glsl 函式、結構體成員、巨集定義。

此外,還有一個 ShaderBuilder 類來輔助 ShaderProgram 的建立。

這一堆私有類與前面 BufferVertexArrayTexture 一樣,並不能單獨使用,通常是與第 2 節中的各種指令物件一起用。

下面給出一個例子,它使用 ShaderProgram 的靜態方法 fromCache 建立著色器程式物件,這個方法會建立物件的同時並快取到 ShaderCache 物件中,有興趣的可以自行檢視快取的程式碼。

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 program = ShaderProgram.fromCache({
  context: context,
  vertexShaderSource: vertexShaderText,
  fragmentShaderSource: fragmentShaderText,
  attributeLocations: {
    "position": 0,
  },
})

完整例子可以找我之前的寫的關於使用 DrawCommand 繪製三角形的文章。

1.4. 上下文物件與渲染通道

WebGL 底層介面的封裝,基本上都在 Context 類中。最核心的就是渲染上下文(WebGLRenderingContextWebGL2RenderingContext)物件了,除此之外,Context 上還有一些重要的渲染相關的功能和成員變數:

  • 一系列 WebGL 2.0 中才支援的、WebGL 1.0 中用擴充套件才支援的特性
  • 壓縮紋理的支援資訊
  • UniformState 物件
  • PassState 物件
  • RenderState 物件
  • 參與幀渲染的功能,譬如 draw、readPixels 等
  • 建立拾取用的 PickId
  • 操作、校驗 Framebuffer 物件

通常,通過 Scene 物件上的 FrameState 物件,即可訪問到 Context 物件。

WebGL 渲染上下文物件暴露的常量很多,CesiumJS 把渲染上下文上的常量以及可能會用到的常量都封裝到 WebGLConstants.js 匯出的物件中了。

還有一個東西要特別說明,就是通道,WebGL 是沒有通道 API 的,而一幀之內切換著色器進行多道繪製過程是很常見的事情,每一道觸發 draw 的行為,叫做通道。

CesiumJS 把高層級三維物件的渲染行為做了打包,封裝成了三類指令物件,在第 2 節中會講;這些指令物件是有先後優先順序的,CesiumJS 把這些優先順序描述為通道,使用 Pass.js 匯出的列舉來定義,目前指令物件有 10 個優先順序:

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,
};

NUMBER_OF_PASSES 成員代表當前有 10 個優先順序。

而在幀狀態物件上,也有一個 passes 成員:

// FrameState 建構函式
this.passes = {
  render: false,
  pick: false,
  depth: false,
  postProcess: false,
  offscreen: false,
};

這 5 個布林值就控制著渲染用的是哪個通道。

指令物件的通道狀態值,加上幀狀態物件上的通道狀態,共同構成了 CesiumJS 龐大的抽象模型中的“通道”概念。

其實我認為這樣設計會導致 Scene 單幀渲染時有大量的 if 判斷、排序處理,顯得有些冗餘,能像 WebGPU 這種新 API 一樣提供通道編碼器或許會簡化通道的概念。

1.5. 統一值(uniform)封裝

統一值,即 WebGL 中的 Uniform,不熟悉的讀者需要自己先學習 WebGL 相關概念。

每一幀,有大量的狀態值是和上一幀不一樣的,也就是需要隨時更新進著色器中。CesiumJS 為此做出了封裝,這種頻繁變動的統一值被封裝進了 AutomaticUniforms 物件中了,每一個成員都是 AutomaticUniform 類例項:

// AutomaticUniforms.js 中
function AutomaticUniform(options) {
  this._size = options.size;
  this._datatype = options.datatype;
  this.getValue = options.getValue;
}

從預設匯出的 AutomaticUniforms 物件中拿一個成員來看:

czm_projection: new AutomaticUniform({
  size: 1,
  datatype: WebGLConstants.FLOAT_MAT4,
  getValue: function (uniformState) {
    return uniformState.projection;
  },
})

這個統一值是攝像機的投影矩陣,它的取值函式需要一個 uniformState 引數,也就是實時地從統一值狀態物件(型別 UniformState)上獲取的。

Context 物件擁有一個只讀的 UniformState getter,指向一個私有的成員。當 Scene 在執行幀狀態上的指令列表時,會呼叫 Context 的繪製函式,進一步地會呼叫 Context.js 模組內的 continueDraw 函式,它就會執行著色器程式物件的 _setUniforms 方法:

shaderProgram._setUniforms(
  uniformMap,
  context._us,
  context.validateShaderProgram
);

這個函式就能把指令物件傳下來的自定義 uniformMap,以及 AutomaticUniforms 給設定到 ShaderProgram 內建的 WebGLProgram 上,也就是完成著色器內統一值的設定。

1.6. 渲染容器封裝

渲染容器主要就是指幀緩衝物件、渲染緩衝物件。

渲染緩衝物件,CesiumJS 封裝為 Renderbuffer 類,是對 WebGLRenderbuffer 的一個非常簡單的封裝,不細說了,但是要單獨提一點,若啟用了 msaa,會呼叫相關的繫結函式:

// Renderbuffer.js

function Renderbuffer(options) {
  // ...
  const gl = context._gl;

  // ...
  this._renderbuffer = this._gl.createRenderbuffer();

  gl.bindRenderbuffer(gl.RENDERBUFFER, this._renderbuffer);
  if (numSamples > 1) {
    gl.renderbufferStorageMultisample(
      gl.RENDERBUFFER,
      numSamples,
      format,
      width,
      height
    );
  } else {
    gl.renderbufferStorage(gl.RENDERBUFFER, format, width, height);
  }
  gl.bindRenderbuffer(gl.RENDERBUFFER, null);
}

接下來說幀緩衝的封裝。

普通的幀緩衝,也就是常規的 WebGLFramebuffer 被封裝到 Framebuffer 類裡了,它有幾個陣列成員,用於儲存幀緩衝用到的顏色附件、深度模板附件的容器紋理、容器渲染緩衝。

function Framebuffer(options) {
  const context = options.context;
  //>>includeStart('debug', pragmas.debug);
  Check.defined("options.context", context);
  //>>includeEnd('debug');

  const gl = context._gl;
  const maximumColorAttachments = ContextLimits.maximumColorAttachments;

  this._gl = gl;
  this._framebuffer = gl.createFramebuffer();

  this._colorTextures = [];
  this._colorRenderbuffers = [];
  this._activeColorAttachments = [];

  this._depthTexture = undefined;
  this._depthRenderbuffer = undefined;
  this._stencilRenderbuffer = undefined;
  this._depthStencilTexture = undefined;
  this._depthStencilRenderbuffer = undefined;
  
  // ...
}

用也很簡單,呼叫原型鏈上繫結相關的方法即可。CesiumJS 支援 MRT,所以有一個對應的 bindDraw 方法:

Framebuffer.prototype.bindDraw = function () {
  const gl = this._gl;
  gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, this._framebuffer);
};

msaa 則用到了 MultisampleFramebuffer 這個類;CesiumJS 還設計了 FramebufferManager 類來管理幀緩衝物件,在後處理、OIT、拾取、Scene 的幀緩衝管理等模組中均有使用。

2. 三類指令

CesiumJS 並不會直接處理地理三維物件,而是在各種更新的流程控制函式中,由每個三維物件去生成一種叫做“指令”的物件,送入幀狀態物件的相關渲染佇列中。

這些指令物件的就遮蔽了各種高層級的“人類友好”型三維資料物件的差異,Context 能方便統一地處理它們攜帶的資料資源(緩衝、紋理)和行為(著色器)。

這些指令物件分成三類:

  • 繪圖指令(繪製指令),DrawCommand 類,負責渲染繪圖
  • 清屏指令,ClearCommand 類,負責清空繪圖區域
  • 通用計算指令,ComputeCommand 類,用 WebGL 來進行 GPU 平行計算

下面進行簡單講解。

2.1. 繪圖指令(繪製指令)

也就是 DrawCommand 類,位於 Renderer/DrawCommand.js 模組。

繪圖指令,在 Scene 物件的每一幀更新過程中,由各種高階三維物件生成,並新增到幀狀態物件中,等待渲染。

我曾經寫過一篇 DrawCommand 畫最簡單的三角形的文,如果你點進我的使用者文章列表找不到,你可能看到本文的盜版了:)

簡而言之,建立 DrawCommand 需要資料(VertexArray、uniformMap、RenderState),也需要行為(ShaderProgram)。建立 DrawCommand 這些輔料,大多數都需要 Context 物件。

它的執行過程如下:

DrawCommand.prototype.execute
  Context.prototype.draw
    fn beginDraw
    fn continueDraw

關於 Scene 渲染一幀的文章中已經提過如何執行這些繪製指令,是 Scene 原型鏈上的 updateAndExecuteCommands 方法出發,一路走到 executeCommand 函式,最終呼叫各種指令物件的執行方法。

上面的簡易邏輯流程中,beginDraw 這個模組內的函式,負責繫結幀緩衝物件和渲染狀態,並繫結 ShaderProgram 到 WebGL 全域性狀態上:

function beginDraw(/* ... */) {
  // ...
  bindFramebuffer(context, framebuffer);
  applyRenderState(context, renderState, passState, false);
  shaderProgram._bind();
  // ...
}

緊接著,continueDraw 函式會向 WebGL 全域性狀態設定(更新)統一值:

// function continueDraw 中
shaderProgram._setUniforms(
  uniformMap,
  context._us,
  context.validateShaderProgram
);

然後就是走 WebGL 的常規繪製流程了,繫結 VertexArray,判斷是否用了索引緩衝,岔開邏輯分別繪製頂點資料:

// function continueDraw 中
va._bind();
const indexBuffer = va.indexBuffer;

if (defined(indexBuffer)) {
  // ...
} else {
  count = defaultValue(count, va.numberOfVertices);
  if (instanceCount === 0) {
    context._gl.drawArrays(primitiveType, offset, count);
  } else {
    context.glDrawArraysInstanced(
      primitiveType,
      offset,
      count,
      instanceCount
    );
  }
}

va._unBind();

程式碼 va._bind(); 是在繫結各種頂點資料。

2.2. 清屏指令

清屏指令,與 WebGL 的 clear 方法目的是一樣的,即清空當前幀緩衝(或 canvas)的顏色部分、深度模板部分,並填充特定的值,封裝成了 ClearCommand 類。

清屏指令就比較簡單了,它的執行與繪製指令是一個過程,都在 Scene.js 模組下的 executeCommand 函式中:

// function executeCommand 中
if (command instanceof ClearCommand) {
  command.execute(context, passState);
  return;
}

可以看到它一旦被執行,就不繼續執行後面的關於繪製指令的程式碼,直接 return 了。

緊接著會執行 Context 原型鏈上的 clear 方法,它也會繫結幀緩衝、設定渲染狀態,最後呼叫 gl.clear 方法,將設定的待清除後要填充的顏色、深度、模板值刷上去,過程比較簡單,就不貼原始碼了。

Scene 物件上有幾個清屏指令成員物件,在渲染流程中由 updateAndClearFramebuffers 函式執行顏色清屏指令,而模板與深度的清屏指令則由 executeCommands 函式執行:

// Scene.js 模組下

function executeCommandsInViewport(/* ... */) {
  // ...
  if (firstViewport) {
    if (defined(backgroundColor)) {
      updateAndClearFramebuffers(scene, passState, backgroundColor);
    }
    // ...
  }
  executeCommands(scene, passState);  
}

function updateAndClearFramebuffers(scene, passState, clearColor) {
  // ...
  const clear = scene._clearColorCommand;
  Color.clone(clearColor, clear.color);
  clear.execute(context, passState);
  // ...
}

function executeCommands(scene, passState) {
  // ...
  const clearDepth = scene._depthClearCommand;
  const clearStencil = scene._stencilClearCommand;
  // ...
  
  for (let i = 0; i < numFrustums; ++i) {
    // ...
    clearDepth.execute(context, passState);
    if (context.stencilBuffer) {
      clearStencil.execute(context, passState);
    }
    // ... 執行其它 commands 的分支邏輯
  }
}

當然,有 ClearCommand 的物件不僅僅只有 Scene,其它地方也有,你可以在原始碼中全域性搜尋 new ClearCommand 關鍵詞。

2.3. 通用計算指令

早期的 WebGL 1.0 對 GPU 通用計算(GPGPU)支援得並不是很好,想讓 GPU 模擬普通的平行計算,需要把資料編碼成紋理,經由渲染管線完成紋理取樣、計算後,再輸出到幀緩衝物件上,再使用 WebGL 讀畫素的介面函式把結果讀取出來。

WebGL 2.0 的計算著色器是姍姍來遲。

CesiumJS 最開始使用了 ComputeCommand 來區別作用於渲染任務的 DrawCommand

CesiumJS 原始碼中使用了計算指令的地方不多,最經典的就是影像圖層的重投影方法中:

ImageryLayer.prototype._reprojectTexture = function (/**/) {
  // ...
  const computeCommand = new ComputeCommand({
    persists: true,
    owner: this,
    preExecute: function (command) {
      reprojectToGeographic(command, context, texture, imagery.rectangle);
    },
    postExecute: function (outputTexture) {
      imagery.texture = outputTexture;
      that._finalizeReprojectTexture(context, outputTexture);
      imagery.state = ImageryState.READY;
      imagery.releaseReference();
    },
    canceled: function () {
      imagery.state = ImageryState.TEXTURE_LOADED;
      imagery.releaseReference();
    },
  });
  // ...
}

執行它的“管家”,不是 Context 而是 ComputeEngine 類。

ComputeCommand.prototype.execute = function (computeEngine) {
  computeEngine.execute(this);
};

當然,去看 ComputeEngine 類的建構函式,它也只不過是對 Context 的一個封裝,用到了裝飾器模式:

function ComputeEngine(context) {
  this._context = context;
}

檢視 ComputeEngine.prototype.execute 的核心執行部分,其實它也是用 DrawCommandClearCommand,在獨立的 Framebuffer 上執行傳入的 ShaderProgram

ComputeEngine.prototype.execute = function (computeCommand) {
  // ...
  const outputTexture = computeCommand.outputTexture;
  const width = outputTexture.width;
  const height = outputTexture.height;

  const context = this._context;
  const vertexArray = defined(computeCommand.vertexArray)
    ? computeCommand.vertexArray
    : context.getViewportQuadVertexArray();
  const shaderProgram = defined(computeCommand.shaderProgram)
    ? computeCommand.shaderProgram
    : createViewportQuadShader(context, computeCommand.fragmentShaderSource);
  // 使用 outputTexture 作為 fbo 的繪製結果載體
  const framebuffer = createFramebuffer(context, outputTexture);
  const renderState = createRenderState(width, height);
  const uniformMap = computeCommand.uniformMap;

  // 使用模組內的變數完成 fbo 清屏
  const clearCommand = clearCommandScratch;
  clearCommand.framebuffer = framebuffer;
  clearCommand.renderState = renderState;
  clearCommand.execute(context);

  // 使用模組內的變數完成 fbo 渲染管線執行
  const drawCommand = drawCommandScratch;
  drawCommand.vertexArray = vertexArray;
  drawCommand.renderState = renderState;
  drawCommand.shaderProgram = shaderProgram;
  drawCommand.uniformMap = uniformMap;
  drawCommand.framebuffer = framebuffer;
  drawCommand.execute(context);

  framebuffer.destroy();
  // ...
}

具體的計算著色、紋理編碼原理就不介紹了,屬於著色器原理,本文(本系列文章)更多是介紹架構設計細節。

3. 自定義著色器

CesiumJS 留有一些公開的 API,允許開發者寫自己的著色過程。

在 Cesium 團隊大力開發下一代 3DTiles 和模型類新架構之前,這部分能力比較弱,只有一個 Fabric 材質規範能寫寫現有幾何物件的材質效果,且文件較少。

隨著下一代 3DTiles 與新的模型類實驗性啟用後,帶來了自由度更高的 CustomShader API,不僅僅有齊全的文件,而且給到開發者最大的自由去修改圖形渲染。

3.1. 早期 Fabric 材質規範中的自定義著色器

Primitive API 時,有這麼一個欄位:

new Primitive({
  //...
  appearance: new MaterialAppearance({
    material: Material.fromType('Color'),
    faceForward: true
  })
})

這個 MaterialAppearanceAppearance 類的一個子類,除了上述這兩個屬性之外,還可以自己傳遞頂點著色器程式碼。

但是,通常不會直接向 Appearance 的派生子類們提供頂點著色器、片元著色器,因為外觀物件所需的著色器有額外的要求,通常是建立 Material 時寫材質 glsl 函式:

const fabricMaterial = new Material({
  fabric: {
    uniforms: {
      my_var: 0.5,
    },
    source: `czm_material czm_getMaterial(czm_materialInput input) {
      czm_material material = czm_getDefaultMaterial(input);
      material.diffuse = vec3(materialInput.st, 0.0);
      material.alpha = my_var;
      return material;
    }`
  }
})

然後把這個遵循了 Fabric 材質規範的材質物件,傳遞給外觀物件:

new MaterialAppearance({
  material: fabricMaterial
})

Fabric 材質規範這裡不多介紹,以後有機會再開一文吧,簡單的說傳遞一個 JavaScript 物件給 Materialfabric 成員變數即可,這個物件可以自定義一種材質,可以具備 uniformMap,並在 glsl 程式碼中使用一個函式,返回一個 czm_material 結構體作為材質。

雖然可以建立 glsl 結構體作為材質,但是它僅僅只能作用於片元的部分著色過程。

Appearance API 作用的是 Primitive API 生成的圖元物件,外觀物件支援直接傳遞 Primitive 所需的兩大著色器程式碼,但是也是有限制的,一些 vertex attritbutevarying 是必須存在的,而且還得自己處理渲染管線的轉換,這方面資料較少。

通過下列程式碼,你可以輸出最簡單的 MaterialAppearance 物件生成的內建預設兩大著色器程式碼,方便自己修改:

const appearance = new Cesium.MaterialAppearance({
  material: new Cesium.Material({}),
})

const vs = appearance.vertexShaderSource
const fs = appearance.fragmentShaderSource
const fsWithFabricMaterial = appearance.getFragmentShaderSource()

// 列印這三個變數,都是 glsl 程式碼字串
// console.log(vs, fs, fsWithFabricMaterial)

3.2. 後處理中的自定義著色器

CesiumJS 其實內建了一大堆常見的後處理器,常見的輝光(Bloom)、快速抗鋸齒(FXAA)等都有,參考 PostProcessStageLibrary 這個類匯出的靜態欄位即可。

雖然這些後處理都是最基本的、對整個 FBO 進行的,效果一般。

你可以訪問 scene.postProcessStages 訪問後處理器的容器,譬如啟用快速抗鋸齒是這樣啟用的:

viewer.scene.postProcessStages.fxaa.enabled = true

內建的環境光遮蔽(AO)或輝光(Bloom)必定在所有後處理器之前執行,FXAA 必定位於所有後處理器之後執行。這三個階段也是 CesiumJS 預設建立的後處理器。

你可以自己建立單獨的後處理階段(PostProcessStage)或合成後處理階段(PostProcessStageComposite)作為一個後處理器傳入 PostProcessStageCollection 容器中,如果不使用官方提供的其它常見後處理演算法,那麼你也可以自己寫著色器。

官方文件寫道:

每個後處理階段的輸入紋理,是 Scene 渲染的紋理,或前一後處理階段的輸出紋理。

參考 PostProcessStage 類的文件,官方也提供了兩個例子:

// 例子1,粗暴地修改顏色
const fs = `uniform sampler2D colorTexture;
varying vec2 v_textureCoordinates;
uniform float scale;
uniform vec3 offset;
void main() {
  vec4 color = texture2D(colorTexture, v_textureCoordinates);
  gl_FragColor = vec4(color.rgb * scale + offset, 1.0);
}`
scene.postProcessStages.add(new Cesium.PostProcessStage({
  fragmentShader: fs,
  uniforms: {
    scale: 1.1,
    offset: function() {
      return new Cesium.Cartesian3(0.1, 0.2, 0.3);
    }
  }
}))

後處理還支援拾取物件的判斷,也就是例子2,修改拾取物件的顏色:

const fs = `uniform sampler2D colorTexture;
varying vec2 v_textureCoordinates;
uniform vec4 highlight;
void main() {
  vec4 color = texture2D(colorTexture, v_textureCoordinates);
  if (czm_selected()) {
    vec3 highlighted = 
      highlight.a * highlight.rgb + (1.0 - highlight.a) * color.rgb;
    gl_FragColor = vec4(highlighted, 1.0);
  } else {
    gl_FragColor = color;
  }
}`
const stage = scene.postProcessStages.add(new Cesium.PostProcessStage({
  fragmentShader: fs,
  uniforms: {
    highlight: function() {
      return new Cesium.Color(1.0, 0.0, 0.0, 0.5);
    }
  }
}))
stage.selected = [cesium3DTileFeature]

PostProcessStageselected 是一個 js 陣列,支援設定 Cesium3DTileFeatureLabelBillboard 等具備 pickId 訪問器,或 ModelCesium3DTilePointFeature 等具備 pickIds 訪問器的類為“選中物件”,並在更新過程(PostProcessStage.prototype.update)中建立一張選擇紋理。

有關後處理的資料以後可能會專門寫應用篇來介紹。

3.3. 新架構帶來的 CustomShader API

這個伴隨著 ModelExperimental 這個新架構(2022年5月,此架構正在啟動對原 Model 類相關架構的替換)的更新而隨時可能會更新。

目前,CustomShader API 僅支援在 Cesium3DTilesetModelExperimental 兩個類上使用。傳入的自定義著色器作用在每個瓦片或模型上。

舉例:

import {
  CustomShader, UniformType, TextureUniform, VaryingType
} from 'cesium'
const customShader = new CustomShader({
  uniforms: {
    u_colorIndex: {
      type: UniformType.FLOAT,
      value: 1.0
    },
    u_normalMap: {
      type: UniformType.SAMPLER_2D,
      value: new TextureUniform({
        url: "http://example.com/normal.png"
      })
    }
  },
  varyings: {
    v_selectedColor: VaryingType.VEC3
  },
  vertexShaderText: `void vertexMain(
    VertexInput vsInput,
    inout czm_modelVertexOutput vsOutput
  ) {
    v_selectedColor = mix(
      vsInput.attributes.color_0,
      vsInput.attributes.color_1, u_colorIndex
    );
    vsOutput.positionMC += 0.1 * vsInput.attributes.normal;
  }`,
  fragmentShaderText: `void fragmentMain(
    FragmentInput fsInput,
    inout czm_modelMaterial material
  ) {
    material.normal = texture2D(
      u_normalMap, fsInput.attributes.texCoord_0
    );
    material.diffuse = v_selectedColor;
  }`
})

相關規範文件可以在原始碼根目錄下 Documentation/CustomShaderGuide/README.md 檔案中查閱。

你可以設定在渲染管線中需要的 uniformMap,指定兩個著色器之間的交換值(varyings),並且能在兩大著色器中訪問 CesiumJS 封裝好的兩個結構體 VertexInputFragmentInput,它們為你提供了儘可能詳盡的值,譬如:

  • 頂點屬性(Vertex Attributes)
  • 要素/批次ID(FeatureID/BatchID)
  • 3DTiles 1.1 規範中的屬性後設資料(Metadata)

頂點屬性中提供了儘可能詳盡的頂點資訊,常見的:頂點座標、法線、紋理座標、顏色等均有附帶,而且附帶了各種座標系下的值。譬如,你可以在頂點著色器中這樣訪問相機座標系下的頂點座標:

void vertexMain(
  VertexInput vsInput,
  inout czm_modelVertexOutput vsOutput
) {
  vsOutput.positionMC = czm_projection * (
    vec4(vsInput.attributes.positionEC) + vec4(.0, .0, 10.0, 0.0)
  );
}

上述程式碼將觀察座標的 z 值提高了 10 個單位,最後乘上內建的投影矩陣,作為輸出模型座標。

CesiumJS 提供的所有內建 glsl 函式、常量、自動變數均支援在 CustomShader API 中使用。

這無疑給想修改模型形狀、模型效果的開發者們提供了極大的便利。

4. 總結

個人覺得在 WebGL 封裝這部分,在本文已經講得可以了,更高層級的應用封裝,例如 OIT、GPUPick、各種物件的著色器和指令生成過程,均基於這篇文章的內容,均發生在 Scene 的渲染流程中。

最後再強調一下,本文僅是對架構方面的介紹,而不是著色器演算法的詳解,著色器演算法可謂 CesiumJS 的頭腦風暴區,一篇文章容不下。

具備渲染架構、WebGL 封裝基礎後,接下來就該看最經典的模型(glTF)、3DTiles 的渲染架構設計了,舊版本的模型架構正在被新架構替換中,所以之後直接以新架構為基礎講解。

相關文章