CesiumJS 2022^ 原理[5] - 着色器相关的封装设计
阅读原文时间:2022年05月15日阅读:1

目录


本篇涉及到的所有接口在公开文档中均无,需要下载 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 方式基本不变。

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 节中提及的指令对象,这两个类就能派上用场了。

纹理是 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 (/* ... */) { /* ... */ }

众所周知,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 绘制三角形的文章。

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 一样提供通道编码器或许会简化通道的概念。

统一值,即 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 上,也就是完成着色器内统一值的设置。

渲染容器主要就是指帧缓冲对象、渲染缓冲对象。

渲染缓冲对象,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 并行计算

下面进行简单讲解。

也就是 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(); 是在绑定各种顶点数据。

清屏指令,与 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 关键词。

早期的 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,不仅仅有齐全的文档,而且给到开发者最大的自由去修改图形渲染。

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)

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)中创建一张选择纹理。

有关后处理的资料以后可能会专门写应用篇来介绍。

这个伴随着 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 的渲染架构设计了,旧版本的模型架构正在被新架构替换中,所以之后直接以新架构为基础讲解。