第 33 章 动手二:在 Shader 里亲手写出”雾”(理解深度如何参与)

上一章(第 32 章)我们亲手把立方体”算”到了屏幕上——那一次,你验证了第 27 章的坐标变换链,看见 w 怎么制造近大远小。这一章我们换个战场:去 shader 里,亲手把”雾”写出来。

你第 8 章就读过雾了:空气透视、线性雾、指数雾,还有 Unity 里那几个滑块(Fog Mode、Fog Color、Start/End、Density)。你当时学会了”拧哪个旋钮对付什么问题”。但你有没有一个挥之不去的疑问:

引擎到底是怎么让”远处的山溶进天色”的?它是给远处的像素贴了一张雾的贴图吗?还是有一个看不见的”雾层”盖在世界上?那个”雾因子 fogFactor”到底是在哪一步、由谁算出来的?

如果你脑子里雾(双关,故意的)里一团——那正常。第 8 章讲的是”现象和参数”,第 26 章讲的是”每个像素都有深度”,但这两件事是怎么焊在一起的,第 8 章没往下拆。今天我把这道焊缝亲手焊给你看:雾的本质,是”按这个像素离相机有多远,把它的颜色和雾色混合”——而”每个像素离相机多远”这件事,正是第 26 章讲的深度。所以雾必须写在 shader 里,逐像素、用自己的深度去算。

和上一章一样,你是个写 Go 的后端,逻辑你懂,图形底层是新手。这一章我不引入新概念(雾你早认识了),只把”雾怎么在 shader 里算出来”变成一段你能读懂、能复制、能在 Unity 里跑的 ShaderLab 代码。读完你应该能指着一段 shader 说”这一行就是在算雾”。

33.1 为什么雾必须在 shader 里算:它不是贴上去的,是算出来的

先把一个最常见的误解掐死:雾不是一张”盖在世界上的雾图片”。 你如果以为是引擎在最后给画面蒙了一层半透明灰,那只对了一半,而且是最偷懒理解的那一半。

为什么雾必须是”逐像素算”的,而不是”贴一层”?我带你从问题倒推。

假设你用”贴一层半透明灰”的笨办法:在镜头前蒙一张灰色玻璃,远处透过灰、近处也透过灰——结果近处也被灰盖糊了。可真实的雾(第 8 章讲过的空气透视)是”越远的越糊、越近的越清”,糊的程度是随距离连续变化的。一张固定灰玻璃,给不出”近处全清、远处全糊”的渐变。所以笨办法出局。

那让每个物体自己决定”我该糊多少”呢?这就对了。一个像素最终显示什么颜色,是在**片元着色器(Fragment Shader)**里算出来的——第 6 章你学过,光照、贴图、颜色都在这一步逐像素计算。既然”糊多少”取决于”这个像素离相机多远”,而”离相机多远”是每个像素自己知道的事(它就是被投影到这里的,深度就在它手里),那最自然的地方,就是在片元着色器里、算完物体本来的颜色之后,顺手按深度把雾色混合进去。

这就是核心结论:雾必须写在 shader 里,因为它是”逐像素、按该像素自身深度”做的颜色混合,离了 shader 这个逐像素的环境,它没法精确表达”越远的越糊”。

连回第 26 章你会更清楚:第 26 章说”深度缓冲里记录每个像素离相机多近”。注意,那是深度缓冲自动帮你算好、存着的值。而我们做雾,要的不是”和别的东西比谁更近”(那是遮挡),而是”我自己离相机有多远”——这个值其实在顶点经过 View 变换(第 27 章 27.3)之后就已经有了:相机空间下,顶点 z 是负数,取个负号就是”离相机的距离”。所以雾用的深度,是视空间深度(view-space depth),它来自第 27 章的 View 矩阵,由第 26 章的深度概念托底。三章在”深度”这一个词上接上了。

生活化类比帮你记:雾像”按距离收费的过路费”。收费站(片元着色器)对每个经过的像素说:“你离相机多远?远就多收你点’雾色’,近就少收。“它不可能用”全国统一贴一张收费单”(贴图法),因为每个人路程不同、该交的钱不同——必须”每人按自己的里程单独算”。shader 就是这个能”逐人按里程算账”的收费站。

还有一点值得你停下来想:既然雾是”按深度混合颜色”,那它和你在第 6 章学的”光照把颜色和明暗混合”、第 30 章学的”后处理把颜色和滤镜混合”,底层是不是同一个动作?没错——它们全都是”某个 factor 驱动的一次 lerp”。你今天在 shader 里写的 lerp(col, fogColor, fogFactor),和第 6 章光照公式、第 30 章色调映射,在数学本质上是亲戚。抓住”混合”这个动作,你会发现图形学里一大半效果都是”算一个比例、然后 lerp”——雾只是其中最直观的一例。这也是为什么我总说:懂一个,就能举一反三懂一片。

33.2 写雾之前,先问自己要什么输入

动手写代码前,像你写 Go 函数前先列参数一样,我们先把”算雾”这个函数需要哪些输入理清楚。这能避免你对着 shader 不知道从哪下手。

输入一:视空间深度(这个像素离相机多远)。 这是雾的灵魂输入。它在哪来?回第 27 章:顶点先过 Model(局部→世界),再过 View(世界→相机坐标)。到相机坐标系里,顶点位置是 (x, y, z),而相机看向 -z,所以在相机前方的点 z 是负的。我们取 -z 就得到”离相机的正向距离”。这个值在顶点着色器里就能算出来(一个顶点一个值),然后传给片元着色器(GPU 会在三角形内部自动插值,得到每个像素平滑的深度)。为什么在顶点算、片元用?因为顶点少(一个立方体才 8 个),片元多(上百万像素),在顶点算一次、插值过去,比每个像素都重算一遍矩阵便宜——这是图形学”能顶点算就别片元算”的通用省钱原则,和你写后端”能预计算就别每次请求都算”一个道理。

输入二:雾的起止距离或密度。 线性雾要 start 和 end(从第几米开始起雾、到第几米彻底糊死);指数雾只要一个 density(空气多浑)。这些就是你第 8 章在 Lighting 面板拖的滑块,在 shader 里就是几个 float 变量(Uniform,所有像素共享同一份值)。

输入三:雾色(Fog Color)。 远处像素”溶进”什么颜色。第 8 章强调过:实战里几乎总设成和天空盒一样的颜色,这样雾的边界隐形。在 shader 里就是一个 fixed4 颜色变量。

把这三个输入摆齐,shader 的”函数签名”就清楚了:

片元颜色 = 雾(物体本来颜色, 视空间深度, 起止/密度, 雾色)

接下来两节,我们分别把”雾”这个函数用两种公式实现,并连回第 8 章。

33.3 两种 fog 公式:连回第 8 章的”距离→浓度”

第 8 章 8.3 给了你两种”距离→混合比例”的公式。今天我们把它们从”面板上的选择”翻译成”shader 里的几行算术”。核心思想第 8 章就点过:最终颜色 = 物体色 和 雾色 的混合(mix),混合比例由距离决定。 这个混合比例叫 fogFactor,取值 0~1:1 = 完全清晰(用物体本色),0 = 完全被雾吞没(用雾色)。

线性雾(Linear Fog)

第 8 章公式:fogFactor = (end - distance) / (end - start)。

我带你逐符号过一遍,因为写 shader 时你得知道每个数代表啥:

  • distance:这个像素离相机的视空间深度(上节说的输入一)。
  • start:起雾距离。比它近的,fogFactor > 1(saturate 后会夹成 1,全清晰)。
  • end:彻底糊死的距离。比它远的,fogFactor < 0(夹成 0,全雾)。
  • (end - distance) 是”还差多远到全糊”,(end - start) 是”从清到糊的总跨度”。两者一除,得到一个 0~1 的线性比例。

直觉:从 start 米开始,匀速变糊,到 end 米糊死。线性,所以叫线性雾。

指数雾(Exponential Fog,这里用 Exp2)

第 8 章公式:fogFactor = 1 - exp(-density^2 * distance^2)(Exp2 模式,比 Exp 更陡)。

逐符号:

  • density:雾的浓度,对应第 8 章 Fog Density。越大空气越浑。
  • distance:还是视空间深度。
  • exp(-density^2 * distance^2):这是个”越远衰减越快”的指数项。当 distance 很小(近),这一项接近 1,所以 fogFactor = 1 - 1 = 0?等等,注意——这里我写的是”被雾吞没的比例”还是”清晰比例”要统一。我们约定:fogFactor 表示”清晰程度”(1 清晰、0 全雾),那么远处 distance 大,density^2 * distance^2 大,exp(...) 趋近于 0,于是 fogFactor = 1 - 0 = 1?这和”远处应该全雾(fogFactor 应趋近 0)“矛盾了。

我特意在这里停下来,因为这是新手写雾最常翻的车:fogFactor 的语义必须先钉死。 我选择让 fogFactor = 1 表示”完全清晰”,= 0 表示”完全被雾吞没”(这样 lerp(fogColor, color, fogFactor) 里 fogFactor=1 取 color、=0 取 fogColor,语义最顺)。那么指数雾里,“被雾吞没的程度”应该是 1 - exp(...) 不对——重新推:远处要”全雾”,即 fogFactor→0;近处”全清”,fogFactor→1。而 exp(-density^2 * dist^2) 在 dist=0 时 =1,dist→∞ 时 =0。所以让 fogFactor = exp(-density^2 * dist^2) 就对了:近处 =1(清),远处 →0(雾)。但第 8 章给的是 fogFactor = 1 - exp(-density^2 * dist^2),那是把 fogFactor 定义成”被雾覆盖的程度”(1=全雾)。两种定义都合法,只是后面 lerp 时参数顺序要反过来。

这一节我埋个重要的工程纪律:写 shader 前,先用一行注释钉死”fogFactor=1 到底代表清晰还是被雾吞没”,否则你调出来的雾要么全反(近处白茫茫、远处清晰),要么怎么调都不对。我下面的代码采用”fogFactor=1 清晰、0 全雾”,所以指数雾写成 fogFactor = exp(-density^2 * dist^2)(即少用那个 1 -)。但为了让它和第 8 章公式字面一致、又语义正确,我在代码里会显式写 fogFactor = 1.0 - exp(-density^2 * dist^2) 并把 fogFactor 定义为”被雾覆盖度”,相应地 lerp 时写成 lerp(color, fogColor, fogFactor)(覆盖度越高越偏雾色)。两种写法我在 33.4 都会点明,你照一种用就行,关键是自洽。

反问你:如果 fogFactor 的语义没钉死,你写完一跑发现”近处反而最糊”,第一反应会去调 density 还是去查 lerp 的参数顺序?——答案是先查语义,因为多半是”清晰/覆盖”定义反了,调密度只是越调越乱。

33.4 完整 ShaderLab / HLSL 片段:逐行解释

好,输入齐了、公式有了、语义钉死了。现在上完整、可直接在 Unity 里用的 ShaderLab(内置渲染管线风格,用 CGPROGRAM,借助 UnityCG.cginc 的便捷函数)。你新建一个 Shader 文件、把下面整段粘进去,再建个材质挂上,扔几个方块到场景里就能看到雾。

Shader "Custom/ManualFog"
{
    Properties
    {
        _MainTex   ("Texture", 2D)    = "white" {}
        _FogColor  ("Fog Color", Color) = (0.5, 0.5, 0.5, 1)
        _FogStart  ("Fog Start", Float) = 5.0
        _FogEnd    ("Fog End",   Float) = 50.0
        _FogDensity("Fog Density", Float) = 0.02
        [Toggle] _UseExp2 ("Use Exp2 Fog", Float) = 0
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "UnityCG.cginc"
 
            // 顶点输入:位置和 uv(uv 用来采样贴图)
            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv     : TEXCOORD0;
            };
 
            // 顶点 -> 片元 的传递结构
            struct v2f
            {
                float2 uv        : TEXCOORD0;
                float  fogDepth  : TEXCOORD1;   // 我们额外传一份"视空间深度"
                float4 vertex    : SV_POSITION;  // 裁剪空间位置(GPU 用来光栅化)
            };
 
            sampler2D _MainTex;
            float4 _MainTex_ST;
            fixed4 _FogColor;
            float  _FogStart;
            float  _FogEnd;
            float  _FogDensity;
            float  _UseExp2;
 
            v2f vert (appdata v)
            {
                v2f o;
                // 标准动作:局部坐标 -> 裁剪空间(内部已串起 MVP,见第 27 章)
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv     = TRANSFORM_TEX(v.uv, _MainTex);
 
                // 关键一步:把顶点变到相机空间,取 -z 得到"离相机的正向距离"
                float3 viewPos = UnityObjectToViewPos(v.vertex);
                o.fogDepth = -viewPos.z;   // 相机看向 -z,故 -z 为正距离
 
                return o;
            }
 
            fixed4 frag (v2f i) : SV_Target
            {
                // 先算物体本来的颜色(采样贴图)
                fixed4 col = tex2D(_MainTex, i.uv);
 
                // 按"被雾覆盖度"算 fogFactor:1 = 全被雾盖,0 = 完全清晰
                float fogFactor;
                if (_UseExp2 > 0.5)
                {
                    // 指数雾:覆盖度 = 1 - exp(-(density*dist)^2)
                    fogFactor = 1.0 - exp(-_FogDensity * _FogDensity * i.fogDepth * i.fogDepth);
                }
                else
                {
                    // 线性雾:覆盖度 = (end - dist) / (end - start),近处为负(夹成0)
                    fogFactor = (_FogEnd - i.fogDepth) / (_FogEnd - _FogStart);
                }
                fogFactor = saturate(fogFactor);   // 夹到 [0,1],防止越界
 
                // 用 fogFactor 把"物体色"和"雾色"混合:覆盖度越高越偏雾色
                col.rgb = lerp(col.rgb, _FogColor.rgb, fogFactor);
                return col;
            }
            ENDCG
        }
    }
}

我逐行解释你最容易懵的几处,这是”读懂”和”复制”的分界:

  • o.fogDepth = -viewPos.z:这是整段 shader 的灵魂。UnityObjectToViewPos 把顶点从局部坐标一路乘到相机空间(它就是第 27 章 View 矩阵在 Unity 里的快捷函数)。相机看向 -z,所以相机前方的点 z 是负数,-z 就是”离相机多远的正数”。我们把这个深度塞进 v2f 的 TEXCOORD1 通道,从顶点着色器**传递(TRANSFER)**给片元着色器。为什么叫”传递”而非”在片元里重算”?因为顶点少、片元多,在顶点算一次、靠 GPU 插值得到每像素深度,省得每个像素都做矩阵乘法——这正呼应 33.2 说的”能顶点算就别片元算”。

  • if (_UseExp2 > 0.5) 分支:这就是第 8 章 Fog Mode 切换的 shader 版——选 Exp2 走指数公式,否则走线性。你面板上一拖那个 Toggle,就切到不同公式。注意 shader 里没有”自动灰掉某个参数”的能力(那是 C# 编辑器的事),所以两个公式所需的参数(线性要 start/end、指数要 density)我都声明了,unused 的在那次不生效而已。

  • saturate(fogFactor):saturate 是 HLSL 内置函数,把值夹到 [0,1]。为什么必须夹?因为线性雾公式在”比 start 更近”时算出来是负数(比如 dist=2、start=5,(50-2)/(50-5)=1.06?不,那是 >1;让我举个真负的:dist 比 end 还远时 (end-dist) 为负,fogFactor 变负)。总之公式在边界外会越界,不夹住的话 lerp 会算出奇怪的过饱和颜色。所以 saturate 是”保险绳”,保证混合比例合法。这对应第 8 章说的”start 之前全清、end 之后全雾”那段被截断的逻辑。

  • col.rgb = lerp(col.rgb, _FogColor.rgb, fogFactor):lerp(a, b, t) 是线性插值,t=0 取 a、t=1 取 b。这里 a=物体色、b=雾色、t=fogFactor(被雾覆盖度)。所以 fogFactor=0(近、清晰)→ 取物体色;fogFactor=1(远、全雾)→ 取雾色。这就是第 8 章核心思想”最终颜色 = 物体色和雾色的混合”在代码里的精确落点。语义钉死在这里,前面所有推导都为了这一行能写对。

这里我还要多讲两个你写雾时一定会碰到的”为什么”,它们能帮你少踩两个坑。

第一个为什么:lerp 这行必须放在片元着色器(frag)里、且必须在”算完物体本来颜色 col 之后”,不能在顶点着色器里提前混好。 道理在于:顶点的颜色是”贫乏”的。一个三角形只有 3 个顶点,如果你在顶点就算好”物体色混雾色”再传给片元,那么三角形内部成千上万个像素拿到的,只是这 3 个顶点已混合颜色的插值——而物体本来的颜色(贴图花纹、光照明暗)是逐像素才精确的。结果是:远处的精细贴图会在三角形内部”被抹平”,近处还行、远处糊成一团块。所以正确做法是:顶点只负责传递”深度”(一个标量,插值很安全),片元负责”先算出精确物体色、再按深度混雾”。这就是图形学里”per-vertex fog(顶点雾,廉价但丢细节)“和”per-pixel fog(像素雾,本章做法,精确)“的分水岭。你今天写的是像素雾,质量更好。这其实也呼应第 26 章:深度缓冲本身就是逐像素的,雾跟着逐像素走顺理成章。

第二个为什么:我们手写的这段,其实正是 Unity 全局 Fog 开关背后干的事。 你第 8 章在 Lighting 面板勾选 Fog、拖 Mode/Color/Start/End/Density,引擎并不是”加了一层雾”,而是自动往场景里每个不透明物体的 shader 里,注入了和我们 33.4 几乎一样的代码——在顶点算视空间深度、在片元用对应公式算 fogFactor、再 lerp 混雾色。Unity 把它封装成了 UNITY_APPLY_FOG 这类内置宏,藏在了 UnityCG.cginc 里。我们今天把宏拆开、自己写一遍,等于把引擎替你藏起来的那层”黑箱”亲手揭开了。下次你在面板拖 Fog 滑块,脑子里就能浮现出这段 shader 的几行算术——这就是”懂”到”会”的跨越。也正因为它是”往每个物体 shader 注入”,所以才有了 33.6 说的局限:逐物体注入的雾,看不到全屏其它物体的深度,做不了基于整体深度图的复杂大气;那部分才需要交给后处理(第 30 章)。

一句话收:这段 shader 干的事,就是把第 8 章的”雾”和第 26 章的”深度”在代码里焊死——顶点用 View 变换算出深度,片元用深度算出 fogFactor,再用 lerp 把雾色混进去。你调 Properties 里的 _FogStart/_FogEnd/_FogDensity/_FogColor,就是在调第 8 章那几个旋钮。

33.5 调参看效果:旋钮和第 8 章一一对应

代码能跑了,接下来是”动手拧”的环节——你改 Properties,画面会怎么变,我给你一个对照表,全都能和第 8 章对上号:

  • _FogColor 改成鲜红、density 拉大:远处一片红,像置身异星。这验证第 8 章”雾色 = 远处溶进的颜色”,不限于自然天色。
  • _UseExp2 = 0(线性),把 _FogStart 设 2、_FogEnd 设 20:近处 2 米内锐利,20 米外全糊。对应第 8 章 Linear 模式 + 小范围起止,做”恐怖游戏几步外啥也看不见”的氛围。
  • _UseExp2 = 1(指数),_FogDensity 从 0.02 拉到 0.1:你会发现雾”前移”得极快、且越深越陡——因为 density^2 * dist^2 让远处的覆盖度指数暴涨。这对应第 8 章说的”指数雾用单个密度参数控制衰减”,且 Exp2 比 Exp 更陡。
  • _FogColor 设成天空盒主色、_FogEnd 设在可视距离边缘:远处物体无声溶进天色,世界尽头被温柔藏起。这对应第 8 章”雾色=天空色,边界隐形”以及 8.2”边界隐藏”那个工程目的。

调参直觉我替你再强调一次(和第 8 章一致):先想”我要解决视觉里的什么问题”,再去拧对应的那个旋钮,别瞎拖。想藏 500 米外的边界 → 线性、end 设在 500、雾色=天空;想做海底浑 → 指数、中等 density、雾色偏蓝绿。

还有个排查经验:如果你发现”近处也雾蒙蒙”,多半是 _FogStart 设太小(起雾距离太近)或 _FogDensity 太大。把 start 拉大、density 调小,近处立刻透亮——和第 8 章 8.8”雾太浓导致远处全白”是同一个扳手的反向操作。

33.6 和”后处理雾 / 距离雾”的区别:连回第 30 章与第 8 章

你第 30 章学过”后处理(Post-processing)“——渲染完一帧,对整张图统一加滤镜(Bloom、色调映射、景深)。那里也提到雾可以以”后处理”形式存在。那问题来了:我们 33.4 写在物体 shader 里的雾,和第 30 章后处理里的雾,是一回事吗?

不是。它们都”按距离把远处混进雾色”(思想同宗),但手段和层级不同,取舍也不同。我拆给你看:

物体 shader 雾(本章做法,也是 Unity 全局 Fog 面板的真实实现方式):

  • 在哪算:每个物体的片元着色器里,用自己的视空间深度算 fogFactor。
  • 长处:简单、便宜,每个物体自己搞定;Unity 自带的 Lighting 面板 Fog 开关,本质就是引擎自动在每个物体 shader 里插入了类似 33.4 的雾代码(用内置宏 UNITY_APPLY_FOG)。
  • 短处:它是”逐物体”的,不知道”别的物体在哪”。对于半透明物体、以及”雾要基于屏幕整体深度做复杂效果(比如高度雾、基于深度的体积感)“就力不从心;而且如果场景里物体 A 在雾里、B 不在,各自算各自的,整体一致性能保证,但跨物体的高级效果做不了。

后处理雾(第 30 章后处理里的 Fog 效果):

  • 在哪算:所有物体画完之后,拿整帧的深度缓冲(第 26 章那个 Z-buffer,此时每个像素的深度都齐了)做一次全屏 shader,按深度把整张图往雾色混。
  • 长处:统一、能基于”屏幕空间”做任何事——比如根据深度梯度做高度雾、做基于深度图的复杂大气;它看得到全屏所有像素的深度,可以做物体 shader 雾做不到的全局效果。
  • 短处:需要深度图、需要额外一次全屏 pass,吃那 16 毫秒预算(第 1 章餐厅比喻里”全屏最贵”);实现比物体 shader 雾复杂。

取舍一句话: 物体 shader 雾是”每个菜自己撒盐”(简单、便宜、够用,对应第 8 章基础雾);后处理雾是”端上桌后整体调味”(统一、高级、但贵,对应第 30 章后处理)。Unity 全局 Fog 默认走前者(所以你第 8 章拖滑块就有雾),想做更讲究的大气再叠加后者。

连回全书你会看到一条清晰线索:第 8 章给你”雾的现象与参数”,第 26 章给你”深度这个原料”,第 30 章给你”后处理这把全局手术刀”,而本章 33.4 把你领到”雾到底在哪一行代码被算出来”。现象(8)→ 原料(26)→ 全局手段(30)→ 逐物体写法(33),四个视角拼成完整的雾。

反问你:如果有个需求是”山顶有雾、山脚没雾”(高度相关的雾),你用本章的物体 shader 雾(只认距离)能做到吗?——做不到,因为它只看”离相机多远”。这种就得上后处理雾(看像素的世界高度)。这正好让你体会两种雾的边界在哪。

33.7 你刚刚亲手验证了什么

写到这里,我想让你回头确认自己到底”验证”了什么,而不只是”抄了一段 shader”。

你验证了第一件事:雾不是贴上去的,是算出来的。 第 8 章说”雾是按距离混合颜色”,今天你看到它精确发生在 frag 里的 lerp(col.rgb, _FogColor.rgb, fogFactor) 这一行——而 fogFactor 来自 saturate(...),根子是顶点传下来的视空间深度 o.fogDepth = -viewPos.z。每一像素的颜色,都是”按自己的深度”和雾色做了一次插值。这就是第 8 章参数背后的真身。

你验证了第二件事:“深度”在 shader 里是怎么来的。 它来自第 27 章的 View 变换(Unity 用 UnityObjectToViewPos 替你做了),产生相机空间下的 -z 距离。第 26 章说”每个像素都有深度”,今天你亲手把它取出来、用在了雾的算式里。第 26、27、8 三章在”深度参与雾”这一点上闭合了。

你验证了第三件事:第 8 章那几个旋钮不是黑话。 _FogStart/_FogEnd 就是线性公式的两个端点,_FogDensity 就是指数公式的浓度,_FogColor 就是”远处溶进的颜色”,_UseExp2 就是 Fog Mode 的切换。你下次在 Lighting 面板拖它们,脑子里会有对应的几行 shader 算术,而不是盲拖。

至此,“雾”从你第 8 章认识的”一个滑块”,变成了你第 33 章认识的”一段可写、可调、可解释的代码”。现象 → 公式 → 可运行代码,闭环完成。第十二部分”动手实践”的两章(32 坐标变换、33 雾的 shader)合起来想告诉你的,就这么一句话:懂,是能讲清道理;会,是自己能写出来并跑通。这两章,就是把你从”懂”推到”会”的那一万行代码里的两万步。

33.8 思考题与参考答案

【思考题】

  1. 为什么雾不能简单地用”在镜头前贴一张半透明灰玻璃”来实现?根本矛盾在哪?这和第 8 章讲的”空气透视是越远的越糊”有什么冲突?
  2. 我们在顶点着色器里算 o.fogDepth = -viewPos.z,再传给片元着色器用。为什么不在片元着色器里直接算每个像素的深度,而非要”顶点算、插值传过来”?这个深度来自第 27 章的哪一步变换?
  3. 本章代码里 fogFactor 我定义为”被雾覆盖度”(1=全雾、0=清晰),并写 col.rgb = lerp(col.rgb, _FogColor.rgb, fogFactor)。如果有人把 fogFactor 反过来定义成”清晰度”(1=清晰),但 lerp 的参数顺序没改,画面会出什么诡异现象?这说明了写 shader 前该先钉死什么?
  4. 本章的物体 shader 雾,和第 30 章后处理里的雾,都能”让远处溶进雾色”。请说出它们至少两个本质区别(在哪算、能利用什么信息),并各举一个”只有后处理雾能做、物体 shader 雾做不了”的例子。

【参考答案】

  1. 固定灰玻璃给所有像素同样的灰度,结果是”近处也被盖糊”,而真实雾(第 8 章空气透视)是”越远的越糊、越近的越清”,糊的程度随距离连续变化。一张固定灰玻璃提供不了”随距离渐变”的覆盖率,所以做不到近清远糊的过渡。根本矛盾是:雾的覆盖率依赖每个像素各自离相机的距离,而贴图法是”全局统一”,两者在数学上就不兼容。这也是为什么雾必须逐像素按深度算(33.1)。
  2. 因为顶点数量远少于片元(一个立方体 8 个顶点 vs 上百万像素),在顶点算一次深度、靠 GPU 在三角形内部自动插值得到每像素深度,比每个片元都重做一遍矩阵变换便宜得多——这是图形学”能顶点算就别片元算”的通用省钱原则。这个深度来自第 27 章的 View 变换(世界→相机坐标):UnityObjectToViewPos 内部就是 Model→World→View 的串乘,相机空间下顶点 z 为负、取 -z 即离相机的正向距离,正是第 26 章讲的”视空间深度”。
  3. 若 fogFactor 实际是”清晰度”(1=清晰、0=全雾),但 lerp 仍写成 lerp(物体色, 雾色, fogFactor),那么近处 fogFactor=1 → 取雾色(近处反而全雾),远处 fogFactor=0 → 取物体色(远处反而清晰)。画面会”近处白茫茫、远处清楚”,完全反了。这说明写 shader 前必须先用注释钉死 fogFactor 的语义(到底 1 代表清晰还是被雾覆盖),否则 lerp 参数顺序和公式推导会全盘拧反,调密度也救不回来。
  4. 区别一:在哪算——物体 shader 雾在每个物体的片元着色器里算(逐物体),后处理雾在所有物体画完后对整帧做一次全屏 shader(全局)。区别二:能利用的信息——物体 shader 雾只知道”自己这个像素离相机的距离”,后处理雾能拿到整帧深度缓冲,知道全屏所有像素的深度。只有后处理雾能做的例子:①高度雾(山顶有雾、山脚无雾,要看像素的世界高度而非离相机距离);②基于深度梯度的体积/大气效果(需要比较邻域深度)。这也呼应 33.6:物体 shader 雾只看”离相机多远”,做不了和空间位置(高度)相关的雾。