HDRP Custom Pass实现电影级物体描边:从原理到实战优化

1. 项目概述与核心价值

最近在捣鼓一个HDRP的圣诞市场场景,想给那些精致的圣诞树、礼物盒和雪人加上一层电影感十足的描边效果,就像《蜘蛛侠:平行宇宙》或者很多3A游戏里那种,角色和重要物体自带一圈发光轮廓,能瞬间从背景中“跳”出来。Unity内置的Outline方案,比如用第二个摄像机渲染纯色或者后处理描边,在HDRP管线里要么效果生硬,要么性能开销大,要么对复杂透明物体的支持不够好。折腾了一圈,最终把目光锁定在了HDRP的Custom Pass功能上,特别是利用它来操作Outline通道,这几乎是目前HDRP下实现高质量、可定制描边效果的“标准答案”。

简单来说,这个项目就是教你如何从零开始,利用HDRP的Custom Pass,亲手打造一个完全可控的、电影级的物体描边效果。它不仅仅是画个边,而是涉及到如何精准地分离出你想要描边的物体、如何计算边缘、如何控制描边的颜色、宽度、平滑度甚至动态效果。无论你是想为自己的独立游戏增加视觉亮点,还是为某个商业项目实现特定的艺术需求,掌握这套方法都能让你对HDRP的渲染控制能力提升一个档次。接下来,我会把整个实现过程、背后的原理、以及我踩过的坑和优化心得,毫无保留地拆解给你看。

2. HDRP Custom Pass与Outline通道核心原理拆解

2.1 为什么是Custom Pass?

在进入实操之前,必须得先搞明白我们手里的“工具”到底是什么。HDRP(High Definition Render Pipeline)作为Unity的高清渲染管线,其设计哲学就是高度可定制和模块化。Custom Pass正是这一哲学的核心体现之一。你可以把它理解为一个“钩子”或者一个“插槽”,允许你在HDRP渲染流程的特定阶段(比如在渲染不透明物体之后、在后处理之前)插入你自己编写的Shader代码。

传统的描边方法,比如在Forward Rendering里给物体加一个正面剔除(Cull Front)的Pass并放大模型,在HDRP的复杂光照和材质体系下会问题百出,难以与HDRP的Lit Shader完美结合,且无法处理物体之间的遮挡关系。而后处理屏幕空间描边,虽然通用,但难以区分特定物体,容易把背景里杂乱的边缘也描上,精度和可控性都欠佳。

Custom Pass的优势就在于精准与灵活

  1. 物体级控制:你可以指定哪些Layer的物体参与描边,实现“指哪打哪”。
  2. 管线深度集成:你的描边计算可以发生在HDRP管线的精确阶段,例如在深度和法线缓冲区已经准备好之后,这为我们提供了高质量的边缘信息源。
  3. 完整的Shader能力:在Custom Pass的Shader里,你可以像写普通Surface Shader或Unlit Shader一样,访问各种纹理(如_CameraDepthTexture, _CameraNormalsTexture)和全局属性,实现复杂的边缘检测算法。

2.2 Outline通道的本质与工作流

在HDRP的语境下,“Outline通道”并不是一个现成的、开关一拨就有的东西。它是一套工作流和策略,其核心思想是:利用Custom Pass,在渲染管线的后期,为选定的物体单独渲染一次轮廓信息到一张额外的纹理(即Outline Buffer),然后再与主场景图像进行合成。

这个工作流通常包含以下几个关键步骤:

  1. 目标选择:通过Layer、Renderer或自定义组件,标记出需要描边的物体。
  2. 轮廓渲染:在一个Custom Pass中,将这些被标记的物体以特定的方式(通常是纯色或基于法线/深度的边缘高亮)渲染到一张Render Texture上。此时,场景中其他物体不参与渲染。
  3. 边缘处理:对上一步得到的纯色轮廓图,进行图像处理(如扩张/腐蚀)来模拟描边宽度,或直接利用几何信息(如背面膨胀)生成轮廓。
  4. 图像合成:在另一个Custom Pass(或后处理Pass)中,将处理好的轮廓图,以“叠加”(Additive)或“屏幕”(Screen)等混合模式,合成到最终的摄像机画面上。

我们常说的“Outline通道”,指的就是第2步中用于存储轮廓信息的那个Render Texture,以及围绕它建立的一整套渲染和合成逻辑。

3. 打造电影级描边效果的完整实操流程

3.1 环境准备与项目设置

首先,确保你有一个使用HDRP的项目。如果是从头创建,在Unity Hub中选择“High Definition RP (Universal RP is also available)”模板即可。对于现有项目,需要通过Package Manager导入“High Definition RP”包,并创建HDRP资产(HDRP Asset)和默认场景设置。

关键设置检查:

  1. HDRP Asset配置:在项目设置的Graphics中,确认使用的HDRP Asset已启用Custom Pass选项。通常这是默认开启的。
  2. 摄像机设置:选中你的主摄像机,在Inspector面板找到“Rendering”部分,确保“Custom Pass”选项是启用的。你需要在这里挂载我们即将创建的Custom Pass Volume。
  3. Layer准备:在Tags & Layers设置中,新建一个Layer,命名为“Outline”或类似名称。我们将把需要描边的物体分配到这个Layer上。

3.2 创建Custom Pass Volume与Shader

Custom Pass需要通过一个Custom Pass Volume组件来管理。这类似于HDRP的后处理Volume系统。

  1. 在场景中创建一个空物体,命名为“Outline Custom Pass Volume”。
  2. 为其添加Custom Pass Volume组件。
  3. 将该Volume的“Mode”设置为“Camera”,并将“Target Camera”拖拽指定为你的主摄像机。这样它的效果就只作用于这个摄像机。
  4. 在“Custom Passes”列表下方,点击“Add Pass”按钮,选择“Fullscreen Custom Pass”。我们第一个Pass先用全屏Pass来清除和准备我们的Outline缓冲区。

接下来是重头戏:编写Shader。

  1. 在Project窗口中右键,Create > Shader > HDRP > Custom Pass。将其命名为“OutlineCustomPass”。
  2. 双击在Shader编辑器中打开(推荐使用Visual Studio或Rider)。

这个模板Shader已经为我们搭建好了HDRP Custom Pass的基本结构。我们需要修改的主要是FullScreenPass函数。

3.3 核心Shader代码解析:深度-法线边缘检测

电影级描边的质量,很大程度上取决于边缘检测的精度。基于模型膨胀的方法在复杂网格上容易变形,而基于深度和法线缓冲区的图像后处理方法则更加稳健和通用。这里我们实现一个经典的深度-法线边缘检测算法。

首先,在Shader的Properties块和HLSLINCLUDE部分,定义我们需要的参数和纹理采样器。

Shader "Hidden/Custom/Outline" { Properties { _OutlineColor("Outline Color", Color) = (0, 1, 0, 1) _OutlineWidth("Outline Width", Range(0, 10)) = 2 _DepthThreshold("Depth Threshold", Range(0, 1)) = 0.01 _NormalThreshold("Normal Threshold", Range(0, 1)) = 0.5 } HLSLINCLUDE #include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl" #include "Packages/com.unity.render-pipelines.high-definition/Runtime/ShaderLibrary/ShaderVariables.hlsl" TEXTURE2D_X(_CameraDepthTexture); TEXTURE2D_X(_CameraNormalsTexture); SAMPLER(sampler_CameraDepthTexture); SAMPLER(sampler_CameraNormalsTexture); float4 _OutlineColor; float _OutlineWidth; float _DepthThreshold; float _NormalThreshold; // 深度值解码(从深度纹理中获取线性深度) float GetLinearDepth(float2 uv) { float depth = LOAD_TEXTURE2D_X(_CameraDepthTexture, uv * _ScreenSize.xy).r; return LinearEyeDepth(depth, _ZBufferParams); } // 法线值解码 float3 GetNormal(float2 uv) { float4 normalEncoded = LOAD_TEXTURE2D_X(_CameraNormalsTexture, uv * _ScreenSize.xy); return normalEncoded.xyz * 2.0 - 1.0; } ENDHLSL // ... SubShader和Pass定义 }

核心的边缘检测函数CalculateOutline如下。它的原理是:对于当前像素,采样其上下左右四个偏移位置的深度和法线值,与中心点进行比较。如果深度差或法线方向差超过阈值,就认为该像素位于边缘。

float CalculateOutline(float2 uv, float2 texelSize) { float centerDepth = GetLinearDepth(uv); float3 centerNormal = GetNormal(uv); // 定义采样偏移,_OutlineWidth控制采样距离,模拟描边粗细 float2 offsets[4] = {float2(1, 0), float2(-1, 0), float2(0, 1), float2(0, -1)}; float edge = 0.0; for (int i = 0; i < 4; i++) { float2 sampleUV = uv + offsets[i] * texelSize * _OutlineWidth; float sampleDepth = GetLinearDepth(sampleUV); float3 sampleNormal = GetNormal(sampleUV); // 深度边缘检测 float depthDiff = abs(centerDepth - sampleDepth); if (depthDiff > _DepthThreshold * centerDepth) // 使用相对阈值,适应远近物体 { edge = 1.0; break; } // 法线边缘检测 float normalDiff = dot(centerNormal, sampleNormal); if (normalDiff < _NormalThreshold) // 法线点积越小,夹角越大,越是边缘 { edge = 1.0; break; } } return edge; }

最后,在FullScreenPass函数中调用它:

float4 FullScreenPass(Varyings varyings) : SV_Target { UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(varyings); float depth = LoadCameraDepth(varyings.positionCS.xy); PositionInputs posInput = GetPositionInput(varyings.positionCS.xy, _ScreenSize.zw, depth, UNITY_MATRIX_I_VP, UNITY_MATRIX_V); float2 uv = posInput.positionNDC.xy; float2 texelSize = 1.0 / _ScreenSize.xy; float outline = CalculateOutline(uv, texelSize); return float4(_OutlineColor.rgb * outline, outline); }

这个Pass的输出,白色(或你设定的颜色)代表边缘,黑色代表非边缘,形成一张轮廓遮罩图。

3.4 配置Custom Pass实现物体过滤与合成

仅有全屏边缘检测还不够,它会把所有物体的边缘都描出来。我们需要限制只对特定物体进行描边。这需要用到DrawRenderers Custom Pass

  1. 回到“Outline Custom Pass Volume”对象。
  2. 在Custom Passes列表中,在刚才的“Fullscreen Custom Pass”之前,添加一个“DrawRenderers Custom Pass”。顺序很重要,我们需要先画出轮廓,再进行全屏处理。
  3. 配置这个DrawRenderers Pass:
    • Name: “Outline Mask Renderers”
    • Target Color Buffer: 选择“Custom”。这是关键!这意味着我们会把物体渲染到一个自定义的缓冲区,而不是主颜色缓冲区。
    • 在“Renderers Filter”下,将“Layer Mask”设置为之前创建的“Outline”层。
    • Override Material: 这里需要一个新的材质。创建一个新的材质,使用一个极其简单的Unlit Shader,将其颜色设置为白色。将这个材质拖拽到这里。这个Pass的目的就是用纯白色渲染所有“Outline”层的物体到自定义缓冲区,生成一个最原始的物体遮罩。
    • Clear Flags: 设置为“Color”,确保每一帧开始前这个自定义缓冲区被清除。

现在,我们有了两个Pass:第一个Pass(DrawRenderers)将轮廓物体以白色渲染到自定义缓冲区(我们称之为_MaskBuffer)。第二个Pass(Fullscreen)读取全屏的深度和法线,但只对_MaskBuffer中为白色的区域(即轮廓物体)进行边缘检测计算。这就需要修改我们的Fullscreen Shader,让它能采样到这个_MaskBuffer。

在Shader中声明并采样这个自定义缓冲区:

TEXTURE2D_X(_CustomBuffer); SAMPLER(sampler_CustomBuffer);

CalculateOutline函数开始或FullScreenPass中,先采样遮罩:

float mask = LOAD_TEXTURE2D_X(_CustomBuffer, uv * _ScreenSize.xy).r; if (mask < 0.1) // 如果不是轮廓物体,直接返回黑色 { return 0.0; } // ... 否则进行正常的边缘检测计算

最后,我们需要第三个Pass将描边合成到最终画面。在Volume中再添加一个“Fullscreen Custom Pass”,放在最后。这个Pass的Shader非常简单,就是采样第二个Pass输出的轮廓图(假设它输出到了_CameraColorTexture),然后以“叠加”方式加到主画面上。

float4 compositeColor = LOAD_TEXTURE2D_X(_CameraColorTexture, uv * _ScreenSize.xy); float4 outlineColor = // ... 采样第二个Pass生成的轮廓纹理; // 简单的叠加混合 return compositeColor + outlineColor * outlineColor.a;

在实际操作中,HDRP的Custom Pass系统会自动管理这些中间纹理。你需要通过“Injection Point”来控制每个Pass的执行顺序(例如在“After Post-Process”合成),并通过“Target Color Buffer”和“Target Depth Buffer”的绑定来传递数据。

3.5 参数调节与视觉打磨

实现基础功能后,调节参数以达到“电影级”效果至关重要:

  1. _OutlineColor (描边颜色):不要只用纯白或纯黑。尝试使用与物体主色调互补的颜色,或者场景氛围光的主色调。对于圣诞场景,给金色铃铛加上暖白色的柔光描边,给深绿色圣诞树加上淡金色的描边,会非常出彩。
  2. _OutlineWidth (描边宽度):此参数控制边缘检测的采样距离。值太大会导致描边粗糙、溢出;太小则描边不明显。建议根据物体在屏幕上的大小动态调整,或者为近处物体设置更宽的描边以增强景深感。
  3. _DepthThreshold (深度阈值):控制多大深度差被视为边缘。对于复杂的、前后重叠的物体(如一堆礼物盒),需要调高此值以避免内部产生过多杂边。对于光滑曲面,则需要调低以捕捉细微的深度变化。
  4. _NormalThreshold (法线阈值):控制法线夹角多大被视为边缘。这是捕捉物体轮廓(如立方体边缘)的关键参数。调低它会让你得到更“硬”的几何轮廓;调高则会得到更平滑的过渡,适合有机体。

实操心得:单独调整深度和法线阈值往往能解决大部分问题。如果物体内部出现了不应有的“龟裂”状描边,通常是法线阈值太低,将模型接缝或平滑组边界误判为边缘,适当调高即可。如果描边在物体与背景交界处断裂,可能是深度阈值太高,未能检测到前景与背景的深度落差,适当调低。

4. 性能优化与高级技巧

4.1 性能瓶颈分析与优化

Custom Pass虽然强大,但滥用会影响性能。主要开销在于:

  • 额外的Draw Call:DrawRenderers Pass会为轮廓物体增加一次渲染。
  • 全屏Shader处理:边缘检测和合成Pass是像素着色器密集型操作。

优化策略:

  1. 严格控制Layer:只将最关键、最需要突出显示的物体(如主角、可交互物品、BOSS)放入“Outline”层。避免给背景或大量小物件添加描边。
  2. 降低采样精度:在全屏Pass的Shader中,可以考虑以半分辨率(uv * 0.5)对深度/法线纹理进行采样和计算,然后再上采样回全分辨率。这对描边效果的影响很小,但能显著降低像素着色器的计算量。
  3. 使用Stencil Buffer进行优化:这是更高级的优化。在第一个DrawRenderers Pass中,不仅输出颜色到自定义缓冲区,还可以向Stencil Buffer写入一个特定值(如1)。然后在全屏边缘检测Pass中,设置Stencil Test,只对Stencil值为1的像素进行处理。这样可以完全避免对非轮廓物体区域的任何像素计算,效率最高。这需要在Shader中编写Stencil相关代码,并在Custom Pass Volume中配置相应的Stencil状态。
  4. 分帧处理:对于非关键或移动缓慢的物体,可以考虑每2帧或3帧更新一次其描边,通过时间插值来平滑变化,适用于性能极度紧张的场景。

4.2 实现动态与交互式描边

静态描边只是开始,动态描边更能增强表现力。

  1. 脉动描边:在Shader中,让_OutlineWidth_OutlineColor的强度随时间正弦变化。这非常适合用于提示可交互物品、任务目标或角色技能就绪状态。
    float pulse = sin(_Time.y * _PulseSpeed) * 0.5 + 0.5; // 生成0-1的脉动值 float dynamicWidth = _OutlineWidth * (1.0 + pulse * _PulseStrength);
  2. 击中高亮:当角色被击中时,瞬间将描边颜色变为红色并提高亮度,然后快速衰减回原色。这可以通过脚本修改Custom Pass Volume中暴露的Shader参数(如_OutlineColor)来实现。
  3. 基于距离的描边:在Shader中,根据像素的深度(即距离摄像机的远近)来调整描边宽度或颜色强度,模拟景深效果,让近处物体描边更实,远处更虚。
    float depth = GetLinearDepth(uv); float distanceFade = saturate(1.0 - depth / _MaxFadeDistance); // 计算距离衰减 float finalWidth = _OutlineWidth * distanceFade;

4.3 与HDRP圣诞场景等艺术资产结合

回到我们最初的热词“unity modular stylized christmas market bundle hdrp圣诞场景”。对于这类风格化场景,描边效果可以玩出更多花样:

  • 风格化强化:对于Low Poly或手绘风格场景,可以使用更“硬”、颜色更鲜艳的描边(提高_NormalThreshold,使用饱和色),来强化其卡通渲染(Cel-shading)的感觉。
  • 雪景适配:场景中可能有大量积雪。如果给雪地也加上描边会显得很乱。此时可以利用Shader中的深度阈值,设置一个上限,让深度差过大的地方(如物体嵌入雪地的部分)不产生描边,或者让描边颜色与雪地颜色接近(淡蓝色),使其更自然地融入环境。
  • 灯光交互:让描边颜色受到场景环境光或主要点光源颜色的影响。例如,在圣诞市场温暖的灯笼光下,描边可以偏橙黄色;在冰冷的月光下,描边可以偏蓝白色。这需要在Shader中采样环境光探针或主要光源颜色进行混合。

5. 常见问题排查与调试实录

在实际操作中,你几乎一定会遇到下面这些问题。这里是我的排查记录和解决方案。

问题现象可能原因排查步骤与解决方案
完全看不到描边1. Custom Pass Volume未启用或未绑定正确摄像机。
2. 物体Layer未设置为“Outline”。
3. Custom Pass执行顺序错误,合成Pass在最后。
1. 检查Volume组件的“Mode”和“Target Camera”。
2. 检查物体Layer和DrawRenderers Pass的Layer Mask。
3. 在Volume中确保Pass顺序为:DrawRenderers(生成遮罩) -> Fullscreen(边缘检测) -> Fullscreen(合成)。检查每个Pass的“Injection Point”是否合理(如边缘检测在“Before Post-Process”,合成在“After Post-Process”)。
描边闪烁或抖动1. 深度/法线纹理采样时,UV计算受TAA(时域抗锯齿)或动态分辨率影响。
2. 边缘检测算法没有处理屏幕边界。
1. 在Shader中使用GetScreenSpaceTAAJitter函数获取并补偿TAA抖动偏移。对于动态分辨率,确保使用_ScreenSize.xy_ScreenSize.zw(即1/屏幕尺寸)进行正确的UV和Texel计算。
2. 在CalculateOutline函数中,对采样UV进行钳制(clamp(sampleUV, 0, 1)),防止采样到纹理边界外。
描边内部有杂点或破碎1. 法线阈值(_NormalThreshold)设置过低,将模型表面的平滑组边界或纹理法线细节误判为边缘。
2. 深度阈值(_DepthThreshold)设置不合理,对于连续曲面内部的微小深度波动过于敏感。
1.逐步调高_NormalThreshold,直到模型表面的杂点消失,只保留主要轮廓。这是最常见有效的解决方法。
2. 将深度阈值从绝对阈值改为相对阈值,如if (depthDiff > _DepthThreshold * centerDepth),这样能根据物体远近自适应。
描边太粗或溢出到背景1._OutlineWidth值过大。
2. 边缘检测采样时,相邻像素可能采样到了背景物体。
1. 降低_OutlineWidth,通常2-5之间是安全范围。
2. 在深度检测逻辑中加入背景判断:如果中心点是物体(有遮罩),但采样点是背景(深度接近远平面或遮罩为0),则也应判定为边缘。这能更好地处理物体与背景的边界。
性能开销过大1. Outline层物体过多或面数过高。
2. 全屏Pass分辨率过高。
1. 严格管理Outline层物体,使用LOD或为复杂物体使用简化的代理网格(Proxy Mesh)进行轮廓渲染。
2. 实施“半分辨率渲染+上采样”优化,或引入Stencil Buffer优化,彻底剔除非轮廓像素的计算。
透明物体描边异常HDRP的深度/法线纹理默认可能不包含透明物体的正确信息。透明物体通常在不透明物体之后渲染。方案一:将透明物体的渲染队列改为“Geometry”或使用“Opaque”材质来生成轮廓遮罩(仅用于描边,不影响主外观)。
方案二:使用一个专门的、只渲染透明物体的Custom Pass,在其“After Post-Process”阶段进行轮廓绘制和合成,避开深度缓冲区的问题。

调试时,善用Frame Debugger和RenderDoc工具至关重要。在Frame Debugger中,你可以一步步查看每个Custom Pass的输入输出纹理,清晰看到遮罩图、边缘检测图是如何生成的,精准定位问题发生在哪个环节。

最后,这套Custom Pass Outline方案是一个强大的框架。掌握了它,你不仅能做描边,其思想可以扩展到很多其他效果:比如为特定物体施加全局的扭曲效果(热浪)、局部的颜色替换(角色中毒变色)、或者高级的遮挡高亮(X-Ray效果)。关键在于理解“通过Custom Pass分离渲染目标,再进行图像处理与合成”这一核心工作流。在圣诞市场的雪夜中,为你精心布置的圣诞树挂上那圈温暖的光晕轮廓时,你会觉得这一切的折腾都是值得的。