效果:
文中还介绍了 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()
函数将 position3DHigh
和 position3DLow
合成为 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
实例的 depthMask
和 stencilMask
字段。
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
,以及 glTF
、3DTiles
等数据
Entity 和 DataSource 实际上底层也是在调用 Primitive 家族,只不过这两个属于 Viewer;中间的 Globe 与 ImageryLayer 和最后的 Primitive 家族,属于 Scene 容器。
既然这篇是介绍的 Primitive,那么就重点介绍 Primitive 家族。
你一定注意过可以向 scene.primitives
这个 PrimitiveCollection
中添加好几种对象:Model
、Cesium3DTileset
、PrimitiveCollection
(是的,可以嵌套添加)、PointPrimitive
、GroundPrimitive
、ClassificationPrimitive
以及本篇介绍的 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 的文章,列举如下:
CesiumJS Wiki – Geometry and Appearance
cesiumlab vtxf – cesium-custom-primitive
知乎 – Cesium DrawCommand [1] 不谈地球 画个三角形
著名的 Cesium 3D 风场案例就是一个非常经典的应用:
4.3. Primitive 在 Scene 中的大致图示
如果读过我写的源码系列,应该知道 Primitive 在 Scene 的更新位置(Scene
模块下的 render
函数),简单放个图吧:
这样就能大致看到在什么时候更新的 PrimitiveCollection 了。
有兴趣了解源码渲染架构的可以去补补我之前写的系列。
文末小结
这两篇文章集合了我三年前的几篇不成熟的文章,我终于系统地写出了这几个内容:
一般性的 Primitive API
用法,包括
Geometry API
的自定义几何、参数内置几何
Appearance + Material API
所表达的 CesiumJS Fabric 材质规范
提出似 Primitive 的概念,为之后自定义 Primitive 学习挡在 WebGL 原生接口之前的最底层 API 打下基础
简单思考了 CesiumJS 的着色器设计和应用
希望对读者有用。