尧图精选

光线步进与SDF实战:从零构建实时体积烟雾渲染

🕒 发布时间:2026/9/28 1:32:58 📁 来源:尧图网络
大家在做实时渲染或者想深入研究体积雾、爆炸特效、烟雾弹这类效果时大概率都遇到过同一个问题网上资料要么只讲 Raymarching 数学原理要么只丢一个 ShaderToy 链接真正能落地的完整案例非常少。之前我一直在研究 CS2 里烟雾弹那种“黏稠、厚重、又会缓慢翻滚扩散”的视觉效果后来看到 Acerola 有一期视频专门逆向复现类似效果结合光线步进和 SDF 体积渲染思路非常清晰。这篇文章就把我拆解这个案例的全部过程整理出来从 SDF 基础到 Raymarching 主循环再到一个可运行的体积烟雾 Demo附带常见坑点和工程优化建议希望能帮到正在啃图形学渲染分支这块内容的同学。本文适合两类读者刚接触光线步进、SDF、体积渲染的初学者希望先建立完整的理论框架。已经写过简单 Raymarching 但没做过“体积材质”的进阶开发者想理解烟雾、云层、爆炸这类参与介质效果是怎么渲染出来的。读完你可以掌握SDF 的基本定义与常用距离函数、光线步进的核心循环、如何用叠加噪声场和密度函数模拟烟雾形体、怎样从单帧渲染扩展到可交互的实时效果以及常见的锯齿、性能、调参问题怎么解决。1. 背景为什么要用光线步进和 SDF 做烟雾弹1.1 从 CS2 烟雾弹的视觉特性说起我们先回想一下 CS2 里烟雾弹的视觉观感烟雾不是一大块均匀的灰白色半透明球而是在空间中有密度变化边缘有涡旋和翻滚感中间偏厚、边缘偏薄光穿过烟雾时会有衰减和散射感。如果只用传统三角形网格 透明贴图做很难做出“立体体积”的感觉因为那只是“面片上的透明度”不是“空间中的密度”。真正让烟雾看起来立体是因为我们把它当作一个“参与介质”Participating Media空间中每一点都有密度值光线穿过时会被吸收和散射。密度高的地方更厚实密度低的地方更通透。要渲染这种效果常见方案有两种体素化Voxelization后做体积光照例如体素锥追踪。光线步进 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.0ShaderToy 默认是 GLSL ES 3.0 兼容风格。如果你要用本地项目推荐VS Code glsl-canvas插件可以直接写 GLSL 并在本地预览。或者用 Three.js 加载自定义 ShaderMaterial。版本说明由于 ShaderToy 和浏览器环境会自动适配 GLSL 版本本文中的代码重点演示算法思路具体版本差异不会影响核心逻辑。如果你在本地编译遇到version指令问题在文件头部加入#version 300 es即可。2.2 示例工程结构如果是 ShaderToy只需要两个 BufferBuffer A负责更新一组随时间演化的噪声场可选用于模拟翻滚。Image负责最终的光线步进渲染采样 Buffer A 或直接在片元着色器里计算噪声。为了降低复杂度本文直接在Image中实时计算三维噪声不需要多 Buffer。这样更容易看懂也方便后续优化。如果是本地 WebGL 工程建议结构如下raymarching-smoke/ ├── index.html ├── main.js └── shaders/ ├── vertex.glsl └── fragment.glsl2.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圆心在内部点 (radius1,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”来判断是否命中因为空间每一处都可能存在烟雾密度。所以体积渲染的思路是沿光线方向分成很多采样点。在每个采样点计算“密度值”。根据物理模型用 Beer-Lambert 定律计算光穿过该点后的衰减。累加各个采样点的散射颜色得到最终像素颜色。简单说表面渲染关心“是否碰到物体”体积渲染关心“这段路径上累计了多少吸收与散射”。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 性能太差怎么办体积渲染非常耗性能。优化方向按优先级排列尽早终止transmittance 0.01时 break。缩短tMax。增大stepSize。降低噪声计算次数。减少阴影函数中的循环次数。使用半分辨率渲染再上采样本地工程中常见。在 ShaderToy 上如果帧率低于 30建议先调stepSize到 0.08迭代次数降到 80。5.4 怎么让烟雾更像 CS2CS2 烟雾的特点是初始爆发时密度高随后慢慢扩散整体轮廓是不规则团状边缘有羽化。要让效果更贴近使用时间变化修改 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/VulkanShaderToy 可以直接用printf阉割替代方案在屏幕上可视化数值范围。6.5 从单帧渲染走向实时游戏集成如果要集成到游戏引擎不要直接用 ShaderToy 代码贴进材质因为引擎的光照、摄像机、后处理都与 ShaderToy 不同。建议做法将密度场和光线步进封装成一个 Compute Shader输出到 3D 纹理。或者使用全屏 Pass 做后处理把“烟雾影响”作为遮罩叠加到场景颜色上。考虑与深度缓冲结合让烟雾正确遮挡场景物体。7. 下一步学习路线如果你已经能跑通上面的 Demo下一步可以尝试这些方向学习真正的 3D Perlin/Simplex 噪声替代土办法noise3D效果会更细腻。学习 FBMFractal Brownian Motion多层噪声叠加实现云层、爆炸、蒸汽。研究 Beer-Lambert 定律与 Henyey-Greenstein 相位函数建立完整的体积光照模型。学习空间 SDF 进阶操作域扭曲、布尔运算、平滑并集能创造更多复杂形状。研究体素化、VDB 数据结构理解工业级体积渲染方案。在实际项目中建议优先关注性能预算。体积渲染是一个典型的“效果换性能”技术你必须清楚你的目标平台能承受多少次采样而不是盲目追求高质量。如果你打算做 CS2 风格的游戏烟雾弹强烈建议把“密度场”抽象成独立模块后续如果需要换用流体模拟数据直接替换map函数即可其他渲染管线都可以复用。这也是本项目中最值得保留的工程经验把视觉表现和算法控制分离才能在后期不断调整而不至于重写整个 Shader。到这里一个基于光线步进和 SDF 的 CS2 风格烟雾弹实战案例就完整跑通了。你可以从最简单的球体约束密度场开始慢慢加噪波、加阴影、加 FBM最终形成自己满意的体积烟雾效果。把这个案例吃透之后再去翻体积云、体积雾等更复杂的资料会发现很多概念在原理上都是相通的。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →