尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Cesium中基于RayMarching的体积云渲染:从Shader接入到完整实现

Cesium中基于RayMarching的体积云渲染:从Shader接入到完整实现 Cesium 项目里临时加体积云最难的不是 写个Shader而是怎么把 Shader 塞进 Cesium 的渲染管线里。前阵子做数字孪生项目甲方验收现场提了个需求场景里要有真实感的云镜头拉远拉近都不能穿帮。我先试了天空贴图云镜头一转就跟随视角假得没法看又试了粒子面片云近看全是旋转变换的广告牌。最后让我收场的就是标题里这条路Cesium 负责三维场景RayMarching 在片元着色器里逐像素计算光线穿过云层的路径也就是俗称的体积云。这篇文章会把从零实现的过程完整拆开包括 Cesium 工程接入方式、噪声函数选型、步进参数调优、光照模型简化以及可直接复制的完整 Shader 代码。适合想在 Cesium 项目里加体积云、或对 WebGL 体积渲染感兴趣的开发者。1. 体积云这条路为什么是 Cesium RayMarching1.1 三维场景里做云最常见的几种错误做法在做真正可用的方案之前先把市面上常见的 假云 盘点一下方便你判断自己项目到底需不需要上 RayMarching。贴图云把一张带透明的云纹理贴到 Plane 或天空盒上。成本最低但视角一旦发生明显变化云和场景的相对关系就会出问题。水平拉远时云纹跟着相机转非常出戏。粒子云用大量 billboard 粒子堆出云团比如很多游戏里的远处云海。体积感靠粒子密度硬撑粒子少则漏光粒子多则性能告急而且很难做出云底平坦、云顶蓬松的结构。3D 模型云用建模软件雕刻一朵云的 Mesh再以 3D Tiles 或 glTF 加载。效果可以但一个云团就是几万面片满屏云海时场景性能立刻崩盘而且云的边缘是静态几何无法表达光阴流动。深度贴图云用一个数学函数生成云的高度场然后做视差映射。这类方案在飞行模拟里常见适合远处层云但头顶视角往下看时厚度感不足。1.2 RayMarching 到底解决什么问题RayMarching 的中文叫光线步进原理很直白从相机出发对屏幕上的每个像素发出一条射线射线沿视线方向一次次往前踩点每个采样点都问一句这里有没有云云多浓透光多少最后把整条射线累积到的颜色写进屏幕。听起来像暴力算法但正是这种逐像素、逐采样点的计算方式让体积云有了真正的三维结构。云不是一张贴图而是世界空间里一个实时计算的密度场。相机怎么转云都在它该在的位置光线穿过云层时内部浓的部分暗、边缘薄的部分亮这才有照片感。1.3 为什么选 Cesium 而不是 Three.js 或 UE如果单纯想玩体积云Three.js 里已经有大量现成仓库可以抄甚至有不少现成后处理插件。但 Cesium 的场景结构和传统 WebGL 框架不一样它有自己的相机、坐标系、地形、3D Tiles、以及一套完整的 GLSL 内置 uniform 和函数。想在 Cesium 里渲染体积云不能直接把 Three.js 的例子拖进来必须搞明白 Cesium 的渲染管线在哪里插入自定义 shader 最合适。这篇文章会基于 Cesium 的PostProcessStage来做因为云和场景所有物体都要参与遮挡关系后处理阶段能拿到场景深度纹理是权衡性能和效果之后最稳的一条路。2. 先在 Cesium 里搭好自定义渲染的“画板”2.1 Cesium 的三种自定义 Shader 管线怎么选Cesium 里给开发者开放自定义渲染的入口主要有三个很多人第一次接触都会纠结。入口适用对象特点CustomShader3D Tiles 模型节点主要用来做单体化、模型变色、剖切材质阶段生效Fabric 材质Entity / Primitive给模型或几何体换材质适合表面纹理不适合体积渲染PostProcessStage全屏后处理在场景渲染完成后对整块屏幕做一次像素级加工最适合体积云体积云必须选PostProcessStage。原因有三第一云射线从相机出发覆盖整个视锥本质是屏幕空间的逐像素计算第二它还能拿到 Cesium 渲染完的colorTexture和depthTexture方便把云和场景里的山体、建筑做遮挡混合第三它不改变原有场景的渲染流程做坏了随时摘掉调试成本低。2.2 创建一个最小可用的 PostProcessStage在 Cesium 中创建一个后处理阶段很简单。PostProcessStage构造函数接受一个对象核心字段是fragmentShader和uniforms。Cesium 会自动为后处理 shader 注入两个关键输入varying vec2 v_textureCoordinates屏幕 UV 坐标和uniform sampler2D colorTexture已渲染好的场景颜色。你需要在 shader 里声明这两个变量然后直接采样。const cloudStage new Cesium.PostProcessStage({ fragmentShader: uniform sampler2D colorTexture; varying vec2 v_textureCoordinates; void main() { vec4 color texture2D(colorTexture, v_textureCoordinates); gl_FragColor color; } , uniforms: {} }); viewer.scene.postProcessStages.add(cloudStage);这段代码跑起来后屏幕上的效果和没加之前完全一样因为片元着色器只是原样输出了场景颜色。但这就是你的画板接下来所有 RayMarching 逻辑都要写在这个main函数里。这里有个新手容易踩的坑Cesium 后处理 shader 用的是 GLSL ES 1.0 语法不是 WebGL2 那种新版 GLSL。所以输出变量必须写gl_FragColor不能自由声明 out 变量纹理采样要用texture2D不是texture循环次数必须是编译期常量不能动态变化。这些限制在后面的体积云 shader 里会反复遇到。3. 云朵的形状Value 噪声 FBM 的边缘与层次3.1 先有随机再有噪声hash 与 Value NoiseRayMarching 的采样点需要在世界空间里问这里有没有云如果每个采样点都返回一个纯随机数画面会是一堆雪花噪点没有连贯性。所以必须引入一个平滑过渡的三维噪声函数。最常用的是 Value Noise先把空间切成一个个小格子每个格子的顶点绑定一个随机值然后在格子内部做平滑插值。说人话就是三维空间里本来没有值我人为地在点阵上撒随机数再让它们之间平滑过渡。float hash13(vec3 p) { p fract(p * 0.1031); p dot(p, p.zyx 31.32); return fract((p.x p.y) * p.z); } float valueNoise(vec3 p) { vec3 i floor(p); vec3 f fract(p); // 三次样条插值比线性插值更平滑 f f * f * (3.0 - 2.0 * f); float n000 hash13(i); float n100 hash13(i vec3(1.0, 0.0, 0.0)); float n010 hash13(i vec3(0.0, 1.0, 0.0)); float n110 hash13(i vec3(1.0, 1.0, 0.0)); float n001 hash13(i vec3(0.0, 0.0, 1.0)); float n101 hash13(i vec3(1.0, 0.0, 1.0)); float n011 hash13(i vec3(0.0, 1.0, 1.0)); float n111 hash13(i vec3(1.0, 1.0, 1.0)); return mix( mix(mix(n000, n100, f.x), mix(n010, n110, f.x), f.y), mix(mix(n001, n101, f.x), mix(n011, n111, f.x), f.y), f.z ); }简单来说hash13负责给空间里的每个点生成一个伪随机数要求同样的输入必须得到同样的输出否则云会闪烁。valueNoise在一个单元网格内做插值得到连续的三维噪声场。你把valueNoise(p)直接当作颜色输出能看到的是类似大理石纹理的平滑随机场这就是云的原料。3.2 FBM把多层噪声叠起来单层 Value Noise 的颗粒太均匀云朵应该有大块积云 碎边的层次感。FBMFractal Brownian Motion分形布朗运动的做法是把不同频率、不同振幅的噪声叠加低频大振幅决定云的大块轮廓高频小振幅决定云边缘的碎花细节。可以这样记第一层噪声相当于画家用大刷子刷出云团的底色后面几层是小刷子不断抠细节。叠到四五层云边就自然出现丝状、絮状结构。float fbm(vec3 p) { float sum 0.0; float amp 0.5; float freq 1.0; for (int i 0; i 5; i) { sum amp * valueNoise(p * freq); amp * 0.5; freq * 2.0; } return sum; }注意循环上限写死为 5这不是偷懒而是 GLSL ES 1.0 的硬性要求循环次数必须是常量表达式否则部分移动端 GPU 直接编译失败。3.3 高度遮罩、覆盖率控制与高频侵蚀只有 FBM 还不够因为真实云层有个非常明显的特点云底大致在一个高度平面云顶高低起伏中间越积越厚。所以需要引入一个高度遮罩Height Gradient来控制密度分布。float remap(float x, float a, float b, float c, float d) { return c (x - a) / (b - a) * (d - c); } float cloudDensity(vec3 p) { float height p.y; float h01 clamp((height - 1200.0) / 4800.0, 0.0, 1.0); // 底部快速出现顶部逐步衰减 float heightMask smoothstep(0.0, 0.2, h01) * (1.0 - smoothstep(0.6, 1.0, h01)); // 第一层低频大形状决定云层哪里有一大团云 float base fbm(p * 0.0012 vec3(0.0, 10.0, 0.0)); // 覆盖率控制用另一个更低频的噪声决定“今天云多不多” float coverage fbm(p * 0.0003 vec3(17.0, 3.0, 11.0)); base clamp(remap(base, coverage * 0.6, 1.0, 0.0, 1.0), 0.0, 1.0); // 第二层高频侵蚀把云的边缘啃碎同时让云内部有真正的密度变化 float detail fbm(p * 0.008); float density heightMask * base - detail * 0.4; return clamp(density, 0.0, 1.0); }这里有两个值得解释的细节。第一remap的作用是把原本 0 到 1 的噪声值根据coverage动态压缩到更低区间再映射回 0 到 1这样能有效控制云的覆盖率让画面里既有万里无云的区域也有成片云海。第二高频侵蚀 是让云有真实感的点睛之笔用高频率 FBM 作为减法把云的边缘变得破碎同时云内部密度也不再是均匀的。4. 光线步进主循环采样策略与相机跟随的云层包围盒4.1 云层包围盒与 AABB 求交有了密度函数下一步就是让射线从相机出发穿过云层区域进行采样。但射线不可能无限走需要先算出一条射线和云层覆盖的空间的交集。云层通常被约束在一个水平范围较大、垂直高度有限的盒子里。我这里采用相机跟随的局部云层方案以相机位置为中心向四周扩展 20 公里垂直方向限制在 1200 米到 6000 米。这样做的好处是保证云只出现在相机周围毕竟数字孪生项目里用户大概率只看自己项目范围内的一块地方没必要做全球云层。vec2 rayAABB(vec3 ro, vec3 rd, vec3 boxMin, vec3 boxMax) { vec3 invR 1.0 / rd; vec3 t0 (boxMin - ro) * invR; vec3 t1 (boxMax - ro) * invR; vec3 tmin min(t0, t1); vec3 tmax max(t0, t1); float tNear max(max(tmin.x, tmin.y), tmin.z); float tFar min(min(tmax.x, tmax.y), tmax.z); if (tNear tFar) { return vec2(-1.0); } return vec2(tNear, tFar); }rayAABB返回的vec2中x 表示射线进入盒子的距离y 表示离开盒子的距离。如果 x 大于 y说明射线根本没穿过盒子直接返回 -1。这个算法用的是分离轴思想把三维问题拆成三个轴向分别求交再取交集。4.2 主循环里的颜色累积与透射率核心的 RayMarching 主循环其实很短但每一步都对应物理概念光线每走一段距离就会在云层里吸收一部分能量同时也把云散射的阳光累积到视线方向。float t tb.x; float tFar min(tb.y, 200000.0); float transmittance 1.0; // 光线穿到这里还剩下多少 vec3 color vec3(0.0); // 累积的云散射颜色 float stepLen 150.0; const int MAX_STEPS 96; for (int i 0; i MAX_STEPS; i) { if (t tFar) break; vec3 p ro rd * t; float density cloudDensity(p); if (density 0.01) { float light lightMarch(p); vec3 sunColor vec3(1.0, 0.95, 0.85); float extinction 0.004; float deltaOD density * stepLen * extinction; vec3 scatterColor sunColor * light * 0.95; color transmittance * scatterColor * (1.0 - exp(-deltaOD)); transmittance * exp(-deltaOD); if (transmittance 0.015) break; } t stepLen; }这里的transmittance就是透射率可以直观理解为光线走到当前位置还剩下多少能量没有被云吸收。颜色累积的公式里用到了指数衰减exp(-deltaOD)这是符合物理直觉的云越厚能量衰减越快。deltaOD是光学厚度增量等于密度 × 步长 × 消光系数。如果直接把所有采样点的颜色都加在一起画面会过曝。正确做法是把当前点的散射贡献乘上已经衰减剩下的transmittance这样前面云层越厚后面云层对颜色的贡献越小最终才能形成云内部暗、云边缘亮的层次。4.3 步长、抖动与性能预算步长和采样次数是体积云性能的第一瓶颈。步长越小、采样越多细节越丰富但 GPU 压力也越大。我在这个 demo 里用的是 150 米步长、96 次采样上限覆盖距离约 14 公里。如果你需要拉特别远的镜头看云海建议把步长提到 200 到 300 米采样次数适当增加如果是低空飞行视角离云很近步长反而要缩小到 80 米左右否则能看到明显的分层感。还有一个经典优化技巧抖动起点。光线进入云层后如果每次都从 tNear 开始固定步长远处采样点会排成整齐的等高线画面出现楼梯状带状伪影。解决办法是在进入盒子时加一个随机偏移让每条射线的采样起点都不一样。t tb.x hash13(rd * 100.0 ro) * stepLen;这样能把带状伪影打散成高频噪声人类视觉对这种噪声的敏感度远低于规则条纹。如果你不想出现任何噪声可以在主循环结束前对边缘做一次窄范围的 smoothstep 软化效果立竿见影。5. 光照模型散射、吸收和云边缘的那圈亮边5.1 为什么云不能只填一种颜色很多人第一次做体积云会把density当成颜色直接输出密度大的地方亮密度小的地方暗。结果渲染出来像一堆灰色棉花糖没有阳光透过云层时的暖色感也没有云边缘的白亮光圈。原因在于云的颜色不是由密度单独决定的而是由光线穿过云层后被散射到相机方向的比例决定的。阳光从太阳方向射入云层沿途被云滴吸收和散射最后剩下的光再散射进相机视线。这个过程必须用一条指向太阳的短光线步进Light March来估算。5.2 LightMarch向太阳方向再走几步LightMarch 的思路很直接从当前云层采样点出发沿着太阳光入射方向走一小段距离统计这段路径上累积了多少光学厚度然后再换算成透射率。路径上云越厚到达当前采样点的阳光越少当前点看起来就越暗。float lightMarch(vec3 p) { vec3 dir u_sunDir; float opticalDepth 0.0; float stepLen 500.0; const int LIGHT_STEPS 6; for (int i 0; i LIGHT_STEPS; i) { p dir * stepLen; float density cloudDensity(p); opticalDepth density * stepLen * 0.004; } return exp(-opticalDepth); }这里u_sunDir是 CPU 侧传入的太阳方向向量必须是归一化的。LIGHT_STEPS只有 6 次但足够表达云的背光面更暗这个核心特征。如果性能允许增加到 8 次效果会更好再多意义不大因为短距离路径上距离太远的光学厚度变化已经很小。5.3 云的边缘亮光与相位函数真实云层还有一个非常迷人的现象云的边缘尤其是被阳光照到的薄边会特别亮内部反而偏暗。这里有两个物理原因一是薄云透光率高阳光容易穿透二是多重散射在边缘处的贡献占比更高。严格的多重散射模拟需要递归计算实时渲染不可能这样做。实战中我通常用两个简单近似把lightMarch返回的透射率做一个提亮操作比如pow(light, 0.6)让中间调更亮用 Henyey-Greenstein 相位函数让云在太阳方向附近有更强的前向散射形成一圈明亮的银边。float hgPhase(vec3 viewDir, vec3 sunDir, float g) { float cosTheta dot(viewDir, sunDir); float denom 1.0 g * g 2.0 * g * cosTheta; return (1.0 - g * g) / (4.0 * 3.14159265 * sqrt(denom * denom * denom)); }g 是各向异性系数取值通常在 0.2 到 0.8 之间。g 越大光越集中在太阳方向云的轮廓越亮。你可以把它当作一个可调旋钮配合太阳颜色一起试。5.4 太阳颜色与大气散射阳光穿过大气到达云层时已经带了颜色。晴天正午的太阳光更接近白色偏暖清晨和傍晚则偏橙红。在 demo 里可以用一个简单的sunColor向量控制。vec3 sunColor vec3(1.0, 0.95, 0.85); // 正午白暖 // vec3 sunColor vec3(1.0, 0.45, 0.15); // 傍晚橙黄同时还要给云的阴影区一点环境光否则背光面会死黑一片。我通常会在最终云颜色里加一个ambientColor比如vec3(0.08, 0.12, 0.18)再叠加到云上。6. 完整 Shader 代码与代码解释附可直接跑的版本6.1 可直接粘贴到 PostProcessStage 的 Fragment Shader下面给出一个完整可运行的版本前面几节的所有函数都整合进来了。这段代码可以直接作为PostProcessStage的fragmentShader使用。uniform sampler2D colorTexture; uniform sampler2D depthTexture; uniform vec3 u_cameraPos; uniform vec3 u_sunDir; uniform float u_time; varying vec2 v_textureCoordinates; float hash13(vec3 p) { p fract(p * 0.1031); p dot(p, p.zyx 31.32); return fract((p.x p.y) * p.z); } float valueNoise(vec3 p) { vec3 i floor(p); vec3 f fract(p); f f * f * (3.0 - 2.0 * f); float n000 hash13(i); float n100 hash13(i vec3(1.0, 0.0, 0.0)); float n010 hash13(i vec3(0.0, 1.0, 0.0)); float n110 hash13(i vec3(1.0, 1.0, 0.0)); float n001 hash13(i vec3(0.0, 0.0, 1.0)); float n101 hash13(i vec3(1.0, 0.0, 1.0)); float n011 hash13(i vec3(0.0, 1.0, 1.0)); float n111 hash13(i vec3(1.0, 1.0, 1.0)); return mix( mix(mix(n000, n100, f.x), mix(n010, n110, f.x), f.y), mix(mix(n001, n101, f.x), mix(n011, n111, f.x), f.y), f.z ); } float fbm(vec3 p) { float sum 0.0; float amp 0.5; float freq 1.0; for (int i 0; i 5; i) { sum amp * valueNoise(p * freq); amp * 0.5; freq * 2.0; } return sum; } float remap(float x, float a, float b, float c, float d) { return c (x - a) / (b - a) * (d - c); } float cloudDensity(vec3 p) { float h01 clamp((p.y - 1200.0) / 4800.0, 0.0, 1.0); float heightMask smoothstep(0.0, 0.2, h01) * (1.0 - smoothstep(0.6, 1.0, h01)); float baseFbm fbm(p * 0.0012 vec3(u_time * 0.02, 10.0, 0.0)); float coverage fbm(p * 0.0003 vec3(17.0, 3.0, 11.0)); float base clamp(remap(baseFbm, coverage * 0.6, 1.0, 0.0, 1.0), 0.0, 1.0); float detail fbm(p * 0.008 vec3(u_time * 0.03, 2.0, 1.0)); return clamp(heightMask * base - detail * 0.4, 0.0, 1.0); } vec2 rayAABB(vec3 ro, vec3 rd, vec3 boxMin, vec3 boxMax) { vec3 invR 1.0 / rd; vec3 t0 (boxMin - ro) * invR; vec3 t1 (boxMax - ro) * invR; vec3 tmin min(t0, t1); vec3 tmax max(t0, t1); float tNear max(max(tmin.x, tmin.y), tmin.z); float tFar min(min(tmax.x, tmax.y), tmax.z); if (tNear tFar) return vec2(-1.0); return vec2(tNear, tFar); } float lightMarch(vec3 p) { vec3 dir u_sunDir; float opticalDepth 0.0; float stepLen 400.0; for (int i 0; i 5; i) { p dir * stepLen; float density cloudDensity(p); opticalDepth density * stepLen * 0.004; } return exp(-opticalDepth); } void main() { vec4 sceneColor texture2D(colorTexture, v_textureCoordinates); // 通过逆视锥投影矩阵重建当前像素对应的射线方向 vec4 ndc vec4(v_textureCoordinates.xy * 2.0 - 1.0, 1.0, 1.0); vec4 farPos czm_inverseViewProjection * ndc; farPos / farPos.w; vec3 ro u_cameraPos; vec3 rd normalize(farPos.xyz - ro); // 相机跟随的云层 AABB vec3 boxMin vec3(ro.x - 20000.0, 1200.0, ro.z - 20000.0); vec3 boxMax vec3(ro.x 20000.0, 6000.0, ro.z 20000.0); vec2 tb rayAABB(ro, rd, boxMin, boxMax); if (tb.x 0.0) { gl_FragColor sceneColor; return; } float t tb.x hash13(rd * 100.0 ro) * 150.0; float tFar min(tb.y, 120000.0); float transmittance 1.0; vec3 color vec3(0.0); float stepLen 150.0; const int MAX_STEPS 96; for (int i 0; i MAX_STEPS; i) { if (t tFar) break; vec3 p ro rd * t; float density cloudDensity(p); if (density 0.01) { float light lightMarch(p); vec3 sunColor vec3(1.0, 0.95, 0.85); vec3 ambient vec3(0.08, 0.12, 0.18); float extinction 0.004; float deltaOD density * stepLen * extinction; vec3 scatterColor sunColor * light ambient; color transmittance * scatterColor * (1.0 - exp(-deltaOD)); transmittance * exp(-deltaOD); if (transmittance 0.015) break; } t stepLen; } vec3 cloudColor color; float alpha 1.0 - transmittance; vec3 finalColor mix(sceneColor.rgb, cloudColor, alpha); gl_FragColor vec4(finalColor, 1.0); }这段代码里我最想强调的是两处一是在进入云层时对t做了抖动这是去掉带状伪影的关键二是MAX_STEPS和stepLen是性能与画质的平衡点你拿到代码后第一件事应该是把这两个值调一遍而不是先改颜色。6.2 Cesium 侧 JS 接入代码Shader 写好后Cesium 侧接入很容易。需要每帧把相机位置和太阳方向传进去时间变量用 Cesium 的clock驱动。const cloudStage new Cesium.PostProcessStage({ fragmentShader: cloudFS, uniforms: { u_cameraPos: function() { return viewer.camera.positionWC; }, u_sunDir: function() { const sunDir new Cesium.Cartesian3(0.5, -0.3, 0.8); return Cesium.Cartesian3.normalize(sunDir, new Cesium.Cartesian3()); }, u_time: function() { return viewer.clock.currentTime.secondsOfDay; } } }); viewer.scene.postProcessStages.add(cloudStage);uniforms里的值如果写成函数Cesium 会在每一帧渲染前自动重新求值所以不需要手动在onTick里赋值。u_cameraPos可以直接返回viewer.camera.positionWC这个属性本身是一个世界坐标向量。6.3 深度遮挡让云被山体和建筑挡住只跑上面的版本云会无脑绘制在场景所有物体之上包括建筑和山体。原因很简单后处理发生在场景渲染完之后它并不知道当前屏幕位置的建筑离相机多远。想要建筑挡住云必须读取depthTexture。float depth czm_unpackDepth(texture2D(depthTexture, v_textureCoordinates)); vec4 eyePos czm_windowToEyeCoordinates(gl_FragCoord.xy, depth); float sceneDist length(eyePos.xyz); // 场景物体到相机的距离拿到sceneDist后在主循环里把tFar限制为min(tFar, sceneDist)即可当步进距离超过场景物体距离时说明云被物体挡住了后面的云不用再计算。float tFar min(tb.y, 120000.0); if (depth 1.0) { tFar min(tFar, sceneDist - 10.0); // 留一点偏移避免云贴建筑表面 }这里depth 1.0的判断是为了略过天空区域。Cesium 的深度纹理在 WebGL 1.0 环境下默认经过了czm_unpackDepth解包你可以直接用这个值。加上深度遮挡后云才会真正嵌进三维场景里而不是一张贴在屏幕上的云层滤镜。6.4 第一次跑通后最值得调的三个旋钮代码能跑起来只是第一步真正让云逼真靠的是调参。我个人经验里性价比最高的三个参数是coverage的系数cloudDensity 函数里的coverage * 0.6影响云的覆盖率调低一点画面更清新调高一点能做出阴天云海。heightMask的上下边界当前是 0.2 和 0.6影响云层的厚度分布把上界调低会让云层更扁调高会出现高耸的积云塔。MAX_STEPS和stepLen影响性能与画质的平衡如果你的目标是移动端先把步长调到 250、采样次数降到 48再考虑优化其他环节。我在实际项目里踩过最深的一个坑是在 Cesium 的后处理 shader 里用了#version 300 es和新的in/out语法结果在部分浏览器上编译失败回退到 GLSL ES 1.0 后一切正常。如果你是在 Cesium 版本较新的项目里照搬网上其他 RayMarching 案例一定要先确认对方用的 GLSL 版本。体积云渲染是个越调越有意思的坑参数一改整个场景情绪都不一样。先跑通这个最小版本再慢慢往里面加天气系统、云层动画、风向扭曲路就顺了。
返回列表