ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Unity Shader屏幕后处理笔记:从高斯模糊到景深效果实战

2026/9/21 21:45:14 拓冰建站 浏览量
Unity Shader屏幕后处理笔记:从高斯模糊到景深效果实战 在《Unity Shader入门精要》这本书里第12章是我重读次数最多的一章。前11章教你的是“怎么把物体画出来”顶点怎么变换、片元怎么着色、光照怎么叠加本质上都在一个模型或一个材质球的范围内工作。到了第12章视角突然拉高了一整个量级——你不再是修改单个物体的外观而是把相机拍到的那一整张画面重新“洗”一遍。这种从“画物体”到“修画面”的思维切换很多自学者会卡很久我也不例外。这一章讲的是屏幕后处理效果Screen Post-Processing Effects涵盖高斯模糊、边缘检测、景深这三个经典效果。它到底解决什么问题简单说当你需要画面整体出现模糊、描边、景深这类效果时逐物体改Shader是改不出“画面整体氛围”的必须在相机渲染完成后做一次全屏加工。对技术美术来说这章是衔接“美术效果”和“引擎渲染管线”的桥也是从“会写Shader”到“会设计渲染流程”的分水岭。这篇文章就是我重刷第12章时的完整笔记包含原理拆解、实战代码、性能取舍和我在2022版Unity里迁移老代码时踩过的一堆坑供同样在自学路上的人参考。1. 先把第12章放进整本书的地图里从“画物体”到“修画面”第12章之所以让很多人觉得跳脱是因为它前面几章的体系太完整了。第6章讲基础光照第7章讲法线纹理和遮罩纹理第9章讲更复杂的光照模型第10章讲透明度第11章讲顶点动画——这些都有一个共同点你的Shader是挂在某个Mesh上的你控制的是这个物体的顶点和片元。到了第12章Shader不再挂在一个具体的物体上而是通过相机的渲染流程作用在整张RenderTexture上。1.1 屏幕后处理在渲染管线里的位置先捋一下位置。一个正常的相机渲染流程是相机把场景里可见的物体逐一遍历经过顶点着色器、片元着色器写入一张RenderTexture如果不开HDR通常就是默认的framebuffer最后呈现到屏幕上。屏幕后处理做的事情是在“场景已经画完”和“呈现到屏幕”之间插进一个或多个全屏Pass。这里的核心逻辑是先把整张画面当作一张贴图然后用一个新的材质对它做一次采样和重计算把计算结果再输出到屏幕。打个比方。普通Shader像是给每个模特单独化妆粉底、眼影、口红都是针对个人脸型来的。屏幕后处理不是再给模特化妆而是给已经拍好的那张合照加滤镜整体调色、整体磨皮、整体加一圈边框。滤镜会对画面里所有人一视同仁地生效这就是后期效果和逐物体效果最本质的区别。1.2 第12章的三条主线分别解决什么问题书里这一章不是只讲一个效果而是用三个经典案例贯穿了屏幕后处理的完整套路高斯模糊解决“画面整体柔化”的问题。它是卷积操作的典型代表后面的景深、泛光、描边都需要模糊做基础。边缘检测解决“把画面里物体的轮廓找出来”的问题。它引入的是Sobel算子同样是卷积但是作用在深度和法线纹理上。景深解决“模拟相机镜头焦点”的问题。它把深度纹理、高斯模糊、动态参数调节全部串起来是一个综合应用。这三个效果看似独立实际是一个递进关系高斯模糊让你理解“全屏重采样”是怎么运作的边缘检测让你理解“多维纹理数据颜色、深度、法线如何参与后处理”景深则把前面两者的经验合在一起再加上一个“实时变化的参数”做出接近真实相机的视觉效果。1.3 为什么说这章是技术美术入门的分水岭我见过很多自学Shader的人把前11章的代码敲了一遍自我感觉很好到第12章突然就停了。原因不是这章的Shader语法变难了而是它第一次要求你具备“渲染流程思维”。你要同时关心C#脚本里的OnRenderImage、RenderTexture的分配与释放、Shader里多Pass的配合、还有相机组件的设置。任何一个环节断了画面上就什么都不显示或者直接报错而且报错信息往往很抽象。反过来说一旦你把这章吃透后面的屏幕空间反射、全局光照、各种风格化滤镜对你来说都只是“换个采样方式、换个混合系数”的问题。很多游戏公司招TA时问的“你做过后期处理吗”考察的其实就是你理解不理解这一整套链路的运作而不是你会不会背某个模糊公式。2. 屏幕后处理的脚本地基OnRenderImage、Blit与临时RT的一生书里第12章的代码示例用到的C#脚本部分是整套后处理系统的地基。这部分看起来只是几行生命周期函数却是初学者最容易写错、最不容易排查问题的地方。2.1 OnRenderImage的执行时机与两种签名Unity内置管线下屏幕后处理的入口是MonoBehaviour的OnRenderImage回调。它的执行时机非常明确当前相机的所有渲染工作完成之后、画面显示到屏幕之前。这正好卡在“场景已经画完”和“呈现到屏幕”之间。两个常用的签名void OnRenderImage(RenderTexture src, RenderTexture dest)或者带pass参数的版本void OnRenderImage(RenderTexture src, RenderTexture dest) { Graphics.Blit(src, dest, material, passIndex); }src是相机刚渲染出来的那张纹理dest是即将显示到屏幕上的目标。你要做的就是用材质在两者之间做一次“翻译”。注意这一步不要自己创建新的渲染目标再显示很容易造成多一帧延迟或者画面不刷新。2.2 Graphics.Blit到底做了什么Graphics.Blit可能是很多初学者只知其名不知其理的API。它做的事情本质是把src绑定为一张贴图然后画一个覆盖整个屏幕的三角形或四边形让材质里的fragment shader对屏幕上的每个像素执行一次。这里有个很反直觉的细节你不需要提供任何Mesh。Unity内部会生成一个全屏三角形并且处理好顶点坐标和屏幕坐标的映射。所以在后处理Shader里你通常会在顶点着色器里把顶点坐标直接传到片元着色器里当UV用连模型矩阵都不用管。struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; v2f vert(appdata_img v) { v2f o; o.vertex UnityObjectToClipPos(v.vertex); o.uv v.texcoord; return o; }appdata_img是UnityCG.cginc里自带的一个结构体它只包含顶点坐标和贴图坐标专门给屏幕后处理用。看到这里你应该明白后处理Shader的顶点着色器纯粹是“搬运工”真正干活的全在片元着色器里。2.3 临时RT的分配与释放内存泄漏的根源后处理效果经常需要不止一个Pass。比如高斯模糊先横向模糊再纵向模糊中间必须有一张中间纹理暂存结果。如果不手动分配临时纹理你就得等前一个Pass完成后再申请一张RenderTexture反复分配释放会带来GC压力和GPU内存碎片。标准做法是Unity提供的临时RT池RenderTexture temp RenderTexture.GetTemporary(src.width, src.height, 0, src.format); Graphics.Blit(src, temp, material, 0); Graphics.Blit(temp, dest, material, 1); RenderTexture.ReleaseTemporary(temp);GetTemporary不是真的每次创建新纹理而是从Unity全局的RT池里取一个复用对象所以速度很快。但如果你调用了GetTemporary却忘记ReleaseTemporary那张RT就会一直占着池子不还连续切换几次场景或者连续触发几次后处理内存直接涨上去。Profiler的Memory分类里能看到RenderTexture数量异常上涨。我踩过的坑是在OnRenderImage里做了分支处理一个分支里GetTemporary了另一个分支忘了Release。表面上画面没异常运行十分钟后突然掉帧查了半天才用Profiler定位到RT没有释放。写后处理脚本最基础的习惯就是进入OnRenderImage前先想好所有分支路径保证每条路径里的临时RT都能走到ReleaseTemporary。2.4 脚本模板一个不怕出事的后处理基类给一个我自己总结的后处理基类模式它处理了Shader丢失、材质找不到、临时RT泄漏三个常见问题using UnityEngine; [ExecuteInEditMode] [RequireComponent(typeof(Camera))] public class PostProcessBase : MonoBehaviour { public Shader shader; private Material _material; protected Material Material { get { if (_material null || _material.shader ! shader) { if (shader null) return null; _material new Material(shader); _material.hideFlags HideFlags.HideAndDontSave; } return _material; } } protected void OnRenderImage(RenderTexture src, RenderTexture dest) { if (shader null || Material null) { Graphics.Blit(src, dest); return; } CustomRender(src, dest); } protected virtual void CustomRender(RenderTexture src, RenderTexture dest) { Graphics.Blit(src, dest, Material); } protected void OnDisable() { if (_material ! null) { DestroyImmediate(_material); _material null; } } }这个基类有几个好处hideFlags设为HideAndDontSave材质不会污染场景文件OnDisable里销毁材质避免编辑器模式下残留shader为空时直接原图输出不至于黑屏。3. 高斯模糊实操推演卷积核、降采样与迭代次数之间的取舍高斯模糊是屏幕后处理第一个正经效果。它表面上是“让画面变模糊”实际上它背后是整章最核心的算法基础——卷积。如果你能真正理解高斯模糊后面边缘检测和景深用到的卷积思想都是同一套东西。3.1 从高斯函数到卷积核为什么用高斯权重而不是均匀权重模糊的本质是“用周围像素的颜色来混合当前像素”。最简单的做法是取周围九个像素求平均这叫均值模糊。但均值模糊有个问题每个方向上的权重都一样模糊出来的效果会出现块状感边缘也不自然。高斯模糊的改进是**权重按距离下降遵循高斯函数的钟形曲线。**离中心像素越近的邻居贡献越大越远的贡献越小。这样的模糊过渡更平滑更像光学系统的弥散效果。二维高斯函数可以写成G(x, y) (1 / (2πσ²)) * e^(-(x² y²) / (2σ²))直接用一个5x5的高斯核做卷积需要采样25次。这样性能开销太高。高斯函数有一个非常好的性质**二维高斯可以拆成两个一维高斯的乘积。**所以先做横向一维模糊再做纵向一维模糊效果等价于一次二维模糊但采样次数从25次降到10次55。如果核更大差距更明显。第12章用的就是这种两Pass方案。3.2 关键细节降采样不是偷懒而是模糊的一部分书里用了一个很多新手觉得“是不是作弊”的做法先对画面降采样再做模糊最后再升采样回去。这看起来很取巧但背后是有道理的。纹理分辨率越高每个纹素在屏幕上覆盖的像素数越少高斯核能影响的“物理范围”就越小。假设你在1920x1080的画面里做横向模糊半径为2意味着只影响前后总共5个像素这个半径在物理屏幕上的跨度非常小肉眼几乎看不出模糊。但如果你先把画面降到960x540同样模糊半径为2影响的就是原来的10个像素跨度模糊效果就直观多了。所以降采样本身就是一次免费的模糊。正确的模糊流程是把src降采样到1/2或1/4分辨率在低分辨率上做多次横向/纵向模糊必要时再升采样回原分辨率输出这个流程既省了计算量又让模糊效果更明显。书里的高斯模糊需要拖一个scale参数本质就是在控制降采样倍数和模糊半径。3.3 迭代次数不是越多越好参数组合的平衡我在实际跑效果时专门对同一张画面做了5组测试降采样倍数固定为2模糊迭代次数分别是1、2、3、5、8次。结果很直观迭代次数画面主观感受移动端大致耗时1轻微柔化像隔了层纱低2明显模糊边缘还能识别低3强模糊大面积柔光中等5几乎看不清细节变“糊”高8画面全糊接近色块很高关键是迭代到5次以上时人眼能感知到的差别已经很小但GPU的负荷还在线性增长。做项目时千万别无脑把迭代次数拉到10次先确定你想要的是“柔化边缘”还是“完全看不清”再反推参数。另一个坑是模糊半径_BlurSize调太大。当你用的是一维9x9核但半径超过纹理宽度的1/4时采样点会越过有效纹素范围出现重复采样边缘或者空洞。解决办法是配合降采样让半径始终控制在纹理尺寸的合理比例内。3.4 高斯模糊的标准Shader书里版本加注释这是第12章高斯模糊的裁减版我加了行内注释说明每一步的意图Shader Custom/Chapter12_GaussianBlur { Properties { _MainTex (Base (RGB), 2D) white {} _BlurSize (Blur Size, Float) 1.0 } SubShader { CGINCLUDE #include UnityCG.cginc sampler2D _MainTex; half4 _MainTex_TexelSize; float _BlurSize; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; v2f vert(appdata_img v) { v2f o; o.vertex UnityObjectToClipPos(v.vertex); o.uv v.texcoord; return o; } // 一维高斯核对应sigma 1.33时的近似权重 fixed4 frag_gaussian9x9(v2f i) : SV_Target { half weight[5] {0.227027, 0.1945946, 0.1216216, 0.054054, 0.016216}; fixed3 sum tex2D(_MainTex, i.uv).rgb * weight[0]; // 利用对称性一次采样同时算左右/上下两个方向实际5次采样 for (int t 1; t 5; t) { float2 offset _BlurSize * _MainTex_TexelSize.xy * t; sum tex2D(_MainTex, i.uv offset).rgb * weight[t]; sum tex2D(_MainTex, i.uv - offset).rgb * weight[t]; } return fixed4(sum, 1.0); } ENDCG Pass { ZTest Always Cull Off ZWrite Off CGPROGRAM #pragma vertex vert #pragma fragment frag_gaussian9x9 ENDCG } // Pass 2 完全一样只是C#脚本里在Blit时用uv.x和uv.y分别控制方向 } }_ZTest Always、Cull Off、ZWrite Off是全屏后处理Shader必须写死的三件套。因为我要处理的是一张已经渲染好的纹理深度测试和写入没有任何意义反而可能导致看不见输出。4. 边缘检测的进阶路线颜色边缘很脏就去算深度法线高斯模糊的卷积思想用熟了以后边缘检测就顺理成章了。边缘检测想解决的问题是“找到画面里物体的边界”做法是在每个像素周围采样一圈计算相邻像素的差异程度。差异大的地方就是边缘。4.1 Sobel算子怎么用两次卷积找出梯度Sobel算子是最经典的边缘检测卷积核。它用两个3x3的卷积核一个检测水平方向梯度Gx一个检测垂直方向梯度GyGx [[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]] Gy [[-1, -2, -1], [0, 0, 0], [1, 2, 1]]对每个像素分别用这两个核跟周围的9个像素做卷积得到这个像素在x方向和y方向上的变化幅度。梯度幅值 sqrt(Gx² Gy²)。梯度幅值越大说明这个像素周围颜色变化越剧烈越可能是边缘。直接套这个公式到颜色纹理上会有一个问题**画面里的高频纹理太多。**衣服的褶皱、墙面的纹理、树叶的缝隙全都会被认为是边缘。如果你想要的效果是卡通描边这种“脏”边缘会让画面显得非常杂乱。4.2 为什么改用深度和法线纹理能获得更干净的边缘书里给的方案是用深度纹理和法线纹理代替颜色纹理来计算Sobel。这是一步非常重要的架构升级。深度和法线描述的是场景的几何信息而不是表面的纹理细节。深度在物体轮廓处变化剧烈前景物体的深度小背景物体的深度大交界处必然产生高梯度。法线在表面朝向变化处变化剧烈墙面和天花板的法线不同即使颜色一样也能检测出边缘。用深度和法线做边缘检测得到的是几何意义上的真实轮廓而不是纹理噪声。这也是很多卡通渲染里描边效果能保持画面干净的原因。4.3 深度法线纹理的采样陷阱和精度问题启用深度法线纹理很简单在相机上设置Camera.main.depthTextureMode | DepthTextureMode.DepthNormals;深度法线纹理是一张基于视空间下的法线信息和深度信息编码后的纹理。Shader里采样它float4 depthNormal tex2D(_CameraDepthNormalsTexture, i.uv); float depth; float3 normal; DecodeDepthNormal(depthNormal, depth, normal);DecodeDepthNormal会把编码解出来depth是[0,1]范围的线性深度不是原始深度缓冲那种非线性值normal是视空间下的单位法线。这里有个常见的坑**在OpenGL平台和DirectX平台下屏幕空间UV的原点方向不同。**书里2016年成稿时的代码在写边缘检测时通常要判断宏定义来翻转uv。Unity 2020之后的内置管线虽然做了大量统一处理但如果你在编辑器里一切正常、在真机上却出现边缘偏移第一嫌疑仍然是深度法线纹理在API差异下的采样坐标问题。4.4 边缘颜色替换和阈值控制拿到梯度值后通常用一个变量控制边缘的显隐比例float edge 1 - abs(dx) - abs(dy); float4 withEdgeColor lerp(_EdgeColor, tex2D(_MainTex, i.uv), edge); float4 onlyEdgeColor lerp(_EdgeColor, fixed4(0,0,0,0), edge); return lerp(withEdgeColor, onlyEdgeColor, _EdgeOnly);_EdgeOnly是0到1之间的值。等于0时输出原图和边缘颜色的混合效果边缘处用边缘色其余区域保留原图等于1时只输出边缘相当于一张线稿。我在调试这个效果时发现边缘阈值非常吃场景。同一个阈值在展示厅场景里效果好换到杂乱场景里全是噪点。所以做程序化参数时至少要暴露两个梯度阈值和边缘柔化范围让TA可以在Inspector里快速调。5. 景深效果全链路拆解从深度纹理到弥散圆景深是我个人觉得第12章最“像做项目”的一个效果因为它把前面所有知识点串起来了深度纹理的获取、高斯模糊的实现、动态参数的控制、性能的取舍。做完一个完整的景深效果你对整个后处理管线的理解会明显上一个台阶。5.1 真实相机的景深原理焦点对在哪里哪里就清晰理解景深先理解真实相机镜头把光线投射到焦平面上只有焦点所在平面的物体成像最清晰离焦点越远的物体成像越模糊。这个“模糊程度”用弥散圆Circle of Confusion, CoC来量化——一个点光源在传感器上不是理想的点而是一个小的圆斑圆斑越大画面越糊。模拟景深至少需要三样数据每个像素的深度通过_CameraDepthTexture采样。焦点位置用一个参数设定通常用距离相机的某个距离值表示或者用鼠标点击选择对焦物体。光圈大小决定弥散圆的半径光圈越大虚化越强。5.2 Unity里怎么拿到像素深度并转成线性距离内置管线里要拿到深度纹理在相机上开启Camera.main.depthTextureMode | DepthTextureMode.Depth;Shader端采样float depth SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv);注意这个深度值是经过非线性压缩后的非线深度范围是[0,1]不能直接和“距离相机10米”这样的参数比较。需要转成线性深度float linearDepth LinearEyeDepth(depth);或者用Linear01Depth得到一个0到1之间的归一化线性深度。景深计算里我习惯用LinearEyeDepth因为焦点距离通常会用世界单位的米数来设置两个量纲一致才能做差值计算。5.3 弥散圆的计算和混合权重得到线性深度后计算弥散圆参数float coC (linearDepth - _FocusDistance) / _FocusRange; coC clamp(coC, -1, 1); coC coC * coC * sign(coC);第一行计算深度与焦点的差值第二行限制在[-1,1]第三行做个非线性映射让近处和远处的虚化过渡更自然。这个coC值就是后面混合模糊结果的权重。景深输出分两层对原图做高斯模糊得到全屏模糊版本。用coC作为lerp系数把清晰原图和模糊图按权重混合。coC绝对值大的像素离焦点远用模糊图更多coC接近0的像素在焦点上用原图更多。5.4 书本版本和真实Bokeh效果的距离书里实现的景深是“圆形均匀模糊混合”它已经能看出明显的景深感。但真实相机镜头的散景往往是六边形或圆环形的而且受镜头光圈叶片形状影响。要做真正的Bokeh效果通常要用更高质量的点扩散函数PSF采样甚至用多个采样点模拟光圈形状这属于后处理里比较高级的方向。初学阶段不建议一上来就啃Bokeh滤镜先把“深度→弥散圆→模糊混合”这条链路跑通知道每个参数的作用后面加Bokeh只是在模糊采样阶段换一种采样模式而已。景深在高斯模糊基础上的实现有几个参数表我每次做Demo都照着填参数含义典型值_FocusDistance焦点距离米5 ~ 20_FocusRange焦点前后清晰范围米3 ~ 10模糊迭代次数模糊强度2 ~ 4模糊半径散景大小1.0 ~ 4.06. 把书里的老代码搬到Unity 2022内置管线下的版本迁移与调试习惯第12章成稿的年代大概是Unity 5.x时代我在Unity 2022上重做这一章的时候除了Shader语法大体兼容之外其他地方遇到了一堆“查了半天发现自己搜错方向了”的问题。这部分记录一下版本迁移的注意点和调试工具链的组合用法。6.1 版本迁移的几个显性差异第一RenderTexture的格式。旧代码里经常用RenderTextureFormat.ARGB32新版本里为了兼容HDR和高动态范围建议统一用GraphicsFormatUtility.GetGraphicsFormat(RenderTextureFormat.DefaultHDR, true)或者干脆直接用RenderTextureFormat.DefaultHDR。否则在高动态范围的场景里会出现颜色断层。第二OnRenderImage在内置管线和URP管线下的地位不同。Unity 2022里如果项目创建时选了URP模板默认情况下OnRenderImage不会被调用。你需要在URP的Renderer特性或者CommandBuffer里自己实现Blit。第12章整章代码都基于内置管线所以我建议初学者先建一个Built-in Render Pipeline的项目来跟书做实验等理解了这个流程再往URP迁移。第三线性空间和Gamma空间的差异。Unity 2022新项目默认是线性颜色空间。线性空间下模糊和颜色混合的结果更物理正确但如果你是用书里的颜色参数直接调出来的效果在Gamma空间下会偏亮线性空间下会偏暗。我的习惯是先在所有没有后期效果的材质上确定颜色空间再调后处理参数不然换个管线整个画面的观感全变了。6.2 调试后处理效果的工具组合Frame Debugger RenderDoc Profiler写后处理效果最容易遇到的问题是“画面全黑但报错信息很少”。这时候别急着改代码先用工具链定位。Frame Debugger是Unity自带的逐DrawCall查看工具。打开Window Analysis Frame Debugger在运行状态下点击Enable就能看到相机渲染的每一帧里所有DrawCall的列表。如果屏幕后处理生效了你会看到在场景DrawCall之后多出一个全屏三角形或者全屏四边形的Pass它显示的Shader就是你的后处理Shader。如果这个Pass没出现说明OnRenderImage根本没被调用如果出现了但结果是黑屏问题就在Shader内部。RenderDoc在排查采样坐标和资源绑定问题时非常好用。特别是遇到不同的平台显示结果不一致、UV翻转这类问题RenderDoc能直接查看每个Pass的输入纹理和最终输出还能用debug overlay把特定的中间值可视化成像。Unity 2022的Window Package Manager里提供RenderDoc的unity integration包装完后Game视图右上角会出现RenderDoc的截图按钮。Profiler主要看性能。后处理效果的性能大头在纹理采样次数和RenderTexture的分辨率。一个全屏效果如果在1080p下需要20次tex2D在移动端基本是扛不住的。性能优化的两个方向是降采样到半分辨率再处理以及把多个效果合并到一个Pass里。记住一个原则渲染一张全分辨率RT很贵渲染一张半分辨率RT再放大肉眼几乎分不出来。6.3 关于阴影、画面整体观感和后处理的关系很多初学者在做后处理时会遇到一个迷惑为什么我的场景里开了实时阴影做了高斯模糊之后阴影边缘变得很糊这其实是正常的——后处理作用于整张画面模糊必然同时模糊阴影边缘。如果你既想要柔和的光照又不希望阴影糊掉有两条路一是让摄像机用更高精度的阴影贴图提升Shadow Distance和Shadow Cascades二是用深度法线边缘检测重绘清晰轮廓。这属于画面整体设计的范畴没有标准答案取决于你想传达的视觉风格。书里的12章没有专门讲阴影但如果你的项目里有多个光源、阴影和后期并存的场景建议花时间看一下相机的深度纹理和阴影图之间的关系它们共享部分深度信息调试起来会有一些牵一发动全身的连锁反应。6.4 自学阶段最有价值的复盘方法学完这一章不要急着往后翻。我觉得最有价值的复盘方法是故意把某个后处理效果的步骤打乱或者把某个Pass注释掉观察画面会发生什么变化。比如在高斯模糊的C#脚本里把第二次Blit纵向模糊屏蔽画面就会只剩横向条纹。在边缘检测的脚本里把深度法线纹理的开启代码删掉画面就会变成一片错误的噪声。做完这些破坏性实验你对每个Pass到底承担了什么职责会形成直观的肌肉记忆。这比看着源码抄十遍都管用。我个人当时是把三个效果串成了一个演示场景先做高斯模糊得到柔和底图再做边缘检测拿到描边最后用景深把前景和背景分离。虽然组合起来在性能上一塌糊涂但整个流程跑通的时候确实有种“我居然能控制整张画面了”的爽感。如果你也在精读第12章建议把书上的代码手敲一遍然后自己试着改参数、加Pass、换纹理直到你能不看参考就把它写出来。这一步做到了后面学屏幕空间环境光遮蔽、体积光、渐变映射这些进阶内容都会顺畅很多。