☰
光线步进与SDF实战:从零构建实时体积烟雾渲染
2026/9/28 1:32:57 网站建设 项目流程

大家在做实时渲染或者想深入研究体积雾、爆炸特效、烟雾弹这类效果时,大概率都遇到过同一个问题:网上资料要么只讲 Raymarching 数学原理,要么只丢一个 ShaderToy 链接,真正能落地的完整案例非常少。之前我一直在研究 CS2 里烟雾弹那种“黏稠、厚重、又会缓慢翻滚扩散”的视觉效果,后来看到 Acerola 有一期视频专门逆向复现类似效果,结合光线步进和 SDF 体积渲染,思路非常清晰。这篇文章就把我拆解这个案例的全部过程整理出来,从 SDF 基础到 Raymarching 主循环,再到一个可运行的体积烟雾 Demo,附带常见坑点和工程优化建议,希望能帮到正在啃图形学渲染分支这块内容的同学。

本文适合两类读者:

  • 刚接触光线步进、SDF、体积渲染的初学者,希望先建立完整的理论框架。
  • 已经写过简单 Raymarching 但没做过“体积材质”的进阶开发者,想理解烟雾、云层、爆炸这类参与介质效果是怎么渲染出来的。

读完你可以掌握:SDF 的基本定义与常用距离函数、光线步进的核心循环、如何用叠加噪声场和密度函数模拟烟雾形体、怎样从单帧渲染扩展到可交互的实时效果,以及常见的锯齿、性能、调参问题怎么解决。

1. 背景:为什么要用光线步进和 SDF 做烟雾弹

1.1 从 CS2 烟雾弹的视觉特性说起

我们先回想一下 CS2 里烟雾弹的视觉观感:烟雾不是一大块均匀的灰白色半透明球,而是在空间中有密度变化,边缘有涡旋和翻滚感,中间偏厚、边缘偏薄,光穿过烟雾时会有衰减和散射感。如果只用传统三角形网格 + 透明贴图做,很难做出“立体体积”的感觉,因为那只是“面片上的透明度”,不是“空间中的密度”。

真正让烟雾看起来立体,是因为我们把它当作一个“参与介质”(Participating Media):空间中每一点都有密度值,光线穿过时会被吸收和散射。密度高的地方更厚实,密度低的地方更通透。要渲染这种效果,常见方案有两种:

  1. 体素化(Voxelization)后做体积光照,例如体素锥追踪。
  2. 光线步进 + SDF,在着色器中逐光线、逐点采样材质密度。

Acerola 的逆向复现案例走的是第二条路线,因为它实现起来最直接,不需要预计算复杂 3D 纹理,也能在 ShaderToy 这类 Web 环境中高效运行。

1.2 为什么叫“逆向复现”

“逆向”在这里不是破解或反编译 CS2 客户端,而是根据视频画面表现,反推烟雾效果的实现思路。CS2 官方用的具体算法并没有公开,但我们知道它大概率用了体积渲染相关技术。Acerola 的做法是:从视觉需求出发,用光线步进 + SDF + 噪声场重新造出一个相似度很高的烟雾效果。这种“视觉逆向”在图形学研究中非常常见:看到效果,拆解原理,再用自己的技术栈复现。

1.3 学习这条技术路线的价值

光线步进(Raymarching)和 SDF 几乎是现代图形学进阶必学的内容:

  • 它是 ShaderToy 上海量特效的基础。
  • 它直接服务于体积云、雾气、火焰、爆炸、流体等效果。
  • 它也是很多离线渲染器体积散射的简化实时版。
  • 弄懂 SDF 和光线步进,再去学 VDB、体素渲染、神经辐射场(NeRF)时会有非常强的迁移感。

因此,这篇文章不只是复现一个烟雾弹,而是把“用 SDF 描述物体 + 用光线步进计算颜色”这套能力完整过一遍。

2. 环境准备与版本说明

2.1 运行环境

这个案例更适合用 GLSL 片元着色器实现,因此推荐的环境是 ShaderToy 在线编辑器,或者本地用 WebGL/OpenGL 构建一个小 Demo。为了便于大家直接复制运行,我以 ShaderToy 的 GLSL 环境为主,同时给出本地工程的思路。

  • 浏览器:Chrome / Edge 最新版,需要支持 WebGL2。
  • ShaderToy 模式:Shader类型,默认Image模式即可。
  • 语言:GLSL ES 3.0(ShaderToy 默认是 GLSL ES 3.0 兼容风格)。

如果你要用本地项目,推荐:

  • VS Code +glsl-canvas插件,可以直接写 GLSL 并在本地预览。
  • 或者用 Three.js 加载自定义 ShaderMaterial。

版本说明:由于 ShaderToy 和浏览器环境会自动适配 GLSL 版本,本文中的代码重点演示算法思路,具体版本差异不会影响核心逻辑。如果你在本地编译遇到version指令问题,在文件头部加入#version 300 es即可。

2.2 示例工程结构

如果是 ShaderToy,只需要两个 Buffer:

  • Buffer A:负责更新一组随时间演化的噪声场(可选,用于模拟翻滚)。
  • Image:负责最终的光线步进渲染,采样 Buffer A 或直接在片元着色器里计算噪声。

为了降低复杂度,本文直接在Image中实时计算三维噪声,不需要多 Buffer。这样更容易看懂,也方便后续优化。

如果是本地 WebGL 工程,建议结构如下:

raymarching-smoke/ ├── index.html ├── main.js └── shaders/ ├── vertex.glsl └── fragment.glsl

2.3 关键依赖

不需要任何第三方库,只需要 GLSL 内置函数,以及自己实现的:

  • hash 函数:生成随机数。
  • value noise / Perlin noise / Simplex noise。
  • Smoothstep、Clamp、Mix 等辅助函数。

这些都不是外部依赖,写进代码即可。

3. 核心概念:SDF 与光线步进到底在算什么

3.1 SDF 是什么

SDF 全称 Signed Distance Function,中文叫有向距离场或有符号距离函数。它输入空间中的一个点p,输出该点到某个几何体的最短距离。符号的含义是:如果点在几何体内部,距离为负;点在外部,距离为正;恰好边界上,距离为 0。

举两个最简单的例子:

  • 球体:length(p) - radius。点 (0,0,0) 时输出 -radius,圆心在内部;点 (radius+1,0,0) 时输出 1,说明在球外 1 单位处。
  • 立方体:使用box函数,我会在下面代码里给出。

SDF 的好处是:我们不需要知道几何体的网格、顶点、三角形,只需要一个数学表达式,就能在空间任意位置查询距离。这让它非常适合光线步进,因为我们可以在一条光线上不断前进,直到碰到物体表面。

3.2 光线步进原理

光线步进(Raymarching)也叫球体追踪(Sphere Tracing)。对屏幕上的每个像素,从摄像机发射一条光线ray,方向为dir。我们沿着光线方向,以步长前进,每次前进到新位置pos,就计算pos到场景中最近物体的距离d。

如果d大于某个很小的阈值(比如 0.0001),说明还没碰到物体,继续前进。如果d小于阈值,说明光线打到了物体表面,此时可以用pos计算颜色或光照。

伪代码如下:

for (int i = 0; i < MAX_STEPS; i++) { float d = map(pos); if (d < EPSILON) { // 命中 break; } pos += dir * d; }

这里的关键是:SDF 告诉我们的距离是最短距离,当它小于一个阈值时,说明我们已经无限接近表面。如果 SDF 计算正确,这种追踪方法是安全且高效的,不会因为步长过大而穿透物体。

3.3 从“表面渲染”到“体积渲染”的转变

传统 SDF 光线步进用于渲染硬表面物体,例如金属球、扭曲的环、布尔运算后的几何体。命中表面后,我们再根据法线算光照。

但烟雾不是硬表面,它是“半透明体积”。我们无法用“距离 = 0”来判断是否命中,因为空间每一处都可能存在烟雾密度。所以体积渲染的思路是:

  1. 沿光线方向分成很多采样点。
  2. 在每个采样点计算“密度值”。
  3. 根据物理模型,用 Beer-Lambert 定律计算光穿过该点后的衰减。
  4. 累加各个采样点的散射颜色,得到最终像素颜色。

简单说,表面渲染关心“是否碰到物体”,体积渲染关心“这段路径上累计了多少吸收与散射”。

3.4 为什么烟雾要用 SDF 而不是直接用网格

其实烟雾这类体积效果也可以用 3D 纹理(体素数据)来驱动。但 SDF 的优势是:

  • 连续:SDF 可以任意采样,没有分辨率限制,纹理则有插值损失。
  • 紧凑:几个噪声表达式就能定义很复杂的形状。
  • 便于做 SDF 混合、扭曲、域变形。

不过 SDF 也有弱点:对于非常复杂的气体流动,SDF 表达能力不如真正的流体模拟数据。Acerola 的案例主要是“外观复现”,所以用 SDF + 噪声已经足够。

4. 光线步进 + SDF 体积烟雾实战案例

下面进入正题。我们完整实现一个“烟雾弹”效果。这个 demo 最终会呈现一个缓慢翻滚、边缘有光晕、内部密度不均匀的半透明烟雾团。

4.1 创建项目结构

如果你是在 ShaderToy 里操作:

  • 点击 “New” 创建 Shader。
  • 将下面的代码粘贴到 “Image” 标签页。

如果你在本地:

  • 参考前面目录结构,在 fragment.glsl 中粘贴片元着色器代码。

4.2 编写基础工具函数

我们先准备几个常用函数:hash、noise、SDF 球体。

// 随机哈希:输入一个向量,输出一个稳定的随机数 float hash(float n) { return fract(sin(n) * 43758.5453123); } // 二维噪声,基于网格顶点插值 float noise(vec2 p) { vec2 i = floor(p); vec2 f = fract(p); vec2 u = f * f * (3.0 - 2.0 * f); float a = hash(i.x + i.y * 57.0); float b = hash(i.x + 1.0 + i.y * 57.0); float c = hash(i.x + (i.y + 1.0) * 57.0); float d = hash(i.x + 1.0 + (i.y + 1.0) * 57.0); return mix(mix(a, b, u.x), mix(c, d, u.x), u.y); } // 三维噪声:通过多张二维噪声切片混合 float noise3D(vec3 p) { float n = noise(p.xy); n += 0.5 * noise(p.yz); n += 0.25 * noise(p.zx); return n / 1.75; } // 球体SDF float sdSphere(vec3 p, float r) { return length(p) - r; }

这里的noise3D并不是严格的三维 Perlin 噪声,而是一种土办法:将三个方向的二维噪声加权相加,得到一个近似三维噪声。这种写法在 ShaderToy 中很常见,足够用于烟雾密度。

4.3 定义烟雾的密度场

烟雾的体积由密度场描述。我们定义一个map函数,返回某个点的“烟雾浓度”,而不是距离。

float map(vec3 p) { // 让噪声随时间缓慢平移,制造流动感 vec3 q = p; q.x += 0.15 * iTime; q.y += 0.1 * iTime; q.z += 0.08 * iTime; // 基础密度:一个大球体作为范围约束 float sphere = sdSphere(p, 2.0); // 噪声扰动:在球内部生成不规则密度 float dens = noise3D(q * 1.5); // 密度在球外迅速衰减 // sphere 是负数时表示在球内,越大越靠近边界 float mask = smoothstep(0.0, -2.0, sphere); // 最终密度 return dens * mask; }

解释一下:

  • iTime是 ShaderToy 内置的全局时间变量,让烟雾随时间动态变化。
  • q.x += 0.15 * iTime让噪声场沿着 X 轴缓慢移动,产生烟雾扩散感。
  • sphere = sdSphere(p, 2.0)在半径为 2 的球内,sphere为负;越靠近边缘,值越接近 0。
  • smoothstep(0.0, -2.0, sphere)将sphere从 [0, -2] 映射到 [0, 1],使球体内侧密度逐渐增加,外部密度为 0。这里参数0.0是外部边界,-2.0是内部较深处。

这样,烟雾密度并不是均匀的,而是球体约束下由噪声调制。边缘区域密度低,看起来更透明;中心区域密度高,烟雾更厚。

4.4 核心光线步进循环

现在写主函数mainImage,对每个像素发射光线,沿光线采样密度并累积颜色。

void mainImage(out vec4 fragColor, in vec2 fragCoord) { // 将像素坐标归一化到 [-1, 1] vec2 uv = (fragCoord - 0.5 * iResolution.xy) / iResolution.y; // 摄像机射线 vec3 ro = vec3(0.0, 0.0, 4.5); vec3 forward = normalize(vec3(0.0, 0.0, 0.0) - ro); vec3 right = normalize(cross(forward, vec3(0.0, 1.0, 0.0))); vec3 up = cross(right, forward); vec3 rd = normalize(forward + uv.x * right + uv.y * up); // 步进参数 float t = 0.0; float tMax = 8.0; float stepSize = 0.05; // 累积颜色和透明度 vec3 color = vec3(0.0); float transmittance = 1.0; // 背景渐变 vec3 bg = mix(vec3(0.05, 0.08, 0.1), vec3(0.2, 0.25, 0.3), uv.y * 0.5 + 0.5); for (int i = 0; i < 128; i++) { if (t > tMax) break; vec3 pos = ro + rd * t; float dens = map(pos); if (dens > 0.01) { // 吸收系数,控制烟雾浓度 float absorb = dens * 0.3; // 假设光线被吸收一部分,透明度减少 transmittance *= exp(-absorb * stepSize); // 如果透明度很小,提前终止 if (transmittance < 0.01) break; // 散射颜色:这里用了简单的单次散射近似 vec3 lightDir = normalize(vec3(1.0, 0.5, -1.0)); float lightAmount = max(0.0, dot(pos - ro, lightDir)); vec3 scatter = vec3(1.0, 0.9, 0.8) * dens * transmittance; // 将散射贡献累加 color += scatter * stepSize; } t += stepSize; } // 将背景叠加到剩余透射率上 color += bg * transmittance; fragColor = vec4(color, 1.0); }

这段代码的核心逻辑:

  • 用固定步长stepSize前进,在每个采样点查密度。
  • 通过exp(-absorb * stepSize)计算光线穿过该小段后的能量剩余比例,也就是 Beer-Lambert 定律的离散形式。
  • 把每次采样得到的散射光按透射率加权后累加到最终颜色。
  • 如果transmittance小于 0.01,说明光线衰减完了,提前跳出循环,提高效率。
  • 最后,把背景色按透射率合成进来:透射率越低,背景贡献越少,烟雾越遮挡背景。

4.5 运行与预期效果

将上面的代码粘贴到 ShaderToy 后,点击运行,你应该能看到一个缓慢流动、中心偏亮、边缘半透的烟雾团。由于我们使用了简单的单次散射近似,烟雾正面面对光源时会更亮,背对光源时暗一些,整体看起来有立体感。

如果你想要更强的“烟雾弹”感觉,可以调整如下参数:

  • stepSize越大,烟雾颗粒感越强,性能越好,但质量下降。
  • absorb越大,烟雾越不透明。
  • tMax越小,烟雾被截断得越早,能看到局部形状。
  • noise3D的缩放倍数(q * 1.5)影响烟雾纹理尺度:倍数越高,细节越密集。

4.6 进阶:增加光源和阴影感

上面的光照计算非常简陋,只是用pos - ro与光方向点乘,这并不能形成真正的阴影。要更真实,需要判断光路是否被烟雾阻挡,即“光学深度”:

在采样点pos,朝光源方向再步进一小段,计算这段光路上的累积密度,用来衰减直接光照。这会显著增加计算量,但效果提升明显。下面是一个简单的阴影函数:

float shadow(vec3 ro, vec3 rd) { float t = 0.0; float res = 1.0; for (int i = 0; i < 24; i++) { vec3 pos = ro + rd * t; float dens = map(pos); res *= exp(-dens * 0.3 * 0.05); t += 0.05; if (res < 0.01) break; } return res; }

然后在主循环里:

float NdotL = max(dot(normalize(pos - ro), lightDir), 0.0); float shadowFactor = shadow(pos, lightDir); color += vec3(1.0, 0.9, 0.8) * dens * shadowFactor * NdotL * transmittance * stepSize;

这样烟雾的向光面更亮,背光面更暗,层次感更强。不过要注意,加入阴影后性能会下降,步长和迭代次数都要控制。

5. 常见问题与排查思路

5.1 画面全是黑色或白色噪点

问题现象常见原因解决思路
全黑射线方向计算错误,导致看向摄像机方向检查ro和rd构建是否正确
大量噪点步长过大、采样点太少减小stepSize,增加循环次数
屏幕中有周期性条纹固定步长导致的摩尔纹使用抖动抖动或半抖动偏移步长起点
烟雾消失dens始终为 0检查smoothstep的edge0和edge1参数是否写反

其中最常见的是射线方向问题,uv需要映射到屏幕坐标系,且forward必须与ro指向目标点。建议先用fragColor = vec4(rd * 0.5 + 0.5, 1.0)调试,能显示方向颜色变化基本就没问题。

5.2 烟雾边缘“塑料感”太强

塑料感主要来自密度场变化太平滑,或者光照太整齐。可以:

  • 添加 FBM(分形噪声)叠加多层噪声,增加细节。
  • 使用乱序抖动(jitter)打破固定步长的规律感。
  • 提高噪声频率缩放倍数。

5.3 性能太差怎么办

体积渲染非常耗性能。优化方向按优先级排列:

  1. 尽早终止:transmittance < 0.01时 break。
  2. 缩短tMax。
  3. 增大stepSize。
  4. 降低噪声计算次数。
  5. 减少阴影函数中的循环次数。
  6. 使用半分辨率渲染再上采样(本地工程中常见)。

在 ShaderToy 上如果帧率低于 30,建议先调stepSize到 0.08,迭代次数降到 80。

5.4 怎么让烟雾更像 CS2

CS2 烟雾的特点是:初始爆发时密度高,随后慢慢扩散,整体轮廓是不规则团状,边缘有羽化。要让效果更贴近:

  • 使用时间变化修改 SDF 半径:float r = 2.0 + 0.3 * sin(iTime * 0.3);
  • 对dens做二阶平滑:dens = dens * dens;
  • 加入旋转噪声场:让烟雾整体缓慢旋转。

这些都属于微调,核心框架已经足够。

6. 最佳实践与工程建议

6.1 写出可维护的着色器代码

GLSL 里没有类,但我们可以把功能模块化:

  • 所有噪声函数统一放一起。
  • SDF 函数统一前辍sd。
  • 密度场函数统一命名为map或densityField。
  • 主循环尽量精简,不要把所有逻辑塞进mainImage。

例如,把采样过程拆成computeScatter(vec3 ro, vec3 rd),主函数只负责合成背景和输出。这样后续替换光照模型或者噪声模型都方便。

6.2 参数化调优

不要直接在代码里硬编码所有数值。推荐在 ShaderToy 中使用iMouse或uniform float暴露关键参数,例如:

float densityScale = 0.3; // 可调 float stepSize = 0.05; // 可调 float noiseScale = 1.5; // 可调

这样你可以实时调整参数观察效果,减少一次一次改代码编译的时间。

6.3 安全与性能边界

在本地工程中,如果要在移动端跑,一定要:

  • 限制循环次数在 64 以内。
  • 关闭阴影或只用 8 次阴影步进。
  • 降低噪声复杂度。
  • 使用半分辨率 + 后处理上采样。

6.4 日志与调试建议

GLSL 调试不方便,推荐手段:

  • 用颜色映射调试输入:比如显示噪声值、密度值、步进次数。
  • 把步进次数写入fragColor.r,可以看出性能瓶颈区域。
  • 截帧工具推荐 RenderDoc(用于本地 OpenGL/Vulkan),ShaderToy 可以直接用printf阉割替代方案:在屏幕上可视化数值范围。

6.5 从单帧渲染走向实时游戏集成

如果要集成到游戏引擎,不要直接用 ShaderToy 代码贴进材质,因为引擎的光照、摄像机、后处理都与 ShaderToy 不同。建议做法:

  • 将密度场和光线步进封装成一个 Compute Shader,输出到 3D 纹理。
  • 或者使用全屏 Pass 做后处理,把“烟雾影响”作为遮罩叠加到场景颜色上。
  • 考虑与深度缓冲结合,让烟雾正确遮挡场景物体。

7. 下一步学习路线

如果你已经能跑通上面的 Demo,下一步可以尝试这些方向:

  1. 学习真正的 3D Perlin/Simplex 噪声,替代土办法noise3D,效果会更细腻。
  2. 学习 FBM(Fractal Brownian Motion),多层噪声叠加,实现云层、爆炸、蒸汽。
  3. 研究 Beer-Lambert 定律与 Henyey-Greenstein 相位函数,建立完整的体积光照模型。
  4. 学习空间 SDF 进阶操作:域扭曲、布尔运算、平滑并集,能创造更多复杂形状。
  5. 研究体素化、VDB 数据结构,理解工业级体积渲染方案。

在实际项目中,建议优先关注性能预算。体积渲染是一个典型的“效果换性能”技术,你必须清楚你的目标平台能承受多少次采样,而不是盲目追求高质量。

如果你打算做 CS2 风格的游戏烟雾弹,强烈建议把“密度场”抽象成独立模块,后续如果需要换用流体模拟数据,直接替换map函数即可,其他渲染管线都可以复用。这也是本项目中最值得保留的工程经验:把视觉表现和算法控制分离,才能在后期不断调整而不至于重写整个 Shader。

到这里,一个基于光线步进和 SDF 的 CS2 风格烟雾弹实战案例就完整跑通了。你可以从最简单的球体约束密度场开始,慢慢加噪波、加阴影、加 FBM,最终形成自己满意的体积烟雾效果。把这个案例吃透之后,再去翻体积云、体积雾等更复杂的资料,会发现很多概念在原理上都是相通的。

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

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

立即咨询