案例 25.6按遮罩给选中层描边进阶
目标:用案例 25.5 的遮罩,给第 8 层物体画出等宽的外描边,并可选半透明纯色填充。这是编辑器里“选中物体橙色描边”的同款做法,和第 14 章“背面法线外扩”描边相比,它不依赖模型的法线质量、线宽在屏幕上恒定,而且对任何 Shader 的物体都有效。
原理:对遮罩做一次膨胀(Dilation)——每个像素取周围半径 r 内遮罩的最大值;膨胀后的区域减去原区域,就是物体外侧宽度为 r 的一圈。然后用 Alpha 混合把这一圈(和可选的内部填充)叠到相机颜色上:
实际实现用两圈各 12 个方向近似圆形邻域(半径 r 和 r/2),双线性采样让边缘有一点抗锯齿:
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:我们不读相机颜色,只往上面混合,读写分离的问题根本不存在:
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。
[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; } }

调参与扩展:
- 开销:遮罩 Pass 只画选中的物体,很便宜;合成 Pass 每像素 25 次采样,在 1080p 上是一次不小的全屏操作。线宽很大时(超过十几像素),圆周采样会出现空隙,可以改用“先降采样再膨胀”或跳跃泛洪算法(Jump Flooding)求距离场,再按距离画线,线宽几乎不影响开销。
- 只在需要时入队:选中物体为空时,在
AddRenderPasses里直接 return,两个 Pass 都不执行。 - 多种颜色:遮罩改成 RGBA,替换材质根据
renderingLayerMask或 MaterialPropertyBlock 输出不同通道,就能一次画出“敌人红、队友绿”多组描边。