
1. 项目概述Shader介入视锥体剔除的独特价值在Unity开发中尤其是追求极致视觉效果或特定艺术风格的项目里我们常常会遇到一个矛盾引擎内置的视锥体剔除Frustum Culling为了性能会“自作主张”地帮我们隐藏掉相机看不到的东西。这本身是好事但当你想要实现一些“非常规”的视觉效果时它就成了绊脚石。比如你想让一个物体在移出屏幕时不是突然消失而是像雾气一样缓缓淡出或者你想制作一个“幽灵”角色其身体的一部分即使不在视锥体内也应当若隐若现再比如某些全屏后处理效果需要采样屏幕外的纹理信息。在这些场景下标准的剔除逻辑会直接切断渲染管线让你的Shader巧妇难为无米之炊。这就是我们今天要深入探讨的核心如何通过Shader层面的技巧来干预甚至“欺骗”标准的视锥体剔除流程从而实现那些依赖屏幕外数据或需要特殊消失逻辑的视觉效果。请注意我们不是在禁用Unity的剔除系统那会带来灾难性的性能问题而是在尊重其基础规则的前提下在Shader中开辟一条“后门”让物体在剔除边界附近能以我们自定义的方式表现。理解这个技术意味着你掌握了在性能与表现力之间进行精细权衡的高级工具。它不适合日常的UI或场景搭建但对于技术美术、图形程序员或任何想突破标准渲染管线的开发者来说这是一个必须了解的“魔法”领域。接下来我将拆解其背后的原理、核心实现方案并分享我踩过的一些坑和实战技巧。2. 核心原理视锥体剔除的边界与Shader的延伸要控制它必须先彻底理解它。Unity以及所有现代图形引擎的视锥体剔除发生在CPU端的渲染管线准备阶段远早于GPU执行我们的Shader。其决策依据非常简单粗暴计算每个渲染器Renderer的包围盒Bounds检查这个包围盒是否与由相机近/远裁剪平面和四个侧平面构成的视锥体相交。如果完全不相交则该渲染器的所有绘制调用都不会被提交到GPU。这里的关键词是“包围盒”和“完全不相交”。引擎依赖的包围盒通常是Mesh Filter组件上的那个边界框或者由代码动态设置。一旦这个框整体在视锥之外整个物体就从本帧的渲染列表中消失了你的Shader再强大也无济于事。那么Shader如何介入呢我们的思路不是去修改CPU端的剔除计算那需要修改引擎源码而是通过以下两种策略来“影响”结果扩展有效渲染范围通过脚本在CPU端有策略地扩大渲染器的包围盒使其始终与视锥体保持相交从而“骗过”剔除系统。这样Shader就能一直被执行我们在Shader内部再实现自定义的“软剔除”或渐变效果。利用摄像机深度纹理对于某些后处理效果我们需要的不是某个特定物体的屏幕外数据而是整个场景在屏幕外的深度或颜色信息。这时可以依赖Unity的Camera Depth Texture或DepthNormals Texture。这些纹理在渲染不透明物体时生成其范围虽然也受视锥体影响但包含了深度信息允许我们在后处理Shader中“窥探”近裁剪平面之前的几何形状实现边缘模糊、雾气等需要屏幕外信息的特效。第一种策略是主动的、针对单个物体的控制第二种则是被动的、面向全屏效果的利用。本篇文章将重点聚焦于第一种策略因为它在实现物体级别的特殊效果上更为直接和强大。3. 方案设计与关键组件解析要实现通过Shader控制剔除效果一个完整的方案需要CPU端与GPU端Shader协同工作。下图展示了核心的数据流与控制逻辑flowchart TD A[“开始: 需要特殊剔除效果的物体”] -- B{“选择控制策略”} B -- C[“策略一: 动态包围盒扩展”] B -- D[“策略二: 深度纹理采样”] subgraph C_Flow [动态包围盒扩展流程] C1[“CPU端脚本br如DynamicBoundsController”] -- C2[“动态计算并扩展brMeshRenderer.bounds”] C2 -- C3[“物体始终提交至GPU”] C3 -- C4[“顶点/片元着色器br计算自定义剔除系数”] C4 -- C5[“输出时应用系数br如alpha渐变”] end subgraph D_Flow [深度纹理采样流程] D1[“启用摄像机brDepth Texture模式”] -- D2[“全屏后处理Shader”] D2 -- D3[“采样屏幕外UV坐标br对应的深度纹理”] D3 -- D4[“基于深度信息br实现屏幕边缘特效”] end C_Flow -- E[“实现效果br如: 边缘淡出、幽灵化”] D_Flow -- F[“实现效果br如: 边缘雾气、动态模糊”] E -- G[“最终渲染结果”] F -- G3.1 CPU端的基石动态包围盒控制器既然剔除的依据是包围盒那么最直接的思路就是控制这个包围盒。我们不能简单地设置一个巨大的静态包围盒那会严重影响其他优化如遮挡剔除。一个优雅的方案是根据物体的实际状态、相机的相对位置或者Shader中需要的额外范围动态地、每帧计算一个“合理”的扩展后包围盒。为此我通常会创建一个名为DynamicBoundsController的C#脚本。它的核心职责是获取引用在Start()中获取MeshRenderer或SkinnedMeshRenderer组件。计算扩展向量根据需求确定需要将包围盒向各个方向扩展多少。这个“需求”可以是一个固定的偏移值用于简单的范围扩大。基于物体到相机距离的缩放值距离越远为了平滑过渡可能需要扩展得更多。读取Shader中通过MaterialPropertyBlock设置的参数实现CPU与GPU的通信。应用扩展在LateUpdate()中先获取渲染器当前的bounds然后使用bounds.Expand()方法以上述计算出的向量进行扩展最后将扩展后的包围盒赋值回去。重要提示对SkinnedMeshRenderer操作包围盒要格外小心。蒙皮动画会导致网格顶点位置剧烈变化其bounds是引擎每帧自动计算的。直接修改可能无效或被覆盖。更可靠的做法是修改SkinnedMeshRenderer.localBounds这是一个在模型本地空间内的边界引擎会基于它进行世界空间变换和剔除计算。3.2 GPU端的灵魂自定义剔除系数计算当CPU端确保物体能被提交到GPU后真正的魔法就在Shader里发生了。我们的目标是在片元着色器中计算一个从0到1的“可见性系数”我常称之为_CullFactor然后用这个系数去调制最终输出的颜色尤其是Alpha通道或其他属性。计算这个系数的核心是“距离函数”。我们需要一个函数输入是片元像素在某种空间下的坐标输出是该位置“离开屏幕”或“接近剔除边界”的程度。常用空间有裁剪空间Clip Space顶点着色器输出的o.pos就在这个空间。其xy分量在[-1, 1]范围内表示在屏幕内。我们可以计算片元的裁剪空间坐标通过ComputeScreenPos等函数转换得到然后计算其xy到边界-1和1的距离。距离越近系数越接近0。视口空间Viewport Space坐标范围是(0,0)到(1,1)。计算到四边的距离更为直观。自定义空间例如以物体中心为原点计算片元到相机视锥体侧面的近似距离。这更复杂但能实现更艺术化的控制。一个简单的裁剪空间边缘淡出计算如下在片元着色器中// 假设 i.screenPos 是经过透视除法的屏幕空间坐标范围0~1 float2 edgeFactor abs(i.screenPos.xy - 0.5) * 2; // 映射到0~1中心为0边缘为1 float distanceToEdge max(edgeFactor.x, edgeFactor.y); // 取到最近边缘的距离 float cullFactor 1.0 - smoothstep(_FadeStart, _FadeEnd, distanceToEdge); // 平滑过渡其中_FadeStart和_FadeEnd是材质参数控制淡出开始的边缘距离和完全消失的距离。3.3 通信桥梁MaterialPropertyBlock的使用为了让CPU端脚本能根据情况动态调整Shader参数比如告诉Shader扩展了多大范围以便Shader精确计算淡出我们需要一个高效的通信机制。直接修改Material的属性是低效的因为它会影响所有使用该材质的实例并且可能触发批处理中断。正确的做法是使用MaterialPropertyBlock。它允许你为每个渲染器实例单独覆盖一组Shader属性而无需创建新的材质实例。在DynamicBoundsController中MaterialPropertyBlock _propBlock; void UpdateShaderProperties() { if (_propBlock null) _propBlock new MaterialPropertyBlock(); renderer.GetPropertyBlock(_propBlock); _propBlock.SetFloat(_BoundsExtension, calculatedExtension); // 可以传递其他参数如相机距离等 renderer.SetPropertyBlock(_propBlock); }这样Shader就能通过_BoundsExtension这个变量得知当前包围盒被扩展了多少从而在距离计算中将其考虑进去实现CPU与GPU的精准协同。4. 核心Shader实现与参数详解理论说得再多不如一行代码。让我们深入一个具体的、可用的Shader实现它实现了基于屏幕边缘距离的Alpha淡出效果并且考虑了CPU端传递的扩展参数。4.1 Shader框架与属性定义我们选择Unity URPUniversal Render Pipeline作为示例管线因为它是当前和未来的主流。Shader类型为Unlit但原理适用于所有类型。Shader Custom/FrustumCullingControl { Properties { _BaseColor (Base Color, Color) (1,1,1,1) _BaseMap (Base Map, 2D) white {} // 控制淡出的核心参数 _FadeStart (Fade Start, Range(0, 0.5)) 0.4 _FadeEnd (Fade End, Range(0, 0.5)) 0.49 // 接收CPU传递的扩展范围信息用于更精确的计算 _BoundsExtensionWS (Bounds Extension (WS), Float) 1.0 // 可选基于相机距离的额外淡化 _DistanceFadeStart (Distance Fade Start, Float) 10 _DistanceFadeEnd (Distance Fade End, Float) 20 } SubShader { Tags { RenderTypeTransparent QueueTransparent RenderPipelineUniversalPipeline} Blend SrcAlpha OneMinusSrcAlpha ZWrite Off // 透明物体通常关闭深度写入避免排序问题 Pass { HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl // ... 后续代码 ENDHLSL } } }属性解析_FadeStart/_FadeEnd在视口空间0-1中定义。当片元到屏幕边缘的距离大于_FadeStart时开始淡出大于_FadeEnd时完全透明。两者应非常接近0.5屏幕边缘例如0.45和0.49这样淡出区域仅发生在最边缘。_BoundsExtensionWS这是一个关键参数计划由CPU端的MaterialPropertyBlock设置。它表示当前包围盒在世界空间中被扩展了多少。Shader可以利用这个值来调整距离计算的阈值实现“扩展了多少就相应多渲染多少”的效果。_DistanceFadeStart/End这是另一个常见的淡化维度基于物体与相机的距离。即使物体在屏幕内如果太远也可以淡出。它与边缘淡出是相乘的关系共同构成最终的透明度。4.2 顶点着色器准备关键数据顶点着色器的任务不仅是变换顶点位置更重要的是为片元着色器准备用于计算剔除系数的数据。struct Attributes { float4 positionOS : POSITION; float2 uv : TEXCOORD0; }; struct Varyings { float4 positionHCS : SV_POSITION; float2 uv : TEXCOORD0; float4 screenPos : TEXCOORD1; // 用于屏幕空间计算 float3 positionWS : TEXCOORD2; // 世界坐标用于距离计算 }; Varyings vert(Attributes IN) { Varyings OUT; VertexPositionInputs positionInputs GetVertexPositionInputs(IN.positionOS.xyz); OUT.positionHCS positionInputs.positionCS; // 裁剪空间位置 OUT.uv TRANSFORM_TEX(IN.uv, _BaseMap); OUT.positionWS positionInputs.positionWS; // 世界空间位置 // 计算屏幕空间位置。使用ComputeScreenPos它考虑了齐次坐标结果在片元着色器中进行透视除法。 OUT.screenPos ComputeScreenPos(OUT.positionHCS); return OUT; }这里我们计算了screenPos。在片元着色器中我们需要对其xy分量除以w分量来得到真正的标准化设备坐标NDC或视口坐标。4.3 片元着色器计算最终可见性这里是所有逻辑汇聚的地方。我们将结合屏幕边缘距离和物体-相机距离来计算最终的Alpha值。half4 frag(Varyings IN) : SV_Target { half4 baseColor SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, IN.uv) * _BaseColor; // 1. 计算屏幕边缘淡化因子 float2 screenUV IN.screenPos.xy / IN.screenPos.w; // 透视除法得到视口坐标(0~1) float2 distanceToEdge abs(screenUV - 0.5) * 2; // 映射到(0~1)边缘为1 float edgeDistance max(distanceToEdge.x, distanceToEdge.y); // 到最近边缘的距离 // 结合BoundsExtension微调阈值扩展越大允许的edgeDistance可以更大一些才开始淡出 // 这是一个简化模型实际可根据_BoundsExtensionWS与视锥体角度的关系做更精确的映射 float adjustedFadeStart _FadeStart _BoundsExtensionWS * 0.01; // 示例性调整 float adjustedFadeEnd _FadeEnd _BoundsExtensionWS * 0.01; float edgeFactor 1.0 - smoothstep(adjustedFadeStart, adjustedFadeEnd, edgeDistance); // 2. 计算基于相机距离的淡化因子 float3 cameraPosWS GetCameraPositionWS(); // URP内置函数获取相机世界坐标 float distanceToCamera distance(IN.positionWS, cameraPosWS); float distanceFactor 1.0 - smoothstep(_DistanceFadeStart, _DistanceFadeEnd, distanceToCamera); // 3. 合并所有因子得到最终透明度 float finalAlpha baseColor.a * edgeFactor * distanceFactor; // 4. 可选非常激进的优化——在GPU端早期剔除 // 如果finalAlpha小于一个极小的阈值如0.01直接丢弃片元节省混合开销。 // 但这会破坏深度对于透明物体需谨慎。 // clip(finalAlpha - 0.01); return half4(baseColor.rgb, finalAlpha); }代码要点解析屏幕坐标转换IN.screenPos.xy / IN.screenPos.w是标准操作将齐次坐标转换为可用于UV采样的标准化坐标。边缘距离计算abs(screenUV - 0.5) * 2将中心点(0.5,0.5)映射为(0,0)屏幕四边映射为1。取max得到到最近边的距离。smoothstep函数这是实现平滑过渡的关键。它返回一个在第一个阈值和第二个阈值之间平滑插值的0-1值。我们用1.0 - smoothstep(...)来得到一个从1内部平滑过渡到0外部的因子。因子合并将边缘因子和距离因子相乘这是最常用的合并方式意味着任何一个因子为0都会导致完全透明。关于clip注释掉的clip指令是一种更极端的优化它会在硬件层面直接丢弃透明度极低的片元避免后续的混合计算。但对于透明物体这会阻止它写入深度可能影响后续物体的渲染顺序导致穿插错误。除非你非常清楚渲染顺序且需要极致性能否则不建议使用。5. 性能考量与实战优化技巧在GPU上做每像素的剔除计算显然比CPU的包围盒剔除开销大。因此这个技术必须用得其所并加以优化。5.1 性能开销分析CPU开销动态计算和设置包围盒、使用MaterialPropertyBlock每帧都有少量开销。对于大量物体需考虑按距离或重要性进行分帧更新。GPU开销片元着色器增加了距离计算和条件判断。对于原本就简单的Unlit Shader增加的开销不大。但对于复杂的PBR Shader需注意ALU算术逻辑单元指令数的增加。最关键的优化点是减少使用此技术的像素数量。控制范围确保_FadeStart和_FadeEnd定义的淡出区域尽可能窄。通常只需屏幕边缘几个像素的过渡。使用LOD对于远距离的物体使用更简单的Shader变体或直接使用标准剔除。基于视口的裁剪对于非常大的物体可以将其拆分成多个子网格只有靠近屏幕边缘的部分使用此Shader。5.2 实战优化技巧包围盒扩展的智能策略不要每帧无脑扩展在DynamicBoundsController中增加一个检查只有当物体的包围盒即将离开视锥体时例如距离边界小于某个阈值才进行扩展。物体完全在屏幕内时使用其原始包围盒。异步计算对于大量动态物体可以将包围盒计算放在Jobs系统中或分帧进行避免单帧CPU峰值。Shader层面的优化使用step代替smoothstep如果你不需要平滑过渡只需要硬切边step函数的性能远优于smoothstep。将计算移至顶点着色器如果网格足够密集可以在顶点着色器中计算边缘因子然后通过TEXCOORD插值到片元。这能减少片元着色器的计算量但会导致边缘渐变不够精确顶点之间线性插值。这是一个经典的“质量 vs 性能”权衡。利用alpha-to-coverage对于使用MSAA多重采样抗锯齿的平台可以启用alpha-to-coverage。这样基于透明度的边缘锯齿会得到很好的平滑你甚至可以使用更粗糙的渐变而不显突兀。针对SkinnedMeshRenderer的特殊处理蒙皮网格的包围盒是动态的。直接扩展renderer.bounds可能无效。更可靠的方法是扩展SkinnedMeshRenderer.localBounds。你需要根据动画的幅度来估算一个安全的扩展值。一个取巧的方法是在动画播放中采样几个关键帧或所有帧的顶点位置计算一个能包裹所有可能位置的“最大包围盒”并将其设置为localBounds。这可以在导入模型时或运行时初始化阶段完成。6. 常见问题与深度排查指南即使按照步骤操作你也可能会遇到一些棘手的问题。下面是我在实践中总结的“踩坑”记录和解决方案。6.1 问题物体在屏幕边缘“闪烁”或突然出现可能原因1包围盒扩展不足或滞后。CPU端脚本在Update中计算包围盒而剔除可能在更早的管线阶段发生。尝试将包围盒更新逻辑放在LateUpdate中并确保扩展量足够覆盖Shader中定义的淡出区域。一个经验公式扩展量世界单位 ≈ (物体在相机空间下的深度) * tan(相机FOV/2) * (_FadeEnd- 0.5)。你可以将这个计算加入脚本。可能原因2Shader中的距离计算有误。检查smoothstep的阈值。确保_FadeEnd小于等于0.5视口坐标边界。如果_FadeEnd被设得大于0.5那么物体在完全移出屏幕前就可能被完全淡化导致“提前消失”。排查工具使用Unity的Frame Debugger或RenderDoc捕获一帧查看该物体的绘制调用是否被提交。如果没有说明剔除发生在CPU端问题在脚本。如果有但像素没画出来问题在Shader。6.2 问题透明物体排序错误出现穿插可能原因我们的Shader关闭了ZWrite。当多个使用此Shader的透明物体重叠时渲染顺序依赖于它们的Queue和与相机的距离。不写入深度会导致深度测试失效后渲染的物体可能覆盖先渲染的物体无论实际前后。解决方案严格排序确保这些物体的渲染队列如QueueTransparent一致并让Unity根据它们到相机的中心点距离进行排序。对于大物体这可能不准确。使用两个Pass第一个Pass只写入深度ColorMask 0ZWrite On第二个Pass进行透明渲染。这能保证深度正确但增加了一个Draw Call。接受瑕疵对于边缘淡出的物体穿插通常发生在边缘透明部分如果影响不大可以接受。6.3 问题在VR或分屏模式下效果异常可能原因我们之前计算的屏幕坐标是基于单个相机的。在VR双屏渲染或Split-Screen分屏时每个眼睛/视口是一个独立的相机其视口screenUV范围不是(0,0)到(1,1)。例如在双屏渲染中左眼可能渲染到纹理的左半部分其screenUV.x范围是0到0.5。解决方案Shader需要感知当前渲染的视口。在URP/HLSL中可以使用GetStereoEyeIndex()函数或UnityStereoGlobals中的变量来获取眼睛索引并相应调整边缘计算。更通用的方法是使用裁剪空间坐标进行判断。因为无论视口如何裁剪空间的NDC范围始终是[-1, 1]。将顶点着色器输出的positionCS除以w后其xy分量在[-1,1]内即在视锥内。这样计算边缘距离更稳健。6.4 MaterialPropertyBlock未生效可能原因1渲染器未正确获取。确保脚本中获取的是正确的MeshRenderer引用并且物体是激活的。可能原因2属性名不匹配。Shader中定义的属性名是_BoundsExtensionWS那么SetFloat的第一个参数字符串必须完全一致包括下划线。大小写敏感。可能原因3批处理冲突。动态合批或GPU Instancing可能会因为MaterialPropertyBlock而中断。这是正常现象。如果性能敏感需要评估使用MaterialPropertyBlock带来的Draw Call增加是否可接受。7. 高级应用与效果延伸掌握了基础实现后我们可以将这个技术玩出更多花样。7.1 实现“体积雾”或“光束”的视锥体剔除对于粒子系统形成的体积雾、光束等其包围盒很难精确界定。粗暴地扩大包围盒会影响性能。此时可以在Shader中实现基于视锥体平面距离的剔除。原理在顶点或片元着色器中将顶点变换到视图空间View Space或世界空间然后计算其到相机六个视锥体平面的符号距离。如果所有距离都为负点在平面内侧则完全可见如果至少一个距离为正且大于某个阈值则开始淡化。这比屏幕空间计算更符合3D空间的透视关系能实现更自然的“体积感”消失。// 示例在视图空间计算到左裁剪平面的距离假设平面法线朝右 float distanceToLeftPlane dot(viewPos, float3(1, 0, 0)) - leftPlaneDistance; // leftPlaneDistance需根据FOV和宽高比计算 float fadeFactor 1 - smoothstep(0, _FadeRange, distanceToLeftPlane); // 对六个平面分别计算取乘积或最小值作为最终因子这种方法计算量更大但效果更精确尤其适合非矩形的体积效果。7.2 与后处理效果结合有时我们需要的“特殊效果”本身就是后处理。例如一个全屏的扭曲效果需要采样屏幕外区域的颜色。这时依赖的是摄像机的深度纹理。步骤在URP Asset中启用Depth Texture。在后期处理Shader中你可以通过_CameraDepthTexture采样任意UV坐标甚至可以超出[0,1]范围的深度值。虽然采样屏幕外UV时深度纹理可能没有有效值或为固定值但你可以利用这一点。应用实现屏幕边缘的“黑洞”扭曲、边缘雾气基于深度差模拟雾浓度等。这里的“剔除”概念变成了对无效采样区域的颜色混合处理。7.3 用于LOD过渡标准的LOD细节层次切换是突变的。我们可以利用此技术实现平滑的LOD过渡。为每个LOD级别的模型设置一个稍大的包围盒覆盖相邻LOD的显示范围。在Shader中根据像素到LOD切换边界的距离计算一个混合权重。在过渡区域同时渲染两个LOD级别的模型并用Shader混合它们的贡献。这能完全消除LOD切换时的“ popping”现象但对性能和内存需要同时加载两个模型要求较高。最后我想分享一点个人体会图形编程很多时候就是在与管线“斗智斗勇”。视锥体剔除是引擎给我们的一个强大保护但理解其原理并学会在必要时优雅地绕过它是迈向高级图形效果的关键一步。这项技术就像一把精细的手术刀用得好能让你的场景在性能和视觉上取得完美平衡用不好则可能伤及性能。始终记住Profile性能分析是你的最佳伙伴。在实现任何此类高级效果后务必在目标平台上进行性能分析确保额外的开销在预算之内。