Unity中基于Dither抖动实现角色遮挡透明化渲染方案

📅 2026/7/22 4:54:35
Unity中基于Dither抖动实现角色遮挡透明化渲染方案
1. 项目概述当角色被遮挡时我们如何优雅地“看穿”它在开发第三人称动作游戏尤其是像《碧蓝幻想Relink》这类拥有华丽特效和复杂场景的ARPG时一个老生常谈但又极其影响体验的问题就是镜头。当你的角色背靠墙壁或者被巨大的BOSS模型、场景装饰物完全挡住时传统的解决方案要么是粗暴地让镜头直接“穿模”拉近导致画面混乱要么是让角色或遮挡物突然消失破坏沉浸感。这两种方式都算不上优雅。《碧蓝幻想Relink》给出了一种非常巧妙的视觉方案当角色被遮挡时遮挡物并不会消失而是会以一种半透明、带有细微噪点纹理的方式呈现让你既能看清后方角色的轮廓和动作又不至于完全失去遮挡物的存在感。这种效果在视觉上非常高级它没有使用简单的透明度渐变而是引入了一种名为“Dither”抖动的技术来模拟透明从而避免了透明物体排序可能带来的渲染问题。这个项目就是要我们在Unity中从零开始复现这一套“基于Dither抖动的遮挡透明化”系统。这不仅仅是复制一个效果更是深入理解现代游戏渲染中如何用有限的精度如屏幕的8位色深去表现连续视觉信息的一种经典手法。2. 核心原理拆解为什么是Dither而不是Alpha Blend在深入代码之前我们必须先搞清楚一个根本问题为什么不用传统的Alpha混合Alpha Blending来实现透明答案关乎渲染管线的效率和正确性。2.1 传统透明渲染的困境Unity中实现透明最常见的方法是使用Transparent渲染队列和Blend操作。但这会带来两个棘手问题排序问题Depth Sorting透明物体需要从后往前渲染以确保颜色混合正确。在动态的、遮挡关系复杂的场景中实时、正确地排序所有可能产生遮挡的物体如一整片森林、一堆废墟计算开销大且极易出错导致视觉瑕疵如一个本该在后面的面片穿透到了前面。性能开销每多一个需要排序和混合的透明物体对渲染管线的负担就加重一分。对于可能被任意场景物体遮挡的角色我们无法预知哪些物体会参与透明化使用传统透明方案在性能上是不可预测的。2.2 Dittering一种取巧的空间换“精度”方案Dither抖动是一种在数字图像处理中历史悠久的技-术最初用于在颜色深度有限的显示器如早期的256色显示器上通过交替放置不同颜色的像素来模拟出更多中间色调经典应用就是黑白报纸图片用黑白点的疏密来表现灰度。在现代渲染中我们利用的是类似的原理但目标变了我们利用屏幕空间像素的“开启”或“关闭”即完全显示遮挡物颜色或完全显示角色颜色通过一个预设的、有规律的阈值图案来在视觉上创造出“局部透明”的错觉。它的核心优势在于无需排序Dither操作发生在每个像素的片段着色器Fragment Shader中决策仅依赖于该像素在屏幕上的坐标和一个固定的阈值矩阵。它不关心物体在3D空间中的前后关系因此彻底避免了透明排序问题。性能稳定计算开销是固定的每个被标记为“可遮挡”的物体其Shader只需多做一次纹理查找和比较操作性能消耗可预测且低廉。风格化表现产生的半透明效果带有规则的噪点图案这本身可以成为一种视觉风格比单纯的模糊透明更具质感。2.3 方案整体工作流程我们的系统将分为几个关键步骤标记我们需要一种方法告诉引擎“这个物体如角色是应该被始终看见的‘重要目标’”。检测在渲染每个可能产生遮挡的物体如墙壁、树木时我们需要知道它当前是否遮挡了“重要目标”。决策与渲染如果发生了遮挡则在该遮挡物体的Shader中基于Dither图案让部分像素“透过去”显示后面的角色/场景否则正常渲染。在Unity中实现这一流程最经典和高效的方式是利用自定义深度纹理Custom Depth Buffer和屏幕空间遮罩。3. 实战准备构建渲染所需的基础设施理论清晰后我们开始搭建实战环境。这里假设你使用Unity的URPUniversal Render Pipeline管线因为它在移动端和PC端有更好的通用性且配置相对清晰。3.1 配置URP渲染管线资产首先确保你的项目使用的是URP。在Project窗口中找到或创建一个Universal Render Pipeline Asset。我们需要对其关键设置进行修改。开启深度纹理Depth Texture在URP Asset的Inspector面板中找到Renderer列表编辑你正在使用的Renderer如Universal Renderer Data。在其Renderer Features部分确保Opaque Texture和Depth Texture是启用的。深度纹理是我们进行遮挡检测的基石。可选创建自定义Renderer Feature为了更精细地控制渲染流程我们可以添加一个自定义的Renderer Feature。这个Feature将负责在渲染不透明物体之后将“重要目标”如角色的深度信息渲染到一张单独的纹理中。这是实现精准遮挡检测的关键一步。3.2 创建层级与Tag系统为了区分“重要目标”和“可遮挡物体”我们需要使用Unity的Layer层级系统。在Edit - Project Settings - Tags and Layers中创建两个新的Layer例如ImportantTarget用于标记玩家角色、主要NPC等必须可见的物体。DitherOccluder用于标记那些在遮挡重要目标时需要应用Dither透明效果的物体如墙壁、大型道具等。将你的主角模型所在的GameObject的Layer设置为ImportantTarget。将场景中可能产生遮挡的静态或动态物体的Layer设置为DitherOccluder。注意Layer的管理至关重要。一个物体不能同时属于两个Layer因此对于既是重要目标又可能遮挡其他重要目标的复杂情况如两个玩家角色需要更精细的设计例如使用额外的Shader变体或脚本动态控制。3.3 编写核心C#脚本管理重要目标我们需要一个脚本挂在主角或摄像机上来协调整个流程。using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; public class DitherOcclusionManager : MonoBehaviour { // 单例模式便于全局访问 public static DitherOcclusionManager Instance { get; private set; } // 存储重要目标的Transform引用 public Transform importantTarget; // 用于计算遮挡的半径可以理解为角色的大致包围球半径 public float targetRadius 0.5f; // 在Shader中使用的属性ID提前缓存以提升性能 private int _targetWorldPosID; private int _targetRadiusID; void Awake() { if (Instance ! null Instance ! this) { Destroy(this.gameObject); } else { Instance this; } _targetWorldPosID Shader.PropertyToID(_ImportantTargetWorldPos); _targetRadiusID Shader.PropertyToID(_ImportantTargetRadius); } void Update() { if (importantTarget ! null) { // 每帧将重要目标的世界坐标和半径传递给所有Shader Shader.SetGlobalVector(_targetWorldPosID, importantTarget.position); Shader.SetGlobalFloat(_targetRadiusID, targetRadius); } } }这个脚本的作用是将重要目标的信息位置、大小以全局Shader变量的形式发布出去这样场景中所有物体的Shader都能读取到这些数据并判断自己是否遮挡了目标。4. Shader实战编写Dither遮挡透明着色器这是整个项目的核心。我们将编写一个Unity Shader Graph或HLSL代码来实现效果。这里以Shader Graph为例进行说明因为它更直观但原理与代码Shader相通。4.1 创建Shader Graph并设置属性创建一个新的Shader Graph命名为DitherOcclusion。在Blackboard中创建以下Property_BaseColor(Color)遮挡物基础颜色。_BaseMap(Texture2D)遮挡物基础纹理。_DitherPattern(Texture2D)一个4x4或8x8的Bayer抖动阈值矩阵纹理。这是实现抖动的关键。_DitherScale(Float)控制抖动图案在屏幕空间的缩放影响“透明颗粒”的大小。_FadeStart和_FadeEnd(Float)基于遮挡深度的渐变范围。当遮挡深度在此区间内时应用抖动透明。4.2 构建节点网络节点逻辑是Shader的灵魂以下是关键步骤的节点实现思路步骤一获取屏幕空间信息与深度比较获取屏幕UV使用Screen Position节点模式设置为Default然后通过一个Split节点取出其.xy分量得到屏幕空间坐标(0,1)范围。采样深度纹理使用Scene Depth节点。这将获取当前渲染的像素位置所对应的场景深度值即离摄像机的距离。计算重要目标的深度我们需要将上一步C#脚本传递过来的_ImportantTargetWorldPos世界坐标转换到当前摄像机的视图空间然后获取其深度值。这可以通过Transform节点World to View和后续处理完成。更直接的方法是在C#脚本中每帧计算目标在摄像机空间下的深度并传递。这里为了简化Graph我们假设通过一个自定义节点或函数直接获取了_TargetViewDepth。深度比较将Scene Depth遮挡物深度与_TargetViewDepth目标深度进行比较。如果Scene Depth小于_TargetViewDepth说明遮挡物在目标前面可能发生了遮挡。步骤二引入抖动阈值采样缩放屏幕UV将屏幕UV乘以_DitherScale。缩放值越大屏幕上的抖动图案越小越密集。采样Dither图案使用Texture 2D Sample节点纹理设置为_DitherPatternUV使用上一步缩放后的结果。关键点这个纹理必须是单通道如R通道且包含一个Bayer矩阵的值例如4x4矩阵的值是0/16, 8/16, 2/16, 10/16...等。采样模式设置为Point无过滤以确保图案锐利。获取阈值从采样的纹理中取出一个通道如.r作为阈值范围应在0到1之间。步骤三基于深度差进行抖动决策计算深度差_TargetViewDepth - Scene Depth。这个值为正时表示目标在遮挡物后面差值越大目标“藏”得越深。计算抖动透明度因子这不是一个简单的step函数。我们希望当深度差在一个范围内_FadeStart到_FadeEnd时产生平滑的抖动透明过渡。使用Smoothstep节点Smoothstep(_FadeStart, _FadeEnd, DepthDiff)。这个函数会在_FadeStart之前返回0在_FadeEnd之后返回1在中间平滑过渡。这个结果假设叫lerpFactor表示“需要透明的程度”1代表完全透明应显示目标0代表完全不透明。抖动裁剪这是最核心的一步。比较lerpFactor和从Dither图案中采样得到的threshold。如果lerpFactor threshold那么这个像素应该“透过去”即丢弃或显示背景。如果lerpFactor threshold那么这个像素应该“保留”即显示遮挡物本身。在Shader Graph中这可以通过Step(threshold, lerpFactor)节点实现。输出为1白表示透过去0黑表示保留。步骤四合成最终输出使用遮罩将上一步得到的裁剪结果0或1作为一个遮罩。输出颜色正常情况下我们输出_BaseColor和_BaseMap的混合结果。实现穿透如何让像素“透过去”在URP的Shader Graph中最直接的方法是使用Clip节点。将lerpFactor - threshold的结果输入Clip如果值小于等于0则丢弃该像素。被丢弃的像素将不会写入颜色和深度缓冲区从而显示出后面绘制的内容即你的角色。重要提示直接Clip会带来性能开销影响Early-Z和硬边缘。另一种更柔和、性能更友好的方案是将遮罩用于颜色的Alpha混合。但这需要将Shader的Surface Type设置为Transparent并配置混合状态这又可能回到排序问题。因此对于《碧蓝幻想Relink》这种风格化效果使用Clip是常见且可接受的选择。最终连接将处理后的颜色连接到Base Color将Clip阈值连接到Alpha Clip Threshold如果你的Graph使用了Alpha Clipping。4.3 创建材质并赋值用创建好的DitherOcclusion.shader创建一个材质。将材质赋给所有Layer为DitherOccluder的物体。为材质赋值_BaseMap物体的漫反射贴图。_DitherPattern导入一张4x4的Bayer阈值图可以程序生成或从网上下载。调整_DitherScale从10开始尝试观察屏幕上的点状图案大小。调整_FadeStart和_FadeEnd例如设置为0.1和1.0。这意味着当遮挡物与目标的深度差在0.1米到1米之间时会逐渐产生抖动透明效果。深度差小于0.1米时完全显示遮挡物大于1米时完全透明即完全显示目标。5. 效果优化与高级技巧基础版本完成后效果可能显得生硬或有瑕疵。以下是提升效果质量的几个关键方向。5.1 解决边缘闪烁与噪声不稳定直接使用屏幕UV采样Dither图案当摄像机移动时图案会在物体表面“滑动”产生难看的闪烁噪声。解决方案使用世界空间或物体空间坐标。将Dither图案的采样UV从屏幕空间改为物体自身的世界坐标或局部坐标。例如Object Position或World Position节点的.xz分量除以一个缩放系数后作为UV。这样抖动图案就“附着”在物体表面随物体移动而移动避免了屏幕空间的滑动闪烁。权衡使用物体空间坐标后当物体本身移动或旋转时其表面的抖动图案也会动这可能不符合“屏幕空间透明”的直觉。你需要根据项目需求选择。对于静态场景物体世界空间是更好的选择。5.2 实现多目标与平滑过渡我们的脚本目前只支持一个目标。在实际游戏中可能有多个队友或重要NPC。扩展脚本将importantTarget改为一个ListTransform并在Shader中传递数组如_ImportantTargetPositions[4]和_ImportantTargetRadii[4]。在Shader中遍历所有目标计算到每个目标的距离取最小的深度差作为最终用于计算的depthDiff。平滑过渡当角色从完全被遮挡走到完全可见时如果_FadeStart和_FadeEnd范围设置过窄透明效果会突然出现或消失。适当加宽这个范围如从0.5到3.0并使用Smoothstep函数可以让透明度的变化更加自然平滑。5.3 性能考量与移动端适配纹理采样多采样一次Scene Depth和一次_DitherPattern纹理。在移动端确保_DitherPattern是非常小的纹理4x4或8x8并且被正确压缩和常驻在缓存中。Clip指令如前所述Clip或HLSL中的clip会禁用一些GPU的早期深度测试优化。如果性能敏感可以考虑使用透明度测试Alpha Test本质上和Clip类似但可能在某些硬件上有优化。使用抖动混合将Shader改为Transparent用抖动遮罩来混合物体颜色和背景色需要通过第二个摄像机渲染目标到纹理来实现更复杂。这能保留深度写入但引入了透明排序问题需要确保渲染顺序。Draw Call为场景中大量物体应用这个自定义Shader可能会增加Draw Call。考虑使用GPU Instancing来合批处理使用同一材质、同一Shader变体的静态物体。6. 常见问题排查与调试实录在实际操作中你几乎一定会遇到下面这些问题。6.1 问题速查表问题现象可能原因排查步骤与解决方案完全没效果1. Shader未正确编译或应用。2. 深度纹理未启用。3. C#脚本中的全局变量未传递到Shader。1. 检查材质球Shader是否选择正确Inspector是否有报错粉色。2. 在摄像机或Scene视图下拉菜单中开启Depth模式查看场景是否有深度信息。3. 在Shader Graph中使用Custom Function节点打印_ImportantTargetWorldPos的值或在C#脚本中用Debug.DrawLine绘制目标位置。效果全屏闪烁不随物体移动Dither图案采样UV使用了屏幕空间且未做抗锯齿处理。将采样UV从屏幕空间切换为世界空间或物体空间坐标。参考5.1节。透明边缘锯齿严重使用了Clip且抖动图案对比度过强。1. 尝试使用更大的Dither矩阵如8x8。2. 在抖动比较前对lerpFactor或threshold进行轻微的平滑处理如乘以一个接近1的系数。3. 考虑使用TAA时域抗锯齿后处理可以极大改善抖动带来的闪烁和锯齿。角色部分身体仍被遮挡_ImportantTargetRadius设置过小未能覆盖角色的整个包围盒。增大C#脚本中的targetRadius值。可以设置为角色碰撞体半径的1.5倍左右。更精确的做法是传递角色的包围盒信息并在Shader中进行更复杂的相交测试。移动端帧率下降明显1. Shader计算复杂。2. 使用Clip导致Overdraw增加。1. 简化Shader减少复杂计算。确保Dither纹理是小图且压缩。2. 尝试将Surface Type改为Transparent使用混合模式替代Clip并仔细管理渲染队列。对性能要求极高的移动端项目可能需要考虑更简化的方案如直接让遮挡物半透明尽管有排序问题。6.2 调试技巧可视化中间步骤在开发Shader时将中间变量可视化是至关重要的调试手段。在Shader Graph中你可以轻松地将任何向量或标量连接到Base Color临时输出以查看其分布。可视化深度差将depthDiff_TargetViewDepth - Scene Depth连接到颜色输出。你会看到距离角色越近的区域颜色越亮值越大这是一个非常直观的遮挡区域图。可视化抖动阈值将采样到的threshold直接输出为灰度图你可以清晰地看到附着在物体表面的Bayer矩阵图案。可视化裁剪结果将step(threshold, lerpFactor)的结果0或1输出为黑白色可以直接看到哪些像素会被丢弃白色哪些会保留黑色。通过这些可视化你可以精确调整_FadeStart、_FadeEnd、_DitherScale等参数直到获得理想的效果。7. 延伸思考从Dittering到更多屏幕空间技术成功实现这个效果后你不应止步于此。Dithering只是屏幕空间后处理或特效技术的冰山一角。它揭示了一种强大的思维方式利用屏幕已渲染的信息深度、颜色、法线在像素级别进行二次加工来实现各种复杂效果。屏幕空间环境光遮蔽SSAO利用深度和法线纹理模拟物体缝隙间的阴影增加场景的立体感。屏幕空间反射SSR利用颜色和深度纹理为光滑表面渲染出逼真的反射无需昂贵的平面反射或立方体贴图。全屏泛光Bloom提取高亮区域进行模糊和叠加营造梦幻的光效。色调映射Tone Mapping与颜色分级在最终输出前对屏幕像素颜色进行全局调整塑造游戏的整体视觉风格。你为Dither遮挡系统编写的深度比较、屏幕UV采样代码是理解这些更高级屏幕空间效果的绝佳起点。下次当你在游戏中看到角色潜入水中边缘的折射、武器上的寒冰雾气、或者科幻舱室内的体积光时不妨想想这背后很可能也是一套在屏幕像素上“精雕细琢”的Shader程序。这个项目的价值远不止于解决一个“卡视角”的问题。它是一次对现代实时渲染管线“延迟思考”的实践——我们不再仅仅命令GPU“画这个三角形”而是告诉它“画完之后请根据这幅画屏幕缓冲区的现有内容再施展一些魔法。” 掌握这种思维你就能创造出更多超越传统多边形渲染的视觉奇迹。