前言
实现飘动红旗的效果整体分两部,一是利用三角函数的周期性让红旗摆动起来,二是根据每个片元的摆动幅度来计算对应位置的阴影。
这是我在一个园区项目中收到的需求,在此记录及分享实现过程。
基础场景搭建(创建cesium场景和必要的实体)
这里使用gltf模型作为红旗,因为需要获得平滑的摆动效果,因此使用的模型面数较多,同时为了旗子与旗杆可以使用相同的坐标位置,我将模型的定位锚地放到了左上角(见下图,来自建模软件blender)。同样的,飘动的功能也可以手动创建Cesium中polygon或rectangle实体来完成,核心部分与使用gltf模型无异。

创建基础场景
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| Cesium.Ion.defaultAccessToken = "your token"; const viewer = new Cesium.Viewer("cesiumContainer"); viewer.camera.setView({ destination: Cesium.Cartesian3.fromDegrees(116.39122232836966, 39.90701265936752, 4.813199462406734), orientation: { heading: Cesium.Math.toRadians(26.754499635799313), pitch: Cesium.Math.toRadians(5.094600937875728), roll: 0, }, }); const modelPosition = [116.39124568344667, 39.90705858625655, 6]
const flag = viewer.entities.add({ position: Cesium.Cartesian3.fromDegrees(...modelPosition), model: { uri: '../source/models/旗子/旗子.gltf', }, });
viewer.entities.add({ position: Cesium.Cartesian3.fromDegrees(modelPosition[0], modelPosition[1]), ellipse: { semiMinorAxis: 0.01, semiMajorAxis: 0.01, extrudedHeight: modelPosition[2] + 0.05, material: Cesium.Color.fromCssColorString('#eee'), }, });
|

让旗子飘动起来
请注意,下文中所有着色器的坐标控制都是基于模型自身的局部坐标系。如果使用不同的模型,可能需要根据模型的具体坐标系统调整相关参数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
| const customShader = new Cesium.CustomShader({ uniforms: { u_time: { type: Cesium.UniformType.FLOAT, value: 0.0 }, u_texture: { type: Cesium.UniformType.SAMPLER_2D, value: new Cesium.TextureUniform({ url: "../source/models/旗子/hongqi.jpg", }) } }, varyings: { v_offset: Cesium.VaryingType.FLOAT }, vertexShaderText: ` void vertexMain(VertexInput vsInput, inout czm_modelVertexOutput vsOutput) { // 根据模型uv坐标的x和y坐标来确定摆动的频率(对应sin曲线的波频) // 这里控制波频时,分别用到了x和y轴坐标,并让x坐标权重大于y坐标,使得摆动更加自然 // 最后乘以0.13是为了控制摆动的幅度(对应sin曲线的波高) float offset = sin(vsInput.attributes.texCoord_0.x * 8.0 + vsInput.attributes.texCoord_0.y * 1.5 - u_time) * 0.13; v_offset = offset - offset * smoothstep(0.4, 0.0, vsInput.attributes.texCoord_0.x); // 为片元赋予新的x坐标,新的x坐标为原始x坐标加上摆动偏移量 vsOutput.positionMC.x += vsOutput.positionMC.x + v_offset; // 因为旗子在x方向上有前后摆动,因此在视觉上z轴应当适当缩短一些 vsOutput.positionMC.z *= 0.95; }`, fragmentShaderText: ` void fragmentMain(FragmentInput fsInput, inout czm_modelMaterial material) { }` }) flag.model.customShader = customShader; let lastTime = Date.now(); viewer.clock.onTick.addEventListener(function () { const currentTime = Date.now(); const deltaTime = currentTime - lastTime; lastTime = currentTime; customShader.uniforms.u_time.value += deltaTime * 0.007; });
|

从上图可以看出,虽然旗子已经实现了摆动效果,但与预期不符:靠近旗杆的一侧本应保持不动。接下来进一步优化顶点着色器代码。
1 2 3 4 5 6 7 8 9 10
| void vertexMain(VertexInput vsInput, inout czm_modelVertexOutput vsOutput) { float offset = sin(vsInput.attributes.texCoord_0.x * 8.0 + vsInput.attributes.texCoord_0.y * 1.5 - u_time) * 0.13; v_offset = offset - offset * smoothstep(0.4, 0.0, vsInput.attributes.texCoord_0.x); vsOutput.positionMC.x += vsOutput.positionMC.x + v_offset; vsOutput.positionMC.z *= 0.95; }
|
此时smoothstep函数已经把旗杆一侧固定住了。

为旗子添加贴图和阴影
1 2 3 4 5 6 7 8 9
| void fragmentMain(FragmentInput fsInput, inout czm_modelMaterial material) { fsInput.attributes.texCoord_0.y *= -1.0; vec4 color = texture(u_texture,fsInput.attributes.texCoord_0) * min((1.0 - v_offset * 2.0),1.0); material.diffuse=vec3(color.rgb); }
|

上图可以看出,旗子凹陷部分已经有了阴影,但是此时阴影和片元的偏移程度为线性关系,阴影处的对比不够强烈,下面分享另一种阴影算法。
优化阴影
接下来通过实现阴影随片元偏移量的指数级增长来增强阴影部分的对比度,使效果更加逼真。
1 2 3 4 5 6 7 8 9 10 11
| void fragmentMain(FragmentInput fsInput, inout czm_modelMaterial material) { float offsetSquared = v_offset * v_offset; float offsetSign = v_offset >= 0.0 ? 1.0 : -1.0; fsInput.attributes.texCoord_0.y *= -1.0; vec4 color = texture(u_texture, fsInput.attributes.texCoord_0) * min(1.0 - offsetSquared * offsetSign * 30.0, 1.0); material.diffuse=vec3(color.rgb); }
|
比刚才逼真多了


完整的CustomShader代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
| const customShader = new Cesium.CustomShader({ uniforms: { u_time: { type: Cesium.UniformType.FLOAT, value: 0.0 }, u_texture: { type: Cesium.UniformType.SAMPLER_2D, value: new Cesium.TextureUniform({ url: "../source/models/旗子/hongqi.jpg", }) } }, varyings: { v_offset: Cesium.VaryingType.FLOAT }, vertexShaderText: ` void vertexMain(VertexInput vsInput, inout czm_modelVertexOutput vsOutput) { // 根据模型uv坐标的x和y坐标来确定摆动的频率(对应sin曲线的波频) // 这里控制波频时,分别用到了x和y轴坐标,并让x坐标权重大于y坐标,使得摆动更加自然 // 最后乘以0.13是为了控制摆动的幅度(对应sin曲线的波高) float offset = sin(vsInput.attributes.texCoord_0.x * 8.0 + vsInput.attributes.texCoord_0.y * 1.5 - u_time) * 0.13; // 这是关键的一步,使用平滑阶梯函数smoothstep函数来控制摆动的范围 // smoothstep(0.4, 0.0, vsInput.attributes.texCoord_0.x)表达式在uv的x轴坐标靠近起点时返回1,到达x轴的0.4时返回0 // 再用offset减去offset乘以smoothstep函数的结果,就可以得到在x轴坐标靠近0时,offset值为0的效果 // 关于smoothstep函数的更多信息,请参考https://zhuanlan.zhihu.com/p/157758600 v_offset = offset - offset * smoothstep(0.4, 0.0, vsInput.attributes.texCoord_0.x); // 为片元赋予新的x坐标,新的x坐标为原始x坐标加上摆动偏移量 vsOutput.positionMC.x += vsOutput.positionMC.x + v_offset; // 因为旗子在x方向上有前后摆动,因此在视觉上z轴应当适当缩短一些 vsOutput.positionMC.z *= 0.95; }`, fragmentShaderText: ` void fragmentMain(FragmentInput fsInput, inout czm_modelMaterial material) { // 计算片元偏移量的平方用于后续的阴影计算 float offsetSquared = v_offset * v_offset; // 获取片元偏移量的符号,用于在后续的计算中保留偏移量的正负性 float offsetSign = v_offset >= 0.0 ? 1.0 : -1.0; fsInput.attributes.texCoord_0.y *= -1.0; // 1.0 - offsetSquared * offsetSign * 30.0表达式用来决定片元的亮度,原理与上面的着色器一致 // 不过此时片元的亮度与偏移量为指数级增长关系,会在阴影区域获得更加大的反差,增强逼真度 vec4 color = texture(u_texture, fsInput.attributes.texCoord_0) * min(1.0 - offsetSquared * offsetSign * 30.0, 1.0); material.diffuse=vec3(color.rgb); }` })
|
总结
摆动核心
用三角函数把 UV 坐标映射成随时间变化的偏移量,再用 smoothstep 把旗杆侧固定,就能让红旗只在自由端飘动。
阴影核心
把顶点着色器算出的偏移量 v_offset 传进片元着色器,用“1 − 偏移量² × 符号 × 放大系数”做指数级压暗,使阴影逼真立体。
扩展和思考
- 如何使用噪声实现上面的功能?
- 如何为摆动的增加随机性?
祝玩旗愉快!