聊到现代游戏里的烟雾弹很多人第一反应是 CS2 里那颗能封住整条通道、被手雷炸散、被子弹撕开的灰白色烟雾。它已经不再是早期引擎里那种半透明贴片而是一个真正的体积场烟雾有厚度、有密度、会阻挡光源也会随风扰动。想要在自研引擎或 Unity、Unreal 里复现类似的效果绕不开三个核心概念光线步进、SDF、体积渲染。本文以 Acerola 的《逆向复现 CS2 烟雾弹》案例为起点拆解这套渲染链路并给出一份可运行的原型代码、性能开销分析和排错清单。适合想系统学习图形学渲染分支的初学者也适合正在做体积云雾、烟雾弹、体素化水体等效果的开发者。读完你不仅能理解光线步进和 SDF 的关系还能动手写一个体积烟雾 shader并知道如何从画面表现反推实现思路。需要先说清楚这里说的“逆向”不是安卓逆向、JS 逆向那种安全方向的代码逆工程而是图形学里的“效果逆向”。也就是说不分析 CS2 的引擎源码也不提取任何商业资源而是通过观察烟雾的形态、遮挡、光照和动态行为用公开的图形学工具重新造出相似效果。1. 背景与核心概念1.1 光线步进是什么光线步进Ray Marching是实时渲染中非常常用的一种求交和采样方式。它不像传统光栅化那样把三角形投影到屏幕上也不像传统光线追踪那样用解析式去计算光线和三角网格的交点而是从摄像机出发沿着每个像素的视线方向一小段一小段地往前走每走一步就采样一次当前位置的“场景信息”。这个“场景信息”可以是距离场数值也可以是密度、颜色、温度等标量场。对于烟雾这类没有清晰表面的介质光线步进尤其合适烟雾不需要精确的“击中点”只需要知道光线在传播过程中积累了多少不透明度吸收了多少钱最后把透过的背景和散射光混合起来。用一个最简单的伪代码表示for (float t near; t far; t stepSize) { vec3 p origin direction * t; float density sampleDensity(p); if (density 0.0) { // 累积颜色和不透明度 } }每段步长stepSize越小采样越精细渲染质量越高但性能开销也越大。光线步进的核心就是一个“用循环换效果”的思维方式因此在实时渲染里步长、步数上限、提前终止条件就是性能三兄弟。1.2 有向距离场 SDF 的原理SDF 全称 Signed Distance Field有向距离场。它存储的是空间中任意一点到最近物体表面的带符号距离。正数表示点在物体外部负数表示点在物体内部0 表示刚好在表面上。最常见的球体 SDFfloat sdSphere(vec3 p, float r) { return length(p) - r; }用起来非常直观传入一个点如果返回负数说明点在球内返回 10说明点离球面还有 10 个单位距离。正因为有“最近距离”这种信息光线步进时可以结合 sphere tracing 做较大步长的加速前进。但烟雾没有坚硬表面SDF 在体积渲染里的作用通常是作为“边界约束”和“形状遮罩”先用一个大球或大盒子的 SDF 限定烟雾存在范围再在这个范围内填充噪声密度。例如一颗烟雾弹刚炸开时可以看成一个不断膨胀的球体后续的翻滚和膨胀是靠 Perlin 噪声、FBM 等扰动函数把它变成不规则形状。了解到这里就够了SDF 决定“烟雾大概在哪”噪声决定“烟雾具体长什么样”。1.3 体积渲染与烟雾的物理基础体积渲染Volume Rendering描述的是光在参与介质中传播的过程比如烟、雾、云、火。对于烟雾来说光线穿过时会经历两个主要过程吸收和散射。吸收很好理解烟里的碳颗粒把光吃掉了所以烟通常看起来发黑。散射则让一部分光改变方向进入摄像机让烟雾本身有了可见的“体积感”。渲染时我们常用一个简化模型设介质每个点有吸收系数、散射系数和消光系数光线穿过一小段距离 ( ds ) 后剩余透射率遵循 Beer-Lambert 定律[ T e^{-\int_0^s \sigma_t(u) du} ]其中 ( \sigma_t ) 是消光系数。当这个积分值非常大时透射率趋近于 0说明烟雾足够浓背景完全被遮挡。在实际 shader 中不会真的做连续积分而是用离散累加transmittance * exp(-density * dt); color transmittance * scattering;这段代码几乎就是所有体积烟雾渲染的最小骨架。理解这个离散累加公式比背一堆体积渲染方程更重要。1.4 为什么这套方案适合 CS2 烟雾弹CS2 里的烟雾弹是真实时间效果不能用离线渲染级别的百万级路径追踪。它的好处是烟雾通常被限制在一个局部空间内视觉范围有限粒子密度不需要覆盖整个世界。因此用光线步进在屏幕空间对有限体积进行采样是性价比最高的方案。和真实世界不同游戏里的烟雾不需要物理上的每个碳颗粒都被追踪。只需要做出“远看是一团灰雾近看有内部翻滚结构边缘柔和不锐利遮挡关系正确被手雷炸到会散开”这几个关键特征。Acerola 的逆向视频最有价值的地方就是把一个商业游戏效果拆解成了可复现的渲染步骤先建立体积场再加噪声扰动最后做光学累加和光照反馈。2. 逆向复现 CS2 烟雾弹的工作流2.1 观察画面而不是看源码做图形学效果逆向的第一步不是去打开 CS2 的 shader而是把烟雾弹的关键视觉现象全部列出来。我建议至少抓住这几点烟雾有核心和羽化边缘中心密度高边缘逐渐透明。烟雾不是静止的它有低速翻滚像一团云而不是一张贴图。烟雾受到环境光影响能看到被光源照亮的高光区域。烟雾能挡住后面的玩家和墙体形成柔和遮挡。手雷爆炸或子弹穿过时烟雾局部会被推开出现“空洞”。这些现象可以直接映射到渲染参数上中心密度、边缘平滑度、噪声缩放、动画速度、光照方向和密度遮罩。先把现象列完整再动手写代码这就是效果逆推的基本模式。2.2 拆解烟雾弹的视觉要素烟雾本质上就是一个三维标量密度场。每个采样点都有一个 density 值光线经过时密度越大的地方透射率衰减越快。在实现中密度场可以拆成两个部分叠加基础形状用一个 SDF 球体或其他凸包体表示烟雾的边界。细节扰动用三维噪声在这个边界内做乘积或叠加产生丝状、块状、翻滚结构。例如先把 SDF 球体转换成边缘渐变的遮罩float mask 1.0 - smoothstep(0.0, 1.5, distToSphere);这样球体中心 mask 接近 1球体边缘 mask 平滑过渡到 0。烟的核心区就在球体中间边缘自然软化。然后用 FBM分形布朗运动噪声去乘这个 mask就能得到“既有一个整体范围又有内部不规则细节”的体积场。Acerola 的拆解思路本质上也是这样CS2 烟雾弹在视觉上首先是一个“会被推开的大体积”然后内部用噪声模拟湍流最后通过体积累积表现出来。2.3 参数设计的顺序新手最容易犯的错是直接把几十个参数全塞进一个 shader然后迷失在调参地狱里。更合理的做法是分优先级第一优先级决定烟雾“范围”的参数比如 SDF 球体半径、中心位置、缩放。第二优先级决定烟雾“密度”的参数比如 extinction 系数、密度阈值。第三优先级决定烟雾“形状”的参数比如噪声缩放、FBM 层数、动画速度。第四优先级决定烟雾“光照”的参数比如散射颜色、边缘光强度、环境光颜色。先把 1 和 2 调出“一团会挡视线的灰雾”再加 3 调出翻滚细节最后加 4 让它看起来有立体感。这样每一步都有明确的判断标准不会因为参数太多而失控。3. 环境准备与版本说明3.1 方案选型本文核心案例使用 ShaderToy 风格的 GLSL 代码因为它能最快展示光线步进和体积渲染的算法逻辑不需要搭建复杂工程。实际接入游戏时可以把同一套思路改写成 Unity 的 HLSL或 Unreal 的 Material Graph。版本方面不需要严格绑定只要你的环境支持 GLSL ES 3.0 或 HLSL 2021这套代码都能运行。如果使用 Unity建议用 2022 LTS 或更新版本因为新版 URP 对自定义 shader 的支持更稳定如果使用 Unreal建议用 5.1 以上版本体积雾模块也更成熟。3.2 运行环境操作系统Windows / macOS / Linux 均可。GPU支持 OpenGL 4.3 或 Vulkan 1.1 以上即可。原型工具ShaderToy 网页端或本地 VS Code GLSL 插件。游戏引擎可选Unity 2022 LTS URP。调试工具RenderDoc 或 Unity Frame Debugger用于分析每一步的渲染消耗。如果你手上没有对应版本的引擎也不用担心。先用浏览器跑通 GLSL再看 HLSL 的迁移片段最后把它塞进自己的渲染管线里。3.3 示例项目结构以 Unity 为例一个最小体积烟雾项目可以这样组织Assets/ Shaders/ SmokeRayMarching.shader CommonNoise.cginc Scripts/ SmokeCameraController.cs Textures/ Noise3D.asset (可选)如果是纯原型阶段我建议先不建 Unity 工程直接用 ShaderToy 的单文件 mainImage 跑核心逻辑验证效果后再迁移。下面进入实战部分。4. 实战光线步进 SDF 体积烟雾原型4.1 建立 SDF 与噪声场首先是噪声采样。这里用一个三维 hash 函数实现简易噪声再用多层叠加得到 FBM。FBM 是体积烟雾最常用的细节来源它能让平面噪声变成类似云层的团块结构。float hash31(vec3 p) { p fract(p * 0.3183099 vec3(0.1, 0.2, 0.3)); p * 17.0; return fract(p.x * p.y * p.z * (p.x p.y p.z)); } float noise(vec3 p) { vec3 i floor(p); vec3 f fract(p); f f * f * (3.0 - 2.0 * f); return mix( mix( mix(hash31(i), hash31(i vec3(1, 0, 0)), f.x), mix(hash31(i vec3(0, 1, 0)), hash31(i vec3(1, 1, 0)), f.x), f.y), mix( mix(hash31(i vec3(0, 0, 1)), hash31(i vec3(1, 0, 1)), f.x), mix(hash31(i vec3(0, 1, 1)), hash31(i vec3(1, 1, 1)), f.x), f.y), f.z); } float fbm(vec3 p) { float v 0.0; float a 0.5; for (int i 0; i 5; i) { v a * noise(p); p * 2.0; a * 0.5; } return v; }然后是 SDF 球体和密度场float sdSphere(vec3 p, float r) { return length(p) - r; } float mapDensity(vec3 pos) { vec3 center vec3(0.0, 0.0, 0.0); float dist sdSphere(pos - center, 1.8); float mask 1.0 - smoothstep(0.0, 1.5, dist); float n fbm(pos * 1.2 vec3(iTime * 0.4, 0.0, iTime * 0.15)); return mask * max(n - 0.35, 0.0); }这里mask负责限制烟雾范围n负责提供翻滚细节max(n - 0.35, 0.0)相当于设置一个密度阈值低于阈值的区域直接视为空气。这个阈值非常重要它让烟雾内部出现空洞而不是一整块实心灰球。4.2 主光线步进循环在 ShaderToy 中mainImage是入口函数。我们根据屏幕坐标构建摄像机射线然后沿射线逐步采样。#define MAX_STEPS 128 #define STEP_SIZE 0.05 void mainImage(out vec4 fragColor, in vec2 fragCoord) { vec2 uv (2.0 * fragCoord - iResolution.xy) / iResolution.y; vec3 ro vec3(0.0, 0.0, 5.0); vec3 ta vec3(0.0, 0.0, 0.0); vec3 ww normalize(ta - ro); vec3 uu normalize(cross(ww, vec3(0.0, 1.0, 0.0))); vec3 vv cross(uu, ww); vec3 rd normalize(uv.x * uu uv.y * vv 2.0 * ww); vec3 lightDir normalize(vec3(0.6, 0.8, -0.2)); vec3 color vec3(0.0); float transmittance 1.0; float t 0.0; for (int i 0; i MAX_STEPS; i) { vec3 pos ro rd * t; float density mapDensity(pos); if (density 0.0) { float dt STEP_SIZE; float extinction 1.2 * density; float sampleTrans exp(-extinction * dt); transmittance * sampleTrans; float lightFactor 0.5 0.5 * dot(normalize(lightDir), -normalize(rd)); vec3 lightColor vec3(0.95, 0.9, 0.85); color sampleTrans * transmittance * extinction * lightColor * lightFactor; if (transmittance 0.02) break; } t STEP_SIZE; } vec3 bg mix(vec3(0.05, 0.08, 0.1), vec3(0.2, 0.25, 0.3), uv.y * 0.5 0.5); fragColor vec4(color transmittance * bg, 1.0); }这段代码的逻辑是每次采样到一个 density 大于 0 的点就计算这一小段带来的透射衰减。transmittance一直在累乘代表“光线走到当前位置还剩多少能量”。把被散射进视线的光累加到color。如果透射率已经低于 0.02说明后面全被烟挡住了直接退出循环节省性能。4.3 光照简化说明上面的光照项lightFactor是一个偏 hack 的做法严格来说不是完整的体积光照。它只是用视线方向和光源方向的夹角模拟出一种“背光边缘亮、正面灰暗”的感觉。真正的体积渲染一般需要再做一次 light marching从当前采样点向光源方向步进重新采样密度并累乘透射率得到该点的阴影系数。比如真实烟雾内部靠近光源一侧会亮远离光源一侧会暗。如果只做单层光线步进看不到这种自阴影效果。但是 light marching 会把性能开销乘以二到三倍所以原型阶段先用简单光照把效果跑通再逐步加入阴影采样。4.4 迁移到 Unity 的 HLSL 核心如果要在 Unity URP 里接入核心循环完全一样只把语法改成 HLSL。float4 RayMarchSmoke(float3 ro, float3 rd) { float t _NearPlane; float transmittance 1.0; float3 accum 0.0; for (int i 0; i _MaxSteps; i) { float3 pos ro rd * t; float density SampleSmoke(pos); if (density 0.001) { float dt _StepSize; float extinction _Extinction * density; float atten exp(-extinction * dt); transmittance * atten; accum transmittance * extinction * _ScatterColor * LightFactor(pos); if (transmittance 0.01) break; } t _StepSize; } return float4(accum, 1.0 - transmittance); }这段代码需要放到自定义 Lit Shader 或后处理 Shader 里并且自己实现SampleSmoke和LightFactor函数。Unity 中常见的做法是用一个包围盒作为烟雾触发区域在片元阶段根据包围盒前后表面距离做光线步进而不是从摄像机一直无边界步进。这样能极大减少无效步数优化性能。5. 常见问题与排查思路体积烟雾的调试难就难在问题可能出现在很多层可能密度场本身不对可能光线步进方向错了也可能光照模型太弱。下面给出高频问题排查表。问题现象常见原因解决思路画面出现条纹状断层步长太大采样率不足减小 STEP_SIZE或加入随机抖动 jitter烟雾看起来像一个个圆球FBM 细节太少噪声缩放太小提高噪声频率增加 FBM 层数到 5-7 层烟雾完全不透明像实心球extinction 系数过高密度阈值太低降低 extinction提高 max 阈值动画闪烁厉害噪声动画速度太快采样点抖动不稳定降低时间系数或对采样坐标做连续平滑性能极差帧率很低步数太多或全屏逐像素都跑满 128 步限制 MAX_STEPS加入透射率提前退出半分辨率渲染摄像机进入烟雾后看不到内部没有处理近裁剪面与包围盒求交使用包围盒求交结果作为步进起点和终点排查时建议遵循以下顺序先用静态帧看密度场把density直接输出成颜色确认烟雾形状是否符合预期。接着把光照项去掉只看透射率累乘确认遮挡是否正确。最后再加颜色和边缘光。如果一开始就叠加所有效果你很难判断问题出在采样还是光照。另一个常见误区是直接在表面 shader 里让每个物体分别走一遍体积步进导致出现半透明物体排序错误。真正生产级做法是把烟雾和后处理分离开通过深度缓冲和模板缓冲来决定哪些区域需要参与体积射线步进。6. 最佳实践与工程建议6.1 性能优化优先级体积烟雾在正式项目里不能像 ShaderToy 那样放开跑必须做性能收敛。第一步是限制光线步进范围。用烟雾包围盒做 ray-AABB 求交让步进只发生在进入包围盒之后、离开包围盒之前。这样可以删掉大量空转步数。第二步是半分辨率渲染。把体积计算放到半分辨率 RenderTexture 上再配合双边滤波或 TAA 上采样到全分辨率。烟雾高频细节本来就不多半分辨率损失不明显性能却能提升三到四倍。第三步是提前终止。当透射率低于某个阈值时后续采样对画面的贡献已经小到可以忽略直接 break。这是所有体积渲染里最简单也最有效的优化。第四步是考虑 3D 纹理预烘焙噪声。运行时每次执行 FBM 会做多次纹理采样的层叠成本较高。预先把一个 32x32x32 或 64x64x64 的噪声纹理烘焙到显存运行时只需要采样一两次效果稳定且性能更好。6.2 稳定性与可维护性体积 shader 里的参数很容易失控建议全部暴露为 Material 属性并在项目文档里写清参数含义。命名可以统一为_SmokeCenter烟雾中心位置。_SmokeRadiusSDF 球体半径。_DensityThreshold密度阈值。_Extinction消光系数。_FbmScale噪声缩放。_FbmSpeed噪声动画速度。给参数建立默认值和推荐范围比如_Extinction推荐 1.0 到 3.0_FbmScale推荐 0.8 到 2.0。这样团队协作时美术可以快速试手感程序也能很快定位性能问题。6.3 合规与资源边界做商业游戏视觉复现时不要直接提取目标游戏的模型、贴图或 shader 二进制。正确的做法是只看公开演示画面通过现象反推图形学算法再用自己的资源重建。烟雾效果的核心不在一张贴图而在密度场、噪声和光线累乘这三层结构的组合完全可以通过自建素材实现出类似观感。另外涉及到游戏修改、辅助工具、破解等内容时一定要遵守当地法律和游戏用户协议。本文讨论的是图形学算法效果复现和绕过商业软件限制的逆向完全不是一回事。7. 总结与下一步这篇文章把 CS2 烟雾弹复现案例拆成了四个技术支柱光线步进负责沿视线采样SDF 负责限定烟雾的宏观形状FBM 噪声负责填充内部细节体积渲染的离散累乘负责把密度转化成真实的不透明度和光照。建议你现在就打开 ShaderToy 或本地 GLSL 环境把上面的代码跑一遍。先只调密度阈值和 extinction 系数感受烟雾从“一片灰雾”变成“一团有结构的云”的过程再加入动画最后尝试给它加一条 light marching 阴影射线看烟雾内部是否会真正暗下来。需要继续深入的方向包括半分辨率体积渲染与时间性上采样、三维噪声纹理烘焙、GPU Compute Shader 做密度场动态更新、手雷爆炸冲击波对体积场的扰动。这些内容每一个都能独立成一篇实战教程也是目前主流游戏体积渲染里最常见的进阶路线。如果在跑代码时遇到问题欢迎对照第五节的排查表先确认密度场再确认透射率累乘最后加光照。烟雾效果调参的最关键一步不是追求参数完美而是先把“与画面对不上的那一环”单独拎出来看。