Cesium中Primitive的渲染过程与底层隐藏API DrawCommand的使用
众所周知,Cesium是基于WebGL技术实现的一个地理三维场景框架,但不知道大家平时是否好奇过,我们写的这么多JavaScript代码,是如何与WebGL层进行交互,从而变成场景里一个个的模型的。在这篇文章里我将带大家了解了解这一过程中的核心函数DrawCommand与ComputeCommand的使用。
一、 从Primitive说起
在这之前,为了能让更多人能够读懂这篇文章,我会从一个大家更为熟悉的类——Primitive开始,如果你对这个类还不是很了解,我也会尽可能的在这里给出一些介绍。
首先我们来看一个Primitive的常见用法:
// 1. Draw a translucent ellipse on the surface with a checkerboard pattern
const instance = new Cesium.GeometryInstance({
geometry : new Cesium.EllipseGeometry({
center : Cesium.Cartesian3.fromDegrees(-100.0, 20.0),
semiMinorAxis : 500000.0,
semiMajorAxis : 1000000.0,
rotation : Cesium.Math.PI_OVER_FOUR,
vertexFormat : Cesium.VertexFormat.POSITION_AND_ST
}),
id : 'object returned when this instance is picked and to get/set per-instance attributes'
});
scene.primitives.add(new Cesium.Primitive({
geometryInstances : instance,
appearance : new Cesium.EllipsoidSurfaceAppearance({
material : Cesium.Material.fromType('Checkerboard')
})
}));
以上是一段来自Cesium官方文档里的例子,可以看到,Primitive是由最核心的两个部分geometryInstances与appearance组成的,其中前者可以理解为这个Primitive的位置与范围,后者可以理解为这个范围内的样子。
那么,我们配置的这些属性,是在哪里被渲染到地图上的呢?我们先来仔细观察一下刚刚用法中出现的这段代码:
scene.primitives.add(new Cesium.Primitive({
geometryInstances : instance,
appearance : new Cesium.EllipsoidSurfaceAppearance({
material : Cesium.Material.fromType('Checkerboard')
})
}));
可以看到,我们的Primitive最终被加入到了我们的Scene对象中,于是我们通过查看Scene的源码https://github.com/CesiumGS/cesium/blob/1.137/packages/engine/Source/Scene/Scene.js,不难发现,我们实际上是将Primitive添加到了Scene中的一个PrimitiveCollection对象中。而在Scene中,我们可以找到一个叫做updateAndRenderPrimitives(scene)的函数,在这个函数里,调用了PrimitiveCollection的update方法,而PrimitiveCollection的update方法中又调用了每一个primitive对象的update方法。而向另一个方向看,能找到updateAndRenderPrimitives(scene) 最终可以溯源到Scene的render方法中。熟悉Cesium渲染过程话便能知道,画面的每一帧正是由Render 方法生产出来的。我们梳理一下这个逻辑,也就是说,在Cesium的画面的每一帧,都会调用Primitive的update方法。
二、每一帧做的事情
接下来就是本文的重点部分了,我们更进一步,一起来看看Primitive的update方法:https://github.com/CesiumGS/cesium/blob/1.137/packages/engine/Source/Scene/Primitive.js#L2079
跳过前面繁琐的参数校验,来看接下来的这一段:
if (createRS || createSP) {
const commandFunc = this._createCommandsFunction ?? createCommands;
commandFunc(
this,
appearance,
material,
translucent,
twoPasses,
this._colorCommands,
this._pickCommands,
frameState,
);
}
上面这段中,出现了我们创建primitive时设置的appearance和material等参数,他们最终被放在了createCommand函数中,因此我们继续查看createCommands方法https://github.com/CesiumGS/cesium/blob/1.137/packages/engine/Source/Scene/Primitive.js#L1842
function createCommands(
primitive,
appearance,
material,
translucent,
twoPasses,
colorCommands,
pickCommands,
frameState,
) {
const uniforms = getUniforms(primitive, appearance, material, frameState);
let depthFailUniforms;
if (defined(primitive._depthFailAppearance)) {
depthFailUniforms = getUniforms(
primitive,
primitive._depthFailAppearance,
primitive._depthFailAppearance.material,
frameState,
);
}
const pass = translucent ? Pass.TRANSLUCENT : Pass.OPAQUE;
let multiplier = twoPasses ? 2 : 1;
multiplier *= defined(primitive._depthFailAppearance) ? 2 : 1;
colorCommands.length = primitive._va.length * multiplier;
const length = colorCommands.length;
let vaIndex = 0;
for (let i = 0; i < length; ++i) {
let colorCommand;
if (twoPasses) {
colorCommand = colorCommands[i];
if (!defined(colorCommand)) {
colorCommand = colorCommands[i] = new DrawCommand({
owner: primitive,
primitiveType: primitive._primitiveType,
});
}
colorCommand.vertexArray = primitive._va[vaIndex];
colorCommand.renderState = primitive._backFaceRS;
colorCommand.shaderProgram = primitive._sp;
colorCommand.uniformMap = uniforms;
colorCommand.pass = pass;
++i;
}
colorCommand = colorCommands[i];
if (!defined(colorCommand)) {
colorCommand = colorCommands[i] = new DrawCommand({
owner: primitive,
primitiveType: primitive._primitiveType,
});
}
colorCommand.vertexArray = primitive._va[vaIndex];
colorCommand.renderState = primitive._frontFaceRS;
colorCommand.shaderProgram = primitive._sp;
colorCommand.uniformMap = uniforms;
colorCommand.pass = pass;
if (defined(primitive._depthFailAppearance)) {
if (twoPasses) {
++i;
colorCommand = colorCommands[i];
if (!defined(colorCommand)) {
colorCommand = colorCommands[i] = new DrawCommand({
owner: primitive,
primitiveType: primitive._primitiveType,
});
}
colorCommand.vertexArray = primitive._va[vaIndex];
colorCommand.renderState = primitive._backFaceDepthFailRS;
colorCommand.shaderProgram = primitive._spDepthFail;
colorCommand.uniformMap = depthFailUniforms;
colorCommand.pass = pass;
}
++i;
colorCommand = colorCommands[i];
if (!defined(colorCommand)) {
colorCommand = colorCommands[i] = new DrawCommand({
owner: primitive,
primitiveType: primitive._primitiveType,
});
}
colorCommand.vertexArray = primitive._va[vaIndex];
colorCommand.renderState = primitive._frontFaceDepthFailRS;
colorCommand.shaderProgram = primitive._spDepthFail;
colorCommand.uniformMap = depthFailUniforms;
colorCommand.pass = pass;
}
++vaIndex;
}
}
终于,我们可以找到这篇文章的主角:DrawCommand。
首先来看看,Cesium自己是如何在开发的时候使用这个方法的。我们从上面这个函数的参数开始,可以看到,函数的入口提供了总共8个参数,分别代表:
primitive:当前渲染的对象
appearance/material:外观与材质,这里被用来作为shader与uniform的来源。这里稍微提及一下,uniform在GLSL(也就是shader)中,可以被理解为是一种全局变量。
translucent:模型是否半透明
twoPasses:模型是否需要双面渲染
colorCommands:DrawCommand的列表,这里面是上次DrawCommand的对象,可以复用,以避免每一帧渲染的时候创建过多对象
pickCommands:猜测是物体允许pick开启后的一些表现,这个函数里压根儿没用到,估计是为了接口规范放在这里的
frameStates:当前帧的状态与一些参数
接下来我们逐段来理解函数,在开头部分有以下内容:
//获取正常情况下primitive的uniforms
const uniforms = getUniforms(primitive, appearance, material, frameState);
//获取primitive被遮挡的情况下的uniforms
let depthFailUniforms;
if (defined(primitive._depthFailAppearance)) {
depthFailUniforms = getUniforms(
primitive,
primitive._depthFailAppearance,
primitive._depthFailAppearance.material,
frameState,
);
}
正如函数名说的一样,这个函数的主要作用是将appearance与material中的uniform提取出来,这里分别对应了primitive正常以及被遮挡的两种情况。在这里,虽然获取到的uniforms暂时还只是一个JavaScript对象,但是不难猜到,我们的appearance和material中的uniform实际上就是与GPU进行数据传输的一个接口。而我们在创建一个primitive的时候,如果使用了Cesium预定义好的类,实际上就是使用了预定义好的一个uniform变量。
我们再看接下来的部分,这一部分的作用是遍历primitive的每一个顶点,并为其管理渲染的方式。从结构上我们可以看出来,这部分实际上可以被分为三段,分别为开启双面渲染后的、正常流程的以及需要在被遮挡时被渲染的DrawCommand操作。方便起见,我们这里只取其中的一段来阅读
colorCommand = colorCommands[i];
if (!defined(colorCommand)) {
colorCommand = colorCommands[i] = new DrawCommand({
owner: primitive,
primitiveType: primitive._primitiveType,
});
}
colorCommand.vertexArray = primitive._va[vaIndex];
colorCommand.renderState = primitive._frontFaceRS;
colorCommand.shaderProgram = primitive._sp;
colorCommand.uniformMap = uniforms;
colorCommand.pass = pass;
可以看到,这里展示的就是一次DrawCommand的创建,为了便于理解,我让GPT老师将DrawCommand中的属性写成了一个文档:DrawCommand属性说明文档
对照文档中的内容,我们大致可以明白,上述代码其实进行的是一个primitive的绑定,将primitive中的顶点类型_primitiveType 、顶点数组_va 、渲染状态(这个可以理解成是和渲染有关的一些设置)renderState 、着色器程序_sp 、GPU统一变量uniform 以及是否透明pass 。将这些属性绑定到DrawCommand里来实现一个primitive的渲染。
这里对于着色器程序这个部分需要展开讲一讲,毕竟我们之所以使用DrawCommand这个方法,很大程度的可能性上是为了能将自己写的GLSL代码应用到Cesium中,显然,最为合理的选择就是利用这个shaderProgram对顶点着色器与片元着色器进行自定义。
我们首先找到ShaderProgram的定义https://github.com/CesiumGS/cesium/blob/main/packages/engine/Source/Renderer/ShaderProgram.js#L17,可以看到里面包含了我们需要的自定义着色器接口和各种属性字段的接口,以及一些用于创建ShaderProgram的方法。事实上,当我们在Cesium中需要进行这样的自定义的时候,一般是通过以下方式:
let shaderProgram = Cesium.ShaderProgram.fromCache({
context: context,
attributeLocations: this.attributeLocations,
vertexShaderSource: this.vertexShaderSource,
fragmentShaderSource: this.fragmentShaderSource
});
这里使用fromCache方法有一个很明显的好处,可以复用当前的shaderProgram,从而节省了WebGL的开销。
其中,context是当前Cesium所在的上下文,可以从frameState.context 中获取(frameState来源于Cesium向Primitive对象的update方法中的参数),attributeLocations可以通过Cesium.GeometryPipeline.createAttributeLocations(geometry) 来获取,在文档中,这个属性被描述为Reorders a geometry's attributes and indices to achieve better performance from the GPU's pre-vertex-shader cache. 可以理解为一种优化WebGL性能的手段,而vertexShaderSource与fragmentShaderSource,这两个就分别对应了我们GLSL中的顶点着色器与片元着色器的代码。
这里给出一段使用DrawCommand的示例:
let vertexArray = Cesium.VertexArray.fromGeometry({
context: context,
geometry: this.geometry,
attributeLocations: this.attributeLocations,
bufferUsage: Cesium.BufferUsage.STATIC_DRAW,
});
let shaderProgram = Cesium.ShaderProgram.fromCache({
context: context,
attributeLocations: this.attributeLocations,
vertexShaderSource: this.vertexShaderSource,
fragmentShaderSource: this.fragmentShaderSource
});
let renderState = Cesium.RenderState.fromCache(this.rawRenderState);
return new Cesium.DrawCommand({
owner: this,
vertexArray: vertexArray,
primitiveType: this.primitiveType,
uniformMap: this.uniformMap,
modelMatrix: this.modelMatrix,
shaderProgram: shaderProgram,
framebuffer: this.framebuffer,
renderState: renderState,
pass: Cesium.Pass.OPAQUE
});
最后,只需要将建立好的Command给push到当前Cesium的frameState.commandList 中,这一帧中就会对你自己定义的Command进行渲染了。
三、总结
这里对drawCommand的使用做一个简单的总结,DrawCommand是Cesium中连接JavaScript代码与WebGL底层渲染的核心API。通过理解Primitive的渲染流程,我们可以看到每一帧都会调用Primitive的update方法,进而创建和更新DrawCommand。
在实际使用中,DrawCommand的创建主要涉及以下几个核心要素:
顶点数组(VertexArray):定义了几何体的顶点数据和属性
着色器程序(ShaderProgram):包含自定义的顶点着色器和片元着色器代码
渲染状态(RenderState):控制渲染管线的各种状态设置
统一变量映射(UniformMap):在JavaScript与GLSL之间传递数据的桥梁
是否透明(Pass):决定物体在透明或不透明阶段渲染
对于想要在Cesium中实现自定义渲染效果的开发者来说,DrawCommand提供了足够的灵活性。通过自定义着色器程序,可以实现各种复杂的视觉效果,如自定义材质、特殊光照模型、后处理效果等。最后,只需将构建好的DrawCommand加入到frameState.commandList中,Cesium就会在当前帧对其进行渲染。
值得注意的是,为了性能考虑,应该尽可能复用已创建的对象(使用ShaderProgram.fromCache),避免在每一帧都创建新的资源,这样可以有效减少WebGL的开销,提升渲染性能。