资讯中心

Unity中基于Dither抖动的动态遮挡透明效果实现

📅 2026/8/4 6:09:09
Unity中基于Dither抖动的动态遮挡透明效果实现
1. 项目概述从《碧蓝幻想Relink》到你的屏幕最近在玩《碧蓝幻想Relink》的时候我特别留意到一个细节当角色被墙壁、岩石或者其他大型物体遮挡时遮挡物靠近镜头的那部分会变得半透明让你能清晰地看到后面的角色。这个效果非常自然不像粗暴的“穿模”或者直接让角色消失而是通过一种带有轻微颗粒感的渐变透明来实现的。作为一名技术美术我立刻意识到这背后大概率用到了Dither抖动技术。这种效果在3A游戏中很常见比如《艾尔登法环》里角色被树叶遮挡时树叶也会呈现类似的半透明效果。它解决了传统“遮挡剔除”或“硬切”方案带来的视觉突兀问题极大地提升了游戏的视觉沉浸感和操作反馈的清晰度。今天我们就来动手在Unity里复刻这个效果。我会带你从原理开始一步步拆解Dither抖动如何实现平滑的透明度过渡并最终实现一个完整的、可复用的Shader让你能直接应用到自己的项目里。无论你是Shader新手想了解一个酷炫效果的实现还是有一定经验的开发者想优化自己的遮挡提示系统这篇文章都会给你带来实用的代码和清晰的思路。我们不会使用任何复杂的后处理或屏幕空间技术而是聚焦于一个物体自身的、基于屏幕空间的抖动透明方案这样性能开销更小控制也更灵活。2. 核心原理为什么是Dither抖动在深入代码之前我们必须搞清楚一个核心问题为什么《碧蓝幻想Relink》这类游戏选择用Dither抖动来实现遮挡透明而不是简单的Alpha混合2.1 传统透明方案的瓶颈在实时渲染中处理透明物体是个老大难问题。最常用的方法是Alpha Blending。当一个像素被渲染时GPU会读取帧缓冲中该位置已有的颜色背景色然后根据当前片元的透明度和混合模式将新旧颜色混合。这听起来很完美但它有两个致命缺陷尤其是在处理动态遮挡时渲染顺序依赖Alpha Blending要求透明物体必须从后往前渲染。因为只有先渲染了后面的物体前面的透明物体才能正确地与它混合。在复杂的场景中动态确定每个物体的渲染顺序是极其困难的很容易出现错误的混合结果比如本该被遮挡的物体却透了出来。深度写入问题通常情况下透明Shader会关闭深度写入ZWrite Off。这是因为如果写入深度后面渲染的、真正不透明的物体就可能因为深度测试失败而被错误地丢弃导致物体“消失”。但关闭深度写入又带来了新的问题透明物体之间无法进行正确的深度排序容易出现穿插和闪烁。想象一下游戏场景角色跑向一堵墙。如果墙使用标准的Alpha Blending透明当墙变透明时它后面的角色、场景必须确保在墙之前渲染否则墙的透明效果会失效。在动态的第三人称视角下要实时、正确地维护这个顺序几乎是不可能的。2.2 Ditter抖动的破局思路Dither抖动采用了一种完全不同的思路它不进行真正的颜色混合而是利用人眼的视觉暂留和空间整合特性通过在一个像素块内交替显示“完全透明”和“完全不透明”的像素点来模拟出中间级别的透明度。其核心是一个阈值矩阵通常是4x4或8x8的 Bayer 矩阵。这个矩阵定义了屏幕上每个像素点基于其屏幕坐标的“透明度阈值”。在Shader中我们计算当前像素所需的透明度比如0.7然后将其与阈值矩阵中对应位置的阈值进行比较。如果透明度值大于阈值这个像素就按不透明渲染如果小于阈值这个像素就被直接丢弃clip显示出后面物体的颜色。举个例子假设一个4x4 Bayer矩阵数值范围是0-15。我们想要50%的透明度即0.5。我们将0.5映射到矩阵的数值范围比如0.5 * 16 8。然后对于屏幕上每一个4x4的像素块我们取对应矩阵位置的值。如果该位置矩阵值 8这个像素被丢弃透明如果 8这个像素保留不透明。从稍远的距离看这一片区域大约有一半的像素被保留一半被丢弃整体上就呈现出50%灰度的半透明视觉效果。这样做的好处是革命性的无排序问题因为每个像素要么全有要么全无被丢弃不存在颜色混合所以彻底摆脱了对渲染顺序的依赖。透明和不透明的物体可以按任意顺序渲染。可写入深度由于被丢弃的像素根本不会进入后续流程而保留的像素是完全不透明的因此我们可以安全地开启深度写入ZWrite On。这意味着这个“透明”物体能正确地遮挡住它后面的物体解决了透明物体常见的“穿帮”问题。性能友好计算非常简单只是一次纹理采样预计算好的阈值矩阵可以存储在很小的纹理里甚至直接用公式计算和一次比较比复杂的混合计算更高效。正是这些特性使得Dither抖动成为实现动态遮挡透明效果的绝佳选择。它用一种“欺骗眼睛”的方式在保持深度正确性的前提下实现了平滑的透明度过渡。2.3 《碧蓝幻想Relink》中的效果分析基于上述原理我们可以推测《碧蓝幻想Relink》中遮挡透明效果的工作流程检测遮挡游戏引擎会实时检测摄像机与玩家角色之间的射线。如果射线击中了特定的可遮挡物体如墙壁、石柱则标记该物体需要进入“透明”状态。计算透明度根据遮挡物的厚度、角色被遮挡的程度、或者简单地根据摄像机到遮挡物的距离计算出一个从0完全透明到1完全不透明的渐变透明度值。通常离摄像机越近的部分越透明。应用Dither Shader将这个动态计算出的透明度值传递给遮挡物材质的Dither Shader。Shader根据每个像素的屏幕坐标和这个全局透明度进行阈值比较决定像素的去留。视觉表现最终我们看到的就是遮挡物靠近镜头部分呈现出细腻的、带有颗粒感的镂空效果角色清晰可见。当角色移动出遮挡范围后透明度值归零物体恢复原状。3. Shader完整实现与代码逐行解析理论讲透了现在我们来动手实现。下面是一个完整的Unity Shader代码它包含了所有必要的属性、SubShader和Pass。我会逐段进行详细解释。Shader “Custom/DitherTransparency” { Properties { _MainTex (“Albedo (RGB)”, 2D) “white” {} _Color (“Color”, Color) (1,1,1,1) _Glossiness (“Smoothness”, Range(0,1)) 0.5 _Metallic (“Metallic”, Range(0,1)) 0.0 // 控制透明度的核心参数 _Transparency (“Transparency”, Range(0, 1)) 0.5 // Dither强度用于微调效果 _DitherScale (“Dither Scale”, Range(0.1, 5)) 1.0 // 抖动图案纹理也可以使用程序化矩阵 _DitherPattern (“Dither Pattern”, 2D) “white” {} } SubShader { Tags { “RenderType”“Opaque” “Queue”“Geometry” } // 关键使用AlphaTest渲染队列并开启深度写入 LOD 200 CGPROGRAM // 表面着色器但使用了自定义的光照模型和顶点/片元修改器 #pragma surface surf Standard fullforwardshadows addshadow vertex:vert // 确保支持阴影投射 #pragma target 3.0 sampler2D _MainTex; sampler2D _DitherPattern; fixed4 _Color; half _Glossiness; half _Metallic; float _Transparency; float _DitherScale; struct Input { float2 uv_MainTex; // 从顶点着色器传递到片元着色器的屏幕空间位置 float4 screenPos; }; // 顶点着色器计算屏幕空间位置 void vert (inout appdata_full v, out Input o) { UNITY_INITIALIZE_OUTPUT(Input, o); o.uv_MainTex v.texcoord; // 计算裁剪空间位置 float4 clipPos UnityObjectToClipPos(v.vertex); // 转换为屏幕空间坐标范围0-1 o.screenPos ComputeScreenPos(clipPos); } // 核心的Dither比较函数 float DitherThreshold(float2 screenPosition, float alpha) { // 方法1使用4x4 Bayer矩阵程序化无需纹理 // 定义4x4 Bayer阈值矩阵 const float4x4 thresholdMatrix { 1, 9, 3, 11, 13, 5, 15, 7, 4, 12, 2, 10, 16, 8, 14, 6 }; // 将矩阵值归一化到0-1范围 thresholdMatrix / 17.0; // 根据屏幕位置获取矩阵中的阈值 float2 pixelPos floor(screenPosition * _ScreenParams.xy * _DitherScale); float threshold thresholdMatrix[pixelPos.x % 4][pixelPos.y % 4]; // 如果当前像素的透明度小于阈值则返回一个负值用于clip return alpha - threshold; } // 表面着色器主函数 void surf (Input IN, inout SurfaceOutputStandard o) { fixed4 c tex2D (_MainTex, IN.uv_MainTex) * _Color; o.Albedo c.rgb; o.Metallic _Metallic; o.Smoothness _Glossiness; o.Alpha c.a; // --- Dither透明度测试 --- // 获取归一化的屏幕空间坐标 float2 screenUV IN.screenPos.xy / IN.screenPos.w; // 调用Dither函数 float ditherTest DitherThreshold(screenUV, 1.0 - _Transparency); // 关键操作根据比较结果裁剪像素 clip(ditherTest); } ENDCG } FallBack “Diffuse” }3.1 关键代码段深度解析1. Tags { “RenderType”“Opaque” “Queue”“Geometry” }这行代码非常重要。我们将渲染队列设置为Geometry这是不透明物体的默认队列。这意味着这个Shader物体将和所有不透明物体一起在透明物体之前被渲染。同时我们声明它的渲染类型是Opaque。这看起来矛盾但正是Dither抖动的精髓在渲染管线看来它是一个会进行像素裁剪的不透明物体因此它可以正确写入深度缓冲遮挡后面的物体。如果设置为Transparent队列深度写入会被禁用效果就错了。2. #pragma surface surf Standard fullforwardshadows addshadow vertex:vertfullforwardshadows和addshadow这两个指令确保了这个材质能够投射并接收正确的阴影。这是实现真实感的关键一个“透明”的墙壁如果不投射阴影会显得非常假。addshadow会为这个Surface Shader生成一个额外的Shadow Caster Pass确保它能在其他物体上产生阴影。vertex:vert指定了自定义的顶点着色器函数vert用于计算屏幕空间坐标。3. void vert 函数这个函数的核心是计算screenPos。UnityObjectToClipPos将顶点从模型空间变换到裁剪空间。ComputeScreenPos是一个Unity内置函数它将裁剪空间坐标转换为齐次屏幕空间坐标范围在0到w之间。在片元着色器中我们需要用xy分量除以w分量来得到真正的归一化屏幕坐标(0,1)。这是为了处理透视投影的校正。4. float DitherThreshold 函数这是算法的核心。thresholdMatrix这是一个4x4的Bayer矩阵。数值经过精心排列能产生在视觉上均匀分布的阈值点。矩阵值被归一化到0-1之间除以17因为最大值是16加上1共17个等级。pixelPosscreenPosition是归一化的(0,1)坐标乘以_ScreenParams.xy屏幕的像素宽度和高度就得到了实际的像素坐标。再乘以_DitherScale可以用来缩放抖动图案的密度比如设为0.5会让图案更稀疏颗粒感更强。floor函数取整确保我们获取的是整数像素位置。threshold使用取模运算% 4将当前像素坐标映射到4x4矩阵的对应位置取出该位置的阈值。return alpha - threshold这里传入的alpha是1.0 - _Transparency。因为我们的_Transparency参数为1时表示完全透明为0时表示不透明。而阈值比较的逻辑是当alpha不透明度大于threshold时像素保留。所以_Transparency越大alpha越小越容易被裁剪物体就越透明。5. clip(ditherTest)这是执行像素裁剪的指令。如果ditherTest的值小于0那么这个像素就会被直接丢弃不会输出任何颜色也不会写入深度但之前通过深度测试的、保留的像素会正常写入深度。GPU会直接处理下一个像素。这就是实现“镂空”效果的关键。3.2 使用纹理化Dither图案替代方案上面的例子使用了程序化的4x4矩阵。你也可以使用一张纹理来获得更复杂或更柔和的抖动图案。修改DitherThreshold函数如下float DitherThresholdTexture(float2 screenPosition, float alpha, sampler2D ditherTex, float scale) { // 计算纹理UVtiling模式设为repeat float2 ditherUV screenPosition * scale; // 采样抖动纹理假设是单通道灰度图 float threshold tex2D(ditherTex, ditherUV).r; // 返回比较结果 return alpha - threshold; }在surf函数中调用它float ditherTest DitherThresholdTexture(screenUV, 1.0 - _Transparency, _DitherPattern, _DitherScale);。使用纹理的好处是可以自由设计抖动图案比如使用蓝噪声纹理可以得到视觉上更舒适、更少规则图案感的抖动效果。程序化矩阵的优势则是零采样开销性能极佳。4. 在Unity中的完整配置与实战应用有了Shader我们还需要在Unity编辑器中正确设置并将其集成到游戏逻辑中才能复现《碧蓝幻想Relink》那样的动态效果。4.1 材质与场景配置创建材质在Project窗口右键 - Create - Material命名为“DitherWall”。指定Shader将材质的Shader选择为我们刚刚创建的Custom/DitherTransparency。配置参数_MainTex贴上一张墙壁的Albedo贴图。_Transparency可以先设置为0.5在场景中拖动滑块立即看到墙壁变为颗粒状半透明的效果。_DitherScale默认为1。调大如2会使抖动颗粒变大图案更明显调小如0.5会使颗粒更细腻从远处看透明度过渡更平滑。应用到物体将材质拖拽到场景中的一个Cube或Plane上作为测试用的墙壁。灯光与阴影确保场景中有方向光并且该物体的Cast Shadows和Receive Shadows属性都开启。你应该能看到这个“半透明”的墙壁在地面上投下了实心的阴影这正是我们想要的效果。4.2 实现动态遮挡检测与透明度控制静态的透明度没什么用我们需要脚本根据摄像机与玩家的位置关系动态计算并设置_Transparency的值。创建一个C#脚本DitherOcclusionController.cs挂载到需要实现遮挡透明的物体上比如那面墙。using UnityEngine; [RequireComponent(typeof(Renderer))] public class DitherOcclusionController : MonoBehaviour { public Transform target; // 需要被看到的角色或目标 public Camera mainCamera; public float fadeStartDistance 2.0f; // 开始变透明的距离 public float fadeEndDistance 0.5f; // 完全透明的距离 public float fadeSpeed 5.0f; // 透明度变化速度 private Renderer _renderer; private MaterialPropertyBlock _propBlock; private float _currentTransparency 0f; void Start() { _renderer GetComponentRenderer(); _propBlock new MaterialPropertyBlock(); if (mainCamera null) mainCamera Camera.main; } void Update() { if (target null || mainCamera null) return; // 1. 计算摄像机到目标的向量 Vector3 camToTarget target.position - mainCamera.transform.position; float distanceToTarget camToTarget.magnitude; // 2. 发射射线检测当前物体是否在中间 Ray ray new Ray(mainCamera.transform.position, camToTarget.normalized); RaycastHit hit; float desiredTransparency 0f; if (Physics.Raycast(ray, out hit, distanceToTarget)) { // 如果射线击中了当前物体 if (hit.collider.gameObject this.gameObject) { // 计算击中点与摄像机的距离 float hitDistance hit.distance; // 根据距离在fadeStartDistance和fadeEndDistance之间插值得到目标透明度 desiredTransparency Mathf.InverseLerp(fadeStartDistance, fadeEndDistance, hitDistance); // 将透明度限制在0-1之间 desiredTransparency Mathf.Clamp01(desiredTransparency); } } // 3. 平滑过渡到目标透明度 _currentTransparency Mathf.Lerp(_currentTransparency, desiredTransparency, Time.deltaTime * fadeSpeed); // 4. 使用MaterialPropertyBlock高效更新材质属性 _renderer.GetPropertyBlock(_propBlock); _propBlock.SetFloat(“_Transparency”, _currentTransparency); _renderer.SetPropertyBlock(_propBlock); } // 可选在编辑器中可视化衰减范围 void OnDrawGizmosSelected() { if (mainCamera ! null) { Gizmos.color Color.cyan; Gizmos.DrawWireSphere(mainCamera.transform.position, fadeStartDistance); Gizmos.color Color.blue; Gizmos.DrawWireSphere(mainCamera.transform.position, fadeEndDistance); } } }脚本逻辑解析射线检测每一帧从摄像机向目标角色发射一条射线。Physics.Raycast会检测这条射线上的第一个碰撞体。命中判断如果射线命中的物体正是挂载此脚本的物体自身则说明它遮挡了目标。距离计算获取命中点hit.point到摄像机hit.distance的距离。这个距离越短说明遮挡物离摄像机越近它应该越透明。透明度映射使用Mathf.InverseLerp函数将hit.distance映射到[fadeEndDistance, fadeStartDistance]区间并转换为[1, 0]的透明度值。当距离等于fadeEndDistance时透明度为1完全透明等于fadeStartDistance时透明度为0不透明。这样就在物体表面创建了一个从近到远的透明度梯度。平滑插值使用Mathf.Lerp让当前透明度平滑地向目标透明度过渡避免突兀的跳变。属性更新使用MaterialPropertyBlock来修改材质属性。这是最佳实践因为它不会创建新的材质实例避免内存泄漏并且性能优于直接修改material.property。配置脚本将脚本挂到墙壁物体上。将场景中的玩家角色Transform拖拽到脚本的Target字段。调整Fade Start Distance和Fade End Distance。例如设置为3和0.5意味着当墙壁距离摄像机3米时开始变透明到0.5米时完全透明。运行游戏控制角色走到墙壁后面。你会发现墙壁靠近摄像机的部分会根据距离自动产生细腻的抖动透明效果完美地露出后面的角色。5. 进阶优化与常见问题排查实现基本功能后我们还需要关注性能、视觉效果以及可能遇到的坑。5.1 性能优化要点使用MaterialPropertyBlock如上所述这是动态修改材质参数的标准做法。永远不要直接在运行时使用renderer.material.property value这会导致材质实例化增加Draw Call和内存负担。简化检测频率对于移动缓慢或静态的场景不需要每帧都进行射线检测。可以通过协程Coroutine每0.1秒或0.2秒检测一次或者只在摄像机或目标移动了一定距离后再检测。控制检测范围不是所有物体都需要遮挡检测。只为大型的、可能遮挡角色的静态或动态物体如墙壁、柱子、大树添加DitherOcclusionController脚本。小物件可以忽略。使用Layer优化射线检测为需要检测的遮挡物和目标角色设置特定的Layer。在Physics.Raycast中使用LayerMask参数只检测这些Layer可以大幅减少不必要的物理计算。public LayerMask occlusionLayer; // ... if (Physics.Raycast(ray, out hit, distanceToTarget, occlusionLayer))5.2 视觉效果调优消除“边缘锯齿”感4x4的Bayer矩阵在近距离观察时颗粒感会比较明显。有两种改进方法使用更高阶矩阵将矩阵换成8x8甚至16x16的Bayer矩阵抖动图案会更细腻。只需修改DitherThreshold函数中的矩阵定义和取模运算% 8。使用蓝噪声纹理蓝噪声在频域分布更均匀能产生视觉上更舒适、更少规则图案感的半透明效果。将一张蓝噪声纹理赋给_DitherPattern并使用纹理采样版本的函数。透明度过渡曲线在脚本中我们使用了线性插值InverseLerp。你可以引入一个动画曲线AnimationCurve来控制距离与透明度的映射关系实现非线性的淡入淡出让视觉效果更柔和或更具戏剧性。多物体重叠处理当多个可遮挡物体叠在一起时简单的射线检测可能只会命中第一个。一种改进方案是使用Physics.RaycastAll获取所有命中的物体然后分别计算并设置它们的透明度。需要注意性能开销。5.3 常见问题与解决方案实录问题1物体边缘出现闪烁或锯齿。原因这通常是由于深度测试ZTest和深度写入ZWrite的冲突或者屏幕坐标计算精度不足导致的。排查与解决确保Shader中深度测试设置为ZTest LEqual默认深度写入为ZWrite On。我们的Shader因为是Surface Shader默认状态是正确的。在vert函数中计算屏幕坐标时确保使用了ComputeScreenPos并进行透视除法IN.screenPos.xy / IN.screenPos.w。直接使用裁剪空间坐标会导致透视错误。尝试在clip操作前对ditherTest值添加一个很小的偏移bias例如clip(ditherTest 0.001)。这有时可以缓解深度精度问题导致的边缘闪烁。问题2透明部分没有投射阴影或者阴影很奇怪。原因Surface Shader的addshadow指令生成的Shadow Caster Pass默认会使用surf函数中的clip逻辑。但如果你的阴影看起来不完整可能是因为阴影贴图的分辨率与屏幕分辨率不同导致抖动图案错位。排查与解决首先确认Shader编译无误并且物体的Cast Shadows选项打开。在surf函数中可以为阴影计算提供一个独立的、更稳定的阈值。例如在片元着色器开头判断UNITY_PASS_SHADOWCASTER在渲染阴影时使用一个固定的阈值或更简单的透明测试。一个更简单粗暴但有效的方法是在项目的Quality设置中适当提高阴影贴图的分辨率。低分辨率的阴影贴图无法捕捉细腻的抖动图案会导致阴影断裂。问题3在移动设备或VR上抖动图案过于明显看起来很“脏”。原因移动设备和VR头显的屏幕像素密度PPI通常很高但观看距离近4x4的规则图案容易被人眼察觉。解决首选方案换用蓝噪声纹理。蓝噪声没有规则的结构在高分辨率屏幕上也能呈现非常自然的颗粒感视觉质量远高于Bayer矩阵。备选方案使用时间性抖动。每一帧对屏幕UV施加一个微小的、随机的偏移或者使用一个随时间变化的噪声值来扰动阈值。这样可以将静态的空间抖动转化为动态的时间抖动在视觉上起到“平滑”的作用但会引入轻微的闪烁感需要谨慎调整强度。参数调整减小_DitherScale使抖动颗粒变得更小更密集。问题4如何让效果只在特定角度或距离生效扩展脚本逻辑在DitherOcclusionController的射线检测之前可以先进行一些预判断。角度判断计算摄像机看向目标的方向与摄像机到遮挡物中心方向的夹角。如果夹角过大说明目标不在遮挡物正后方可以忽略透明效果。Vector3 toTargetDir (target.position - mainCamera.position).normalized; Vector3 toSelfDir (this.transform.position - mainCamera.position).normalized; float angle Vector3.Angle(toTargetDir, toSelfDir); if (angle maxOcclusionAngle) return; // 超过最大角度不处理距离判断如果摄像机离遮挡物非常远即使有遮挡效果也不明显可以直接关闭以节省性能。实现这个效果的过程让我再次体会到图形学中“权衡”的艺术。Dither抖动用近乎取巧的方式巧妙地绕开了实时渲染中透明排序的经典难题用性能和深度正确的优势换来了轻微的、在多数情况下可接受的画面颗粒感。在实际项目中尤其是手游或对性能敏感的平台这种交换往往是值得的。它不仅仅是一个遮挡提示方案其思想——用二值化的决策保留/丢弃来模拟连续值——在诸多后处理效果如距离场阴影、屏幕空间反射中都有广泛应用。