GameTech/URP 渲染实战

案例 25.6按遮罩给选中层描边进阶

目标:用案例 25.5 的遮罩,给第 8 层物体画出等宽的外描边,并可选半透明纯色填充。这是编辑器里“选中物体橙色描边”的同款做法,和第 14 章“背面法线外扩”描边相比,它不依赖模型的法线质量、线宽在屏幕上恒定,而且对任何 Shader 的物体都有效。

原理:对遮罩做一次膨胀(Dilation)——每个像素取周围半径 r 内遮罩的最大值;膨胀后的区域减去原区域,就是物体外侧宽度为 r 的一圈。然后用 Alpha 混合把这一圈(和可选的内部填充)叠到相机颜色上:

outline=saturate(max|d|≤rM(p+d)−M(p))

实际实现用两圈各 12 个方向近似圆形邻域(半径 r 和 r/2),双线性采样让边缘有一点抗锯齿:

Assets/Cookbook/Ch25/LayerOutline.shader(节选:frag)
half4 Frag(Varyings input) : SV_Target
{
    UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);
    float2 uv = input.texcoord;
    half center = Mask(uv);

    // 膨胀(Dilation):在半径 = 线宽、线宽/2 的两圈圆周上各取 12 个样本,求最大值
    float2 r = _BlitTexture_TexelSize.xy * _OutlineWidth;
    half dilated = center;
    [unroll] for (int k = 0; k < 12; k++)
    {
        float a = k * (TWO_PI / 12.0);
        float2 dir = float2(cos(a), sin(a));
        dilated = max(dilated, Mask(uv + dir * r));
        dilated = max(dilated, Mask(uv + dir * r * 0.5));
    }

    // 膨胀后的区域减去原区域 = 物体外侧一圈
    half outline = saturate(dilated - center);
    half3 rgb = lerp(_FillColor.rgb, _OutlineColor.rgb, outline);
    half alpha = outline * _OutlineColor.a + center * _FillColor.a;
    return half4(rgb, alpha);
}
vec4 Frag()
{
    vec2 uv = texcoord;
    float center = Mask(uv);

    // 膨胀(Dilation):在半径 = 线宽、线宽/2 的两圈圆周上各取 12 个样本,求最大值
    vec2 r = _BlitTexture_TexelSize.xy * _OutlineWidth;
    float dilated = center;
    for (int k = 0; k < 12; k++)
    {
        float a = float(k) * (TWO_PI / 12.0);
        vec2 dir = vec2(cos(a), sin(a));
        dilated = max(dilated, Mask(uv + dir * r));
        dilated = max(dilated, Mask(uv + dir * r * 0.5));
    }

    // 膨胀后的区域减去原区域 = 物体外侧一圈
    float outline = saturate(dilated - center);
    vec3 rgb = mix(_FillColor.rgb, _OutlineColor.rgb, outline);
    float alpha = outline * _OutlineColor.a + center * _FillColor.a;
    return vec4(rgb, alpha);
}

合成 Pass 用 ConfigureTarget(相机颜色, 相机深度) 声明目标,然后用 Blitter.BlitTexture(不切换目标的那个重载)把遮罩作为 _BlitTexture 画一个全屏三角形,靠 Shader 里的 Blend SrcAlpha OneMinusSrcAlpha 叠加上去。和案例 25.1 比,这里不需要临时 RT:我们不读相机颜色,只往上面混合,读写分离的问题根本不存在:

Assets/Cookbook/Ch25/LayerOutlineFeature.cs(节选:composite-pass)
class CompositePass : ScriptableRenderPass
{
    static readonly int s_OutlineColor = Shader.PropertyToID("_OutlineColor");
    static readonly int s_FillColor = Shader.PropertyToID("_FillColor");
    static readonly int s_OutlineWidth = Shader.PropertyToID("_OutlineWidth");

    readonly Material m_Material;
    MaskPass m_MaskPass;
    RTHandle m_CameraColor, m_CameraDepth;
    int m_ShaderPass;

    public CompositePass(Material material)
    {
        m_Material = material;
        profilingSampler = new ProfilingSampler("Ch25 Outline Composite");
    }

    public void Setup(Settings s, MaskPass maskPass)
    {
        m_MaskPass = maskPass;
        m_ShaderPass = s.mode == Mode.Outline ? 0 : 1;
        m_Material.SetColor(s_OutlineColor, s.outlineColor);
        m_Material.SetColor(s_FillColor, s.fillColor);
        m_Material.SetFloat(s_OutlineWidth, s.width);
    }

    public void SetTarget(RTHandle color, RTHandle depth)
    {
        m_CameraColor = color;
        m_CameraDepth = depth;
    }

    public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData)
    {
        // 画回相机颜色。显式 ConfigureTarget,URP 会在 MaskPass 之后重新绑定相机目标
        ConfigureTarget(m_CameraColor, m_CameraDepth);
    }

    public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
    {
        var cmd = CommandBufferPool.Get();
        using (new ProfilingScope(cmd, profilingSampler))
        {
            // 不切换目标,直接在当前目标(相机颜色)上画一个全屏三角形,靠混合叠加上去
            Blitter.BlitTexture(cmd, m_MaskPass.mask, new Vector4(1, 1, 0, 0), m_Material, m_ShaderPass);
        }
        context.ExecuteCommandBuffer(cmd);
        CommandBufferPool.Release(cmd);
    }
}
// 案例 25.5 / 25.6:只给某个 Layer 的物体画遮罩,再根据遮罩画描边(对应 Ch25/LayerOutlineFeature.cs)。
// 两个 Pass:MaskPass 用 DrawRenderers 把选中层画进一张 R8 遮罩;CompositePass 读遮罩、膨胀、叠到屏幕上。
import { Material } from '../engine/shader.js';
import { RenderPassEvent, layerMask } from '../engine/features.js';

// 只画原材质里有这些 LightMode 的物体(URP 前向渲染用到的三个标签)
const SHADER_TAGS = ['UniversalForward', 'UniversalForwardOnly', 'SRPDefaultUnlit'];

export default class LayerOutlineFeature {
  constructor(engine, data, scene) {
    this.engine = engine;
    this.data = data || {};
    this.settings = {
      layerMask: 1 << 8, seeThrough: true, outlineColor: [1, 0.55, 0.1, 1], fillColor: [1, 0.55, 0.1, 0],
      width: 4, mode: 0, passEvent: RenderPassEvent.AfterRenderingTransparents, ...(this.data.settings || {}),
    };
  }

  async init() {
    const sh = (ref, def) => this.engine.shaders.get((ref && ref.shader) || def);
    this.maskMaterial = new Material(await sh(this.data.maskShader, 'Cookbook/Ch25/LayerMaskSolid'));
    this.outlineMaterial = new Material(await sh(this.data.outlineShader, 'Cookbook/Ch25/LayerOutline'));
  }

  addPasses(frame, enqueue) {
    const s = this.settings;
    // CompositePass.Setup
    // 注意:C# 的 material.SetColor 写的是 Properties 块里没声明的属性,Unity 原样上传、不做 sRGB→线性(和书中图实测一致),所以用 setVector
    this.outlineMaterial.setVector('_OutlineColor', s.outlineColor);
    this.outlineMaterial.setVector('_FillColor', s.fillColor);
    this.outlineMaterial.setFloat('_OutlineWidth', s.width);
    const shaderPass = s.mode === 0 ? 0 : 1;
    // 两个 Pass 同一个事件:入队顺序就是执行顺序
    enqueue(s.passEvent, (fr) => this.maskPass(fr), 'Ch25 Layer Mask');
    enqueue(s.passEvent, (fr) => {
      // 画回相机颜色:在相机目标上画一个全屏三角形,靠混合叠加上去
      fr.blit(this.mask, 'camera', this.outlineMaterial, shaderPass);
    }, 'Ch25 Outline Composite');
  }

  maskPass(fr) {
    const E = fr.engine, gl = fr.gl, s = this.settings;
    // 遮罩与相机同尺寸、同 MSAA(要和相机深度一起绑定,两者采样数必须一致),单通道 8 位即可
    const rt = E.rt('ch25:_Ch25LayerMask', fr.width, fr.height, { color: 'R8', depth: null, samples: fr.msaa, renderbuffers: true });
    // ConfigureTarget(mask, cameraDepth):把相机的深度缓冲挂到遮罩的 FBO 上(借用,不清除)
    gl.bindFramebuffer(gl.FRAMEBUFFER, rt.fbo);
    gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_STENCIL_ATTACHMENT, gl.RENDERBUFFER, fr.cameraTarget.depthRB);
    E.bindTarget(rt);
    // ConfigureClear(ClearFlag.Color, Color.clear):只清颜色,深度绝不能清
    gl.colorMask(true, true, true, true);
    gl.clearColor(0, 0, 0, 0);
    gl.clear(gl.COLOR_BUFFER_BIT);
    // RenderStateBlock 覆盖深度状态:透视模式 ZTest Always,否则 LEqual;不写深度
    const pass = this.maskMaterial.shader.passes[0];
    fr.drawRenderers({
      queue: 'all', lightModes: SHADER_TAGS, layerMask: layerMask(s.layerMask),
      overrideMaterial: this.maskMaterial, overridePass: 0,
      state: { ...pass.state, zwrite: 'Off', ztest: s.seeThrough ? 'Always' : 'LEqual' },
    });
    E.glc.resolve(rt, { color: true });
    this.mask = rt.texture;
  }
}

两个 Pass 都设在 AfterRenderingTransparents,入队顺序是先遮罩后合成,稳定排序保证它们按这个顺序执行。

用 Frame Debugger 验证时,会在 AfterRenderingTransparents 的位置看到 “Ch25 Layer Mask” 和 “Ch25 Outline Composite” 两个节点。点开前者,右侧的 RenderTarget 显示为 _Ch25LayerMask,绘制列表里只有第 8 层的两个物体,Shader 显示为替换材质 LayerMaskSolid;点开后者只有一次全屏的 Draw Procedural。如果遮罩节点里一个物体都没有,先检查相机的 Culling Mask 是否包含这一层(不在剔除结果里的物体 DrawRenderers 画不出来),再检查物体原来的 Shader 是否有 UniversalForward 等被过滤的 LightMode。

Assets/Cookbook/Ch25/Editor/Ch25Shots.cs(节选:shot-outline)
[BookShot("ch25-layer-outline", W = 640, H = 400, Panels = 3)]
static void LayerOutlineShot(Shot s)
{
    Stage(s, LayerA);
    s.Cube(s.Lit(new Color(0.55f, 0.5f, 0.45f), 0, 0.3f), new Vector3(-1.2f, 0.45f, -1.25f), 1f)
        .transform.localScale = new Vector3(0.9f, 0.9f, 0.08f);
    var f = s.Feature<LayerOutlineFeature>();
    f.settings.layerMask = 1 << LayerA;
    f.settings.width = 4;
    switch (s.Panel)
    {
        case 0: f.settings.seeThrough = true; break;
        case 1: f.settings.seeThrough = false; break;
        default:
            f.settings.seeThrough = true;
            f.settings.outlineColor = new Color(0.2f, 0.9f, 1f, 1f);
            f.settings.fillColor = new Color(0.2f, 0.9f, 1f, 0.35f);   // 纯色填充 + 描边
            f.settings.width = 6;
            break;
    }
}
图 25-10左:透视描边,红球被板子挡住的轮廓也画了出来;中:只描可见部分;右:青色描边 + 35% 纯色填充,线宽 6 像素

调参与扩展: