Cesium中基于RayMarching的体积云渲染:从Shader接入到完整实现
2026/9/20 14:18:00 网站建设 项目流程

Cesium 项目里临时加体积云,最难的不是 "写个Shader",而是怎么把 Shader 塞进 Cesium 的渲染管线里。前阵子做数字孪生项目,甲方验收现场提了个需求:场景里要有真实感的云,镜头拉远拉近都不能穿帮。我先试了天空贴图云,镜头一转就跟随视角,假得没法看;又试了粒子面片云,近看全是旋转变换的广告牌。最后让我收场的,就是标题里这条路:Cesium 负责三维场景,RayMarching 在片元着色器里逐像素计算光线穿过云层的路径,也就是俗称的体积云。这篇文章会把从零实现的过程完整拆开,包括 Cesium 工程接入方式、噪声函数选型、步进参数调优、光照模型简化,以及可直接复制的完整 Shader 代码。适合想在 Cesium 项目里加体积云、或对 WebGL 体积渲染感兴趣的开发者。

1. 体积云这条路,为什么是 Cesium + RayMarching

1.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 渲染完的colorTexturedepthTexture,方便把云和场景里的山体、建筑做遮挡混合;第三,它不改变原有场景的渲染流程,做坏了随时摘掉,调试成本低。

2.2 创建一个最小可用的 PostProcessStage

在 Cesium 中创建一个后处理阶段很简单。PostProcessStage构造函数接受一个对象,核心字段是fragmentShaderuniforms。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 Noise

RayMarching 的采样点需要在世界空间里"问这里有没有云",如果每个采样点都返回一个纯随机数,画面会是一堆雪花噪点,没有连贯性。所以必须引入一个平滑过渡的三维噪声函数。

最常用的是 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 的颗粒太均匀,云朵应该有"大块积云 + 碎边"的层次感。FBM(Fractal 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

下面给出一个完整可运行的版本,前面几节的所有函数都整合进来了。这段代码可以直接作为PostProcessStagefragmentShader使用。

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_STEPSstepLen是性能与画质的平衡点,你拿到代码后第一件事应该是把这两个值调一遍,而不是先改颜色。

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_STEPSstepLen:影响性能与画质的平衡,如果你的目标是移动端,先把步长调到 250、采样次数降到 48,再考虑优化其他环节。

我在实际项目里踩过最深的一个坑是:在 Cesium 的后处理 shader 里用了#version 300 es和新的in/out语法,结果在部分浏览器上编译失败,回退到 GLSL ES 1.0 后一切正常。如果你是在 Cesium 版本较新的项目里照搬网上其他 RayMarching 案例,一定要先确认对方用的 GLSL 版本。体积云渲染是个越调越有意思的坑,参数一改,整个场景情绪都不一样。先跑通这个最小版本,再慢慢往里面加天气系统、云层动画、风向扭曲,路就顺了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询