1. 项目概述:从“距离”到“轮廓”的魔法
在Unity里做实时人像抠图,听起来像是需要调用某个AI模型API或者依赖一套复杂的后处理管线。但今天要聊的这个“Zero Distance Shader”方案,走的是一条截然不同的“物理”路径。它的核心思想非常直观:利用摄像机到人物模型表面每个像素点的距离信息,来动态地区分前景(人像)和背景。简单来说,就是让离摄像机特定距离范围内(比如人物所在的区域)的像素显示为原色,而之外的像素则被“抠掉”或替换。这听起来有点像绿幕抠像的原理,但它完全在Shader层面实现,不依赖任何外部硬件或颜色键,纯粹是数学计算。
我第一次接触这个想法,是在为一个需要实时虚拟直播但预算有限的移动端项目寻找方案时。传统的基于颜色差异或深度学习的方案,要么对光照和背景要求苛刻,要么在移动设备上性能开销太大。而这个基于距离的Shader,其计算量可控,效果在特定场景下出奇地好,尤其适合固定机位、人物活动范围相对确定的场景,比如虚拟主播、在线教育老师的形象展示等。它不是一个“万能”的抠图方案,但绝对是工具箱里一把锋利且轻便的“手术刀”。
2. 核心原理拆解:距离场与阈值化
要理解Zero Distance Shader,得先抛开“图像识别”的思维,进入“空间几何”的领域。它的理论基础可以概括为构建一个以摄像机或某个空间点为原点的距离场,并通过阈值函数对距离值进行二值化或平滑处理,从而生成遮罩(Alpha通道)。
2.1 距离信息的获取
在Shader中,我们如何知道一个像素点距离摄像机有多远?最直接的信息来源就是深度纹理(Depth Texture)。在Unity的URP或内置管线中,我们可以通过_CameraDepthTexture采样获取当前像素在视图空间下的深度值。这个深度值Z,本质上就是像素点到摄像机近裁剪平面的距离(经过非线性变换)。但直接使用这个深度值进行抠图会有一个问题:它对整个场景一视同仁,无法单独区分“人物”这个物体。
因此,更常见的做法是针对特定的人物模型,在Shader中计算其模型表面某点到某个“参考点”的距离。这个“参考点”通常是:
- 摄像机位置(_WorldSpaceCameraPos):计算模型表面世界坐标点到摄像机世界坐标的距离。这种方式下,人物整体与摄像机的距离是动态变化的,适合人物在场景中移动的情况。
- 一个自定义的世界空间坐标点:比如在人物脚底或中心设置一个空物体作为“锚点”。计算表面点到这个锚点的距离。这种方式下,抠图范围是以锚点为中心的球形或圆柱形区域,适合人物固定位置旋转、做动作。
在Shader中,这个距离d的计算就是简单的向量模长:d = length(worldPos - referencePoint)。
2.2 阈值化与边缘平滑
得到距离d后,下一步就是根据它来决定这个像素的透明度(Alpha)。最粗暴的方法是设置一个距离阈值_Cutoff:
- 如果
d < _Cutoff, Alpha = 1 (完全显示,前景) - 如果
d >= _Cutoff, Alpha = 0 (完全透明,背景)
但这会产生非常生硬、锯齿状的边缘,就像用剪刀粗暴地剪开一样,毫无真实感。为了解决这个问题,我们需要引入一个平滑过渡区间。通常使用smoothstep函数来实现。
smoothstep(min, max, x)函数会在x处于[min, max]区间时,返回一个在[0, 1]之间平滑插值的结果。我们可以这样定义我们的透明度:
float halfWidth = _EdgeWidth * 0.5; // _EdgeWidth 是过渡区的总宽度 float minThreshold = _Cutoff - halfWidth; float maxThreshold = _Cutoff + halfWidth; // 当 distance 小于 minThreshold 时,alpha为1;大于 maxThreshold 时,alpha为0;在中间则平滑过渡。 float alpha = smoothstep(maxThreshold, minThreshold, distance);这里smoothstep的参数顺序是(edge0, edge1, x),当x < edge0时返回0,x > edge1时返回1。所以我们用maxThreshold和minThreshold对调,来实现距离越小、透明度越高的效果。
通过调整_Cutoff和_EdgeWidth,我们就可以控制抠图的范围和边缘的柔和度。这构成了Zero Distance Shader最核心的控制参数。
2.3 与深度测试的配合
一个关键的细节是,这个Shader通常需要关闭深度写入(ZWrite Off),但开启深度测试(ZTest LEqual)。为什么?
- 关闭深度写入:避免人物自身的像素在深度缓冲区中互相覆盖,导致内部被错误剔除。我们只希望用距离信息来控制透明度,而不是用深度来排序。
- 开启深度测试:确保人物能正确地被场景中其他位于其前方的物体遮挡。这是保证抠出的人像能正确融入新场景的关键,否则人像会永远“浮”在所有物体之上。
注意:这种处理方式意味着,人物模型本身不能有复杂的自遮挡(比如手臂放在身体前)。因为关闭深度写入后,模型内部的深度关系会混乱。对于大多数站姿或坐姿的人像,这通常不是问题。
3. Shader完整实现与逐行解析
下面,我将结合一个完整的、适用于Unity URP管线的Shader代码,来详细讲解每一部分的作用和实现细节。这个Shader被命名为ZeroDistanceHumanCutout。
3.1 属性(Properties)与变量定义
Shader "Custom/ZeroDistanceHumanCutout" { Properties { _BaseColor ("Base Color", Color) = (1,1,1,1) _BaseMap ("Base Map", 2D) = "white" {} _Cutoff ("Distance Cutoff", Range(0, 10)) = 2.0 _EdgeWidth ("Edge Smooth Width", Range(0.01, 2)) = 0.2 _ReferencePoint ("Reference Point (World Space)", Vector) = (0,0,0,0) [Toggle(USE_CAMERA_AS_REFERENCE)] _UseCamera ("Use Camera as Reference", Float) = 1 }_BaseColor和_BaseMap:定义人物的基础颜色和纹理,这是最终显示的部分。_Cutoff:核心的距离阈值。单位是世界空间单位(米)。例如,设置为2.0,意味着距离参考点2米以内的像素将被保留。_EdgeWidth:边缘平滑过渡区的宽度。值越大,从前景到背景的过渡越柔和、越宽。_ReferencePoint:一个世界空间坐标,作为距离计算的参考原点。当不使用摄像机作为参考时生效。[Toggle] _UseCamera:一个Shader变体开关。勾选时,使用摄像机位置作为参考点;不勾选时,使用_ReferencePoint属性指定的位置。这提供了灵活性,方便在固定机位和跟随摄像机两种模式间切换。
3.2 顶点着色器(Vertex Shader)
顶点着色器的任务主要是完成标准的坐标变换,并将必要的数据传递给片元着色器。
struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; float2 uv : TEXCOORD0; }; struct Varyings { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; float3 positionWS : TEXCOORD1; // 关键:将世界空间坐标传递下去 float3 normalWS : TEXCOORD2; }; Varyings vert(Attributes IN) { Varyings OUT; VertexPositionInputs vertexInput = GetVertexPositionInputs(IN.positionOS.xyz); VertexNormalInputs normalInput = GetVertexNormalInputs(IN.normalOS); OUT.positionCS = vertexInput.positionCS; OUT.uv = TRANSFORM_TEX(IN.uv, _BaseMap); OUT.positionWS = vertexInput.positionWS; // 计算并传递世界坐标 OUT.normalWS = normalInput.normalWS; return OUT; }这里最关键的一行是OUT.positionWS = vertexInput.positionWS;。我们在顶点阶段计算出每个顶点的世界空间坐标,并通过TEXCOORD1语义传递给片元着色器。片元着色器接收到的会是经过光栅化插值后的、每个像素点精确的世界坐标。这是后续计算距离的基础。
3.3 片元着色器(Fragment Shader)与核心算法
片元着色器是魔法发生的地方。
half4 frag(Varyings IN) : SV_Target { // 1. 采样基础纹理和颜色 half4 baseColor = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, IN.uv) * _BaseColor; // 2. 确定参考点(世界空间) float3 referencePointWS = _ReferencePoint.xyz; #ifdef USE_CAMERA_AS_REFERENCE referencePointWS = GetCameraPositionWS(); // URP内置函数,获取摄像机世界坐标 #endif // 3. 计算当前像素到参考点的距离 float distanceToRef = distance(IN.positionWS, referencePointWS); // 4. 基于距离计算Alpha值(核心算法) float halfWidth = _EdgeWidth * 0.5; float minThreshold = _Cutoff - halfWidth; float maxThreshold = _Cutoff + halfWidth; // 使用smoothstep创建平滑过渡 // 注意:smoothstep(edge0, edge1, x) 当 x < edge0 返回0, x > edge1 返回1。 // 我们希望距离越小,Alpha越大,所以把maxThreshold和minThreshold对调。 float alpha = smoothstep(maxThreshold, minThreshold, distanceToRef); // 5. 应用Alpha到最终颜色,并进行Alpha裁剪(可选,用于极致性能) #ifdef _ALPHATEST_ON clip(alpha - 0.001); // 如果alpha极小,直接丢弃该片元 #endif baseColor.a *= alpha; // 将计算出的alpha乘到基础颜色的alpha通道上 // 6. 简单的光照(可选,根据需求添加) // 这里使用兰伯特光照模型为例 float3 lightDir = normalize(_MainLightPosition.xyz); float NdotL = saturate(dot(IN.normalWS, lightDir)); baseColor.rgb *= NdotL * _MainLightColor.rgb; return baseColor; }逐段解析:
- 纹理采样:获取人物本来的颜色。
- 参考点选择:通过预编译指令
#ifdef,根据是否启用USE_CAMERA_AS_REFERENCE关键字,动态选择使用自定义参考点还是摄像机位置。GetCameraPositionWS()是URP提供的工具函数。 - 距离计算:使用
distance函数,计算当前像素世界坐标IN.positionWS到参考点referencePointWS的欧几里得距离。 - Alpha计算(核心):这是整个Shader的灵魂。通过
_Cutoff和_EdgeWidth计算出过渡区的上下界,再利用smoothstep函数生成一个平滑的、从1到0变化的Alpha值。这个值在距离阈值中心附近变化最快,模拟了边缘的羽化效果。 - Alpha应用与测试:将计算得到的
alpha乘到baseColor.a上。#ifdef _ALPHATEST_ON块是性能优化选项,启用后,对于Alpha值几乎为0的像素(如完全背景),GPU会直接丢弃该片元,不进行后续的混合操作,可以提升渲染效率。 - 简单光照:为了让人物抠出来后不显得扁平,可以添加基础光照。这里使用了最简单的兰伯特漫反射模型。在实际项目中,你可能需要根据需求替换为更复杂的PBR光照模型。
3.4 SubShader与渲染状态配置
SubShader { Tags { "Queue" = "Transparent" "RenderType" = "Transparent" "RenderPipeline" = "UniversalPipeline" "IgnoreProjector" = "True" } LOD 100 Pass { Name "ForwardLit" Tags { "LightMode" = "UniversalForward" } Blend SrcAlpha OneMinusSrcAlpha // 标准Alpha混合 ZWrite Off // 关闭深度写入 ZTest LEqual // 开启深度测试 Cull Back // 背面剔除 HLSLPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile _ _ALPHATEST_ON // 编译变体,支持Alpha测试 #pragma multi_compile _ USE_CAMERA_AS_REFERENCE // 编译变体,支持参考点切换 // URP核心库 #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl" // ... 属性、变量、结构体、函数声明 ... ENDHLSL } }- Tags:
"Queue" = "Transparent"将渲染队列设为透明物体,确保它在不透明物体之后渲染,能进行正确的Alpha混合。"IgnoreProjector" = "True"忽略投影器,避免干扰。 - Blend:
SrcAlpha OneMinusSrcAlpha是最常用的透明混合模式,让前景和背景根据Alpha值进行叠加。 - ZWrite Off:如前所述,关闭深度写入是关键。
- ZTest LEqual:深度测试设为小于等于,确保人物能被前方的物体正确遮挡。
- #pragma multi_compile:这两行指令告诉Unity为
_ALPHATEST_ON和USE_CAMERA_AS_REFERENCE这两个关键字编译不同的Shader变体。这样我们就可以在材质面板上通过勾选框来动态切换功能,而无需编写多个Shader文件。
4. 实战应用与参数调优指南
写好Shader只是第一步,把它用对、用好才是关键。下面结合一个典型的虚拟主播应用场景,来讲解如何设置和调优。
4.1 场景搭建与材质配置
- 导入人物模型:将你的3D人像模型(FBX或其它格式)导入Unity场景。
- 创建材质:在Project窗口右键创建 Material,Shader选择我们刚写的
Custom/ZeroDistanceHumanCutout。 - 配置材质参数:
- Base Map:拖入人物模型的贴图。
- Use Camera as Reference:通常勾选。这样抠图范围会随着摄像机移动而动态变化,适合主播在有限范围内活动。如果不勾选,则需要手动在场景中摆放一个GameObject,将其世界坐标赋值给
Reference Point,适合固定位置的展示。 - Distance Cutoff (_Cutoff):这是最主要的参数。将摄像机移动到人物正前方合适的位置(比如主播的常用位置)。在Scene视图中,选中人物材质,逐步调整
_Cutoff值。你会看到一个球形的“透明区域”从摄像机(或参考点)向外扩张。将这个值调整到刚好包裹住整个人物模型,并稍微留出一点余量(用于动作伸展)。初始值可以从1.5到3.0之间尝试。 - Edge Smooth Width (_EdgeWidth):这个参数控制边缘的柔和度。值太小(如0.05),边缘会非常锐利,可能产生锯齿。值太大(如0.5),过渡区会非常宽,导致人物边缘半透明的区域过多,显得模糊。对于大多数1080p或2K的渲染,0.15到0.25是一个不错的起点。你可以放大观察人物轮廓,调整到一个既有羽化效果又不失清晰度的值。
4.2 与后处理栈的配合
单纯抠出人像往往不够,为了更好的融入新背景,我们通常需要一些后处理来“润色”。
- 边缘抗锯齿:虽然
smoothstep提供了平滑过渡,但在运动或特定角度下,边缘仍可能出现闪烁或锯齿。可以在URP的后处理体积(Volume)中,启用抗锯齿(Anti-aliasing),推荐使用FXAA或TAA。TAA效果更好但略有延迟,FXAA更轻量。 - 颜色校正:抠出的人像与新背景可能存在色温、对比度的差异。可以使用后处理中的Color Adjustments效果,微调人物的伽马、饱和度、对比度,使其与背景更协调。
- 环境光遮蔽(AO):在人物脚底与地面接触的部分,添加一点点屏幕空间环境光遮蔽(SSAO),可以让人物看起来更像是“站”在背景上,而不是浮在空中。注意调整SSAO的强度和半径,避免过度。
- 背景虚化(景深):如果新背景是3D场景,可以使用景深效果,将焦点对准人物,让背景产生自然的虚化,进一步增强立体感和融入感。
4.3 性能考量与移动端适配
Zero Distance Shader本身计算非常轻量,主要开销是每个像素一次距离计算和一次smoothstep操作。但在移动端,仍需注意:
- Overdraw:由于我们关闭了深度写入,人物模型内部重叠的像素(如衣服褶皱)会进行多次混合计算,导致Overdraw增加。对于面数较高的模型,这可能成为性能瓶颈。
- 优化建议:尽量使用面数适中、拓扑合理的人物模型。如果模型是双面渲染的(如薄片状的头发),考虑将其拆分为单面模型或使用更简化的Shader。
- Alpha Test vs Alpha Blend:在片元着色器中,我们提到了
_ALPHATEST_ON。- Alpha Test:使用
clip()函数,对于完全透明的像素直接丢弃,不进行混合。这减少了片元着色器的写入操作和带宽占用,在大多数移动GPU上性能更好,但会产生硬边缘。 - Alpha Blend:进行标准的透明混合,能产生平滑的边缘,但性能开销相对较大。
- 选择策略:在移动端,如果边缘柔和度要求不高,可以启用
_ALPHATEST_ON,并将_EdgeWidth设得较小,以获得更好的性能。在PC端,可以放心使用Alpha Blend以获得最佳视觉效果。
- Alpha Test:使用
- Shader变体管理:我们使用了
#pragma multi_compile来生成USE_CAMERA_AS_REFERENCE等变体。在项目构建时,要确保所有用到的变体都被正确包含,否则在运行时切换功能可能会导致材质变紫(Shader丢失)。可以在Graphics Settings中查看和配置变体剥离策略。
5. 进阶技巧与常见问题排查
掌握了基础用法后,下面分享一些能显著提升效果和解决棘手问题的进阶技巧。
5.1 处理复杂轮廓与头发半透明
标准方案对大体轮廓有效,但对于头发这类需要表现发丝半透明的区域,一刀切的Alpha值会显得很假。解决方案是结合一张额外的遮罩纹理。
- 制作遮罩贴图:在建模软件或PS中,为人物的头发、薄纱等需要特殊透明处理的区域,制作一张灰度遮罩贴图(如,头发区域为灰色~白色,其他区域为黑色)。
- 修改Shader:在片元着色器中采样这张遮罩贴图,得到一个
mask值(范围0~1)。 - 混合Alpha:将距离计算出的
alpha_distance与遮罩值mask进行某种混合。最简单的是相乘:float finalAlpha = alpha_distance * mask;。这样,头发区域的透明度会同时受距离和遮罩控制,可以实现发梢更透明、发根更实的效果。 - 更精细的控制:甚至可以定义两个阈值和过渡宽度,一个用于身体,一个用于头发,然后用遮罩贴图在两者之间线性插值,实现不同部位不同的抠图强度。
5.2 解决背景颜色溢出(Color Bleeding)
当人物边缘半透明时,如果背景是纯色或高对比度区域,可能会看到背景颜色“渗入”到人物边缘,称为颜色溢出。
- 原因:Alpha混合时,前景的半透明像素与背景色叠加所致。
- 缓解方案:
- 收缩遮罩:在距离计算时,可以稍微将
_Cutoff值调小一点,或者增加_EdgeWidth中靠近前景一侧的权重(这需要修改smoothstep的参数,使其过渡曲线不对称),让人物实心区域稍微“膨胀”一点,减少边缘的半透明像素。 - 后处理边缘处理:使用一个后处理Shader,检测人物边缘(例如通过深度或法线突变),并对边缘像素的颜色进行微调,偏向于前景色,以中和背景色的影响。这属于比较高级的技巧。
- 收缩遮罩:在距离计算时,可以稍微将
5.3 动态参考点与多人物处理
当场景中有多个人物需要独立抠图时,为每个人物单独设置一个参考点GameObject会非常麻烦。我们可以通过脚本动态传递参考点位置。
- 创建C#脚本:
using UnityEngine; public class DistanceCutoutController : MonoBehaviour { public Transform referencePoint; // 拖入作为参考点的Transform public Renderer targetRenderer; // 拖入需要控制的人物Renderer private MaterialPropertyBlock _propBlock; void Start() { _propBlock = new MaterialPropertyBlock(); } void Update() { if (referencePoint != null && targetRenderer != null) { targetRenderer.GetPropertyBlock(_propBlock); _propBlock.SetVector("_ReferencePoint", referencePoint.position); targetRenderer.SetPropertyBlock(_propBlock); } } } - 配置:将脚本挂载到任意GameObject上,把参考点(比如一个空物体)和人物的Renderer组件拖拽赋值。
- Shader调整:确保材质中
Use Camera as Reference是不勾选的。这样,Shader就会使用脚本通过MaterialPropertyBlock设置的_ReferencePoint值。你可以用这个脚本控制多个人物,甚至让参考点随时间移动,实现动态变化的抠图区域。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 人物完全透明或完全不透明 | _Cutoff值设置极端,或参考点位置错误。 | 检查_Cutoff值是否合理(如0.1~10)。在Scene视图开启Gizmos显示参考点,确认其位置。 |
| 边缘锯齿严重 | _EdgeWidth值太小,或渲染分辨率过低。 | 增大_EdgeWidth(如0.2)。在URP Asset中提高渲染缩放(Render Scale)或启用抗锯齿后处理。 |
| 人物内部出现空洞 | 模型本身有洞,或开启了_ALPHATEST_ON且阈值过高。 | 检查模型完整性。禁用_ALPHATEST_ON或降低其clip阈值。 |
| 人物无法被其他物体遮挡 | Shader中深度测试(ZTest)设置错误。 | 确保Pass中ZTest设置为LEqual或Less。 |
| 移动时边缘闪烁 | 没有使用抗锯齿,或_EdgeWidth过小导致子像素级别的变化剧烈。 | 启用TAA或FXAA抗锯齿。适当增加_EdgeWidth。考虑在距离计算中增加一点点抖动(dithering)来平滑运动时的过渡。 |
| 性能开销过大(移动端) | 模型面数过高导致Overdraw严重,或使用了复杂的片元计算。 | 优化模型面数。尝试启用_ALPHATEST_ON。检查是否在片元着色器中进行了不必要的复杂计算(如多次纹理采样、循环)。 |
6. 方案对比与适用边界
没有一种抠图方案是完美的。Zero Distance Shader有其鲜明的优缺点和最佳适用场景。
优势:
- 极致性能:计算简单,几乎不增加额外的渲染负担,非常适合移动端和性能敏感的场景。
- 实时性:完全在渲染管线中完成,零延迟,适合实时交互应用。
- 无需特殊背景:不依赖绿幕、蓝幕或特定的背景颜色/图案。
- 易于理解和控制:参数物理意义明确(距离、宽度),美术和策划人员也能快速上手调整。
劣势与局限:
- 依赖三维模型:必须有一个三维的人物模型,无法直接用于二维图片或视频流。
- 对模型轮廓要求高:抠图精度直接取决于三维模型的几何精度。对于复杂的发型、飘带等,需要高精度模型或配合遮罩纹理。
- 固定空间关系:抠图范围基于空间距离。如果人物动作幅度过大,超出设定的距离阈值,部分身体可能会被错误抠除。需要根据动作范围预留足够的阈值余量。
- 无法处理遮挡:如果人物用手臂抱住一个属于背景的物体,该物体不会被抠掉,因为Shader只作用于指定的人物材质。这需要更复杂的场景分层管理。
适用场景推荐:
- 虚拟主播/视频会议:人物坐姿或站姿相对固定,活动范围有限。
- AR试衣/虚拟形象展示:用户与固定位置的虚拟形象交互。
- 游戏中的角色特写/UI展示:将3D角色从游戏场景中分离出来,展示在UI界面中。
- 教育与培训模拟:讲师或操作员的虚拟形象演示。
不适用场景警告:
- 需要从复杂动态背景中抠出人物的应用(如监控视频)。
- 人物动作幅度极大、频繁超出预设距离范围的场景。
- 对发丝级抠图精度有极高要求的影视级应用。
这个基于距离的抠图Shader,它更像是一个精巧的物理工具,而非万能的AI。它的价值在于,在特定的约束条件下(固定的空间关系、已知的三维模型),能以最小的性能代价,获得相当不错的实时抠图效果。在项目初期进行技术选型时,清晰界定需求边界,判断是否落入它的“甜蜜区”,往往能省下大量后期优化和寻找替代方案的时间。对于快速原型、移动端应用、预算有限的独立开发者来说,掌握并善用这类“小而美”的技术,常常是让项目顺利推进的关键。