1. 先从一次"灯光翻车"说起
我在做一个半透明玻璃材质的展示场景时,遇到过一个挺典型的坑:场景里的玻璃瓶子明明设置了透明材质,投影也开了,但在地面上怎么都看不到它的阴影。一开始我以为是灯光角度问题,折腾了半天才发现,问题出在Shader的ShadowCaster Pass上——透明材质默认的ShadowCaster Pass会在Alpha测试阶段直接把透明区域剔除掉,阴影自然就没了。
等我把ShadowCaster Pass里的Alpha计算逻辑改完,玻璃瓶的阴影终于出来了,但又发现一个更尴尬的问题:阴影是出来了,却是完全不透明的实心黑影,看起来像一块砖头立在地板上。这就是很多入门Unity Shader的开发者会用到的透明阴影方案的最大局限——它只做了"形状"上的透明剔除,却没有处理"遮挡关系"上的柔和渐变。
这篇文章就是围绕这个场景展开的。我把自己在高级光照与阴影方向上的实践整理成了一份总结,重点覆盖三块:Unity的渲染路径(前向与延迟)在实际项目里的选型逻辑、多光源体系下光照计算的组织方式、以及透明阴影从基础实现到进阶优化的完整过程。适合已经会写基础Shader的人,或者那些刚被透明阴影问题卡住、想知道到底怎么解决的读者。文章里的代码都是基于Unity Built-In Render Pipeline的,但很多思路在URP里一样能套用。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 渲染路径的选型逻辑:前向和延迟到底该怎么选
2.1 两种路径的核心差异
很多人一上来就纠结"前向渲染好还是延迟渲染好",其实这个问题的答案永远取决于你的项目里有什么样的光源、物体和硬件预算。
前向渲染(Forward Rendering)的核心思路是:每个物体渲染一次,在同一个Pass里把灯光和阴影全部算完。它的优点是简单、直观、支持MSAA抗锯齿,而且在物体数量少、光源少的时候效果又快又准。缺点是光源一多,Draw Call和像素着色器的计算量会成倍上涨——每多一盏逐像素光源,场景里每个受影响物体就要多执行一次完整的Pass。
延迟渲染(Deferred Rendering)的思路则是把"光照计算"和"几何体渲染"分成两个阶段:先只输出颜色、法线、高光、深度等信息到G-Buffer,然后在一个全屏Pass里用G-Buffer里的数据统一计算所有光源光照。它的优势在哪里?就是光源数量对渲染开销的影响变得非常低——每增加一盏灯,只是全屏光照计算多一层叠加而已,跟场景物体的数量没有直接关系。它最适合的场景是:大量动态光源、大量物体并且光源数量很多。
2.2 Shader里怎么判断当前走的是哪条路径
很多人在写Shader的时候根本不看渲染路径,结果发现自己写的Shader在某些平台上颜色完全不对,就是因为忽略了路径相关的编译指令。
Unity里可以用指令在SubShader或者Pass级别编写:
hlsl复制#pragma multi_compile_fwdadd
#pragma multi_compile_fwdadd_fullshadows
这是一个很典型的组合。前者告诉Unity:这个Pass是用于附加光源的前向渲染附加Pass。后者则告诉Unity:编译所有支持实时阴影的附加光源组合。
而在Shader代码内部,可以通过判断当前光源类型来控制行为:
hlsl复制#ifdef USING_DIRECTIONAL_LIGHT
// 平行光处理逻辑
#else
// 点光源与聚光灯处理逻辑
#endif
如果你用延迟渲染,那么Shader中需要包含在光照计算阶段需要的全部输出信息,通常是下面的宏:
hlsl复制#pragma multi_compile ___ UNITY_HDR_ON
另外,延迟渲染的Shader写入非常有讲究。你需要用HLSL的Fragment函数输出到指定的RenderTarget,Unity里默认通过Deferred光照模式实现。这里有个常见的坑:如果Shader没有正确写入G-Buffer里的_CameraNormalsTexture,你在后期处理里一旦用到法线重构,物体表面就会出现错误的法线方向,导致边缘光、环境光遮蔽计算全面错乱。
我在项目里通常的做法是:把Shader分成两类——需要支持动态多光源的物体,走前向;大面积静态场景、不带复杂材质属性的物体,走延迟。这样既绕开了延迟渲染对透明物体支持不友好的老问题,又能享受它在大光源数量下的性能优势。
2.3 渲染路径选择与透明阴影的直接关系
透明阴影的问题,在不同的渲染路径下表现也完全不同。
前向渲染里,透明物体可以正常走阴影投射和阴影接收的完整流程,只是需要额外写对一些东西——尤其是ShadowCaster Pass里的Alpha逻辑。
延迟渲染里则更麻烦。因为延迟渲染的最终光照计算是基于G-Buffer做的,而透明物体本身不会写入G-Buffer的深度信息,所以延迟渲染默认根本不参与透明物体的阴影接收。如果你在延迟路径下要让透明物体接收阴影,就得额外做类似"把接收阴影的深度信息通过特殊方式覆盖到前向通道"的处理。
从性能对比的角度看:
| 特性 | 前向渲染 | 延迟渲染 |
|---|---|---|
| 多光源支持 | 较差,需要Pass增补 | 优秀,光源数几乎不影响物体Pass |
| MSAA | 支持 | 不支持(需要后处理方案) |
| 半透明物体 | 正常 | 困难,需要额外方案 |
| 阴影处理 | 常规 | G-Buffer深度限制 |
| 实现复杂度 | 低 | 略高,需要处理G-Buffer,后期重构等 |
我的经验是:如果你的项目主要跑在移动平台,而且角色、玻璃、UI粒子等特殊材质较多,老老实实走前向。延迟渲染的优势在移动端很难完全发挥出来,反而容易踩G-Buffer带宽和最终光照质量调整的坑。
3. 多光源体系下的Pass组织与光照叠加逻辑
3.1 从一盏灯到一排灯:光照累加的核心
多光源光照的关键在于理解"每个光源在渲染过程中是如何累加结果的"。前向渲染中,物体默认会接收全部逐像素光源的光照,但在Shader里,你必须自己决定哪些光源走逐像素、哪些光源走逐顶点、哪些光源直接忽略。
Unity的Splash Screen里经常提到"最多支持4个逐像素光源",这个限制的根源其实在于Pass的数量和移动平台的寄存器资源。如果你写的是自定义Shader,这个数字直接由你写的Pass决定。
一个基础的Base Pass写法是这样的:
hlsl复制Pass
{
Tags { "LightMode" = "ForwardBase" }
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma multi_compile_fwdbase
#include "UnityCG.cginc"
#include "Lighting.cginc"
#include "AutoLight.cginc"
// ... 顶点着色器与片元着色器代码
ENDCG
}
而Additional Pass则需要设置好标签:
hlsl复制Pass
{
Tags { "LightMode" = "ForwardAdd" }
Blend One One
ZWrite Off
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma multi_compile_fwdadd
#pragma multi_compile_fwdadd_fullshadows
#include "UnityCG.cginc"
#include "Lighting.cginc"
#include "AutoLight.cginc"
// ... 顶点着色器与片元着色器代码
ENDCG
}
这里有个关键点:Blend One One。这意味着当前Pass的颜色会和之前Pass已经写入的颜色直接相加。如果不设置Blend,那么这个Pass会直接覆盖掉之前的所有光照结果,看起来就像场景里只有一盏灯。
3.2 光照衰减的计算:为什么角度不同颜色就变了
光照衰减是另一个容易被忽略的细节。点光源和聚光灯的衰减计算是有本质区别的。
Unity内置的Lighting.cginc里,通过宏和光照贴图可以拿到一个衰减值。但是直接使用UNITY_LIGHT_ATTENUATION宏的话,它会把距离衰减和阴影遮挡信息合在一起。如果你想自己控制衰减范围,或者需要实现特殊的光照形状,就需要手动计算:
hlsl复制// 点光源衰减计算
float3 lightDir = _WorldSpaceLightPos0.xyz - worldPos;
float distanceSqr = dot(lightDir, lightDir);
float atten = 1.0 / (1.0 + unity_4LightAtten0.z * distanceSqr);
聚光灯的衰减则更复杂一点,除了距离还要考虑角度衰减:
hlsl复制float spotEffect = dot(normalize(lightDir), -normalize(_LightPos.xyz - worldPos));
float spotAtten = pow(saturate(spotEffect), _SpotLightParams.x);
实际项目中,我建议不要过度复杂化光照衰减公式。很多时候,一个简单的距离平方反比加一个调好的饱和系数,视觉上比Unity内置的衰减更自然,性能也更好。但前提是,你要确保你的Project Settings里没有勾选"Legacy Deferred"这种老旧光照模式,否则衰减计算会出现预期外的偏差。
3.3 多光源性能的实战控制策略
写完了Shader之后,多光源的性能问题才是真正的拦路虎。这里分享三个我在项目中实测有效的控制策略。
第一,严格控制逐像素光源数量。在我的项目里,场景中同时可见的逐像素光源一般控制在4盏以内。多出来的光源要么改成逐顶点光照,要么用Light Probe或者光照贴图烘焙来替代。
hlsl复制// 在ForwardAdd Pass中,通过UNITY_LIGHT_ATTENUATION宏判断当前光源类型
// 如果是平行光(UNITY_DIRECTIONAL_LIGHT),衰减直接取1,因为平行光没有距离衰减
#ifdef USING_DIRECTIONAL_LIGHT
float atten = 1.0;
#else
// 点光源、聚光灯走距离衰减
float3 lightDir = _WorldSpaceLightPos0.xyz - worldPosition;
float distanceSqr = dot(lightDir, lightDir);
float atten = 1.0 / (1.0 + unity_4LightAtten0.z * distanceSqr);
#endif
第二,善用Culling Mask。每个光源都可以设置Culling Mask,决定它影响哪些Layer的物体。把灯光按Layer分离,尤其当场景里有大量静态建筑或者地形时,可以大幅降低光照计算量。
第三,把动态光源和静态光照分离。场景里的大面积环境光、天光,直接用烘焙光照贴图处理,动态物体再通过Light Probe插值来采样环境光。这样可以大大减少动态光照计算的数量。
多光源相关的内容其实很庞大,但掌握了Pass的组织方式和衰减计算逻辑之后,剩下的基本都是工程优化层面的积累。
4. Unity中的阴影投射与接收机制:从Shadow Map到软阴影
4.1 Shadow Map的基本原理和Unity的封装
阴影渲染的核心机制是Shadow Map(阴影贴图)。理解Shadow Map,对后续优化透明阴影至关重要。
Shadow Map的思路是:从灯光的视角渲染一遍场景,把深度值写入一张纹理。然后在主相机渲染物体时,把物体表面的深度跟Shadow Map中对应位置的深度进行比较。如果物体表面深度比Shadow Map记录的深度更远,就说明这个点处于阴影中;否则,这个点就被照亮。
Unity在Built-In管线里把整个Shadow Map的分配、渲染和采样流程都封装好了。Shader里只需要通过采样指令拿到阴影值就可以:
hlsl复制#include "AutoLight.cginc"
// 在顶点着色器中计算阴影坐标
TRANSFER_SHADOW(o)
// 在片元着色器中采样阴影
fixed shadow = SHADOW_ATTENUATION(i)
TRANSFER_SHADOW和SHADOW_ATTENUATION是一对宏,它们在不同的路径、不同的平台下会有不同的实现。比如:在延迟渲染路径下,TRANSFER_SHADOW只需要采样屏幕空间的阴影纹理,而在前向渲染路径下,则需要把坐标变换到灯光空间。
4.2 阴影偏置(Bias)到底在解决什么问题
阴影偏置是一个在调试时特别容易让人崩溃的环节。常见的问题是:明明物体的阴影没问题,但当物体离地面特别近时,地面上出现了一堆摩尔纹一样的条纹抖动。
造成这个现象的原因,是Shadow Map的精度限制。阴影贴图里存储的是离散的深度值,当物体表面和地面之间的实际深度差距小于Shadow Map的采样精度时,比较结果就会产生"自阴影遮挡"。
解决办法就是给阴影比较加一个偏移。Unity里可以调整两个参数:
| 参数 | 作用 | 推荐调整范围 |
|---|---|---|
| Normal Bias | 沿法线方向偏移,解决表面倾斜导致的条纹 | 0.01 ~ 0.5 |
| Depth Bias | 沿深度方向偏移,解决物体与地面过于贴近的问题 | 0.01 ~ 0.1 |
但偏置不是越大越好。Depth Bias过大会导致阴影与物体分离,视觉上出现"阴影悬空"。Normal Bias过大会导致阴影沿着物体轮廓收缩,尤其是圆润物体上特别明显。
我的调试思路是:先关掉所有阴影,然后手动把灯光的Shadow Bias调小,观察条纹出现;再逐步加大,观察条纹消失并且阴影仍然贴合物体边缘的状态。这个平衡点就是当前场景下最合适的值。
4.3 软阴影的质量控制:Shadow Resolution和Filtering
软阴影的效果主要取决于Shadow Map的分辨率和采样滤波方式。Unity里可以在Quality Settings里设置阴影分辨率(Low/Medium/High/Ultra),也可以在每个光源上单独设置Shadow Resolution。
在移动平台上,我一般把主光源的Shadow Resolution设为Medium(1024x1024),辅助光用Low(512x512)。再低的画质下,阴影边缘会出现明显锯齿,但如果你用PC端或者主机端,可以考虑直接上4096分辨率的Shadow Map,配合4x PCF软阴影,效果提升会很明显。
PCF(Percentage Closer Filtering)是Unity默认的软阴影方案。它的原理是在阴影贴图上进行多次采样,然后把多个采样结果做混合,模拟出边缘半影的效果。PCF采样次数越多,软阴影越平滑,但性能消耗也越大。Unity里的"Soft Shadows"选项本质上是开启了特定模式的PCF。
5. 透明阴影:从"没有阴影"到"好看的半透明阴影"
5.1 透明物体阴影消失的根源
回到开头提到的玻璃瓶子。透明物体在Unity里看不到阴影,根源在于ShadowCaster Pass的默认行为。
ShadowCaster Pass负责把物体从光源视角渲染出深度图。对于默认的Shader,这个Pass里会使用Alpha测试:当材质的Alpha值低于某个阈值(例如_Cutoff)时,该片元会被丢弃,从而不会写入深度图。对于透明混合(Alpha Blend)材质来说,整个物体的Alpha值都可能低于这个阈值,最终导致整个物体在Shadow Map中完全不显示。
解决办法是:在自定义Shader中,为透明材质单独写一个ShadowCaster Pass,让它使用一个额外的属性(比如_ShadowAlpha)控制阴影投射时的透明度,而不是直接使用材质本身的Alpha。
下面是一个最小可用的阴影投射Pass:
hlsl复制Pass
{
Name "ShadowCaster"
Tags { "LightMode" = "ShadowCaster" }
ZWrite On
ZTest LEqual
ColorMask 0
CGPROGRAM
#pragma vertex vertShadowCaster
#pragma fragment fragShadowCaster
#pragma multi_compile_shadowcaster
#include "UnityCG.cginc"
float _ShadowAlpha;
struct v2f
{
V2F_SHADOW_CASTER;
};
v2f vertShadowCaster(appdata_base v)
{
v2f o;
TRANSFER_SHADOW_CASTER_NORMALOFFSET(o)
return o;
}
float4 fragShadowCaster(v2f i) : SV_Target
{
clip(_ShadowAlpha - 0.001);
SHADOW_CASTER_FRAGMENT(i)
}
ENDCG
}
在这个Pass里,_ShadowAlpha对应材质上用来控制阴影透明度的一个滑块属性。阴影投射时,只保留Alpha大于0的片元,这样玻璃瓶子就能在Shadow Map里留下自己的轮廓了。
5.2 从硬阴影到半透明阴影:Alpha通道参与阴影计算
轮廓出来了,但你会发现阴影依然是完全不透明的黑色。这是因为Shadow Map本身只记录了"有没有"的信息,没有记录"遮挡程度"。阴影区域的颜色是由主相机Pass里的阴影衰减值决定的,SHADOW_ATTENUATION宏返回的要么是0(完全被遮挡),要么是1(未被遮挡),中间的值只有在PCF软阴影下才会出现。
要让半透明物体的阴影呈现合适的深浅,需要在Shader中额外处理。常见做法是:在物体投影到地面的Pass中,把透明物体自身的Alpha值或者深度信息考虑进去,手动控制"阴影强度"。
举个例子,对于一个半透明玻璃瓶,你可以这样处理阴影接收:
hlsl复制// 在Fragment函数里手动叠加一个阴影强度系数
fixed4 frag(v2f i) : SV_Target
{
fixed4 col = tex2D(_MainTex, i.uv);
// 计算普通阴影
UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);
// 根据物体自身Alpha值调节阴影强度
float shadowFactor = atten * (1.0 - _ShadowIntensity);
// 最终颜色
col.rgb *= shadowFactor * _LightColor0.rgb;
return col;
}
这里的_ShadowIntensity是材质面板上的一个属性,范围是0到1。当它为0时,物体完全忽略阴影;当它为1时,物体区域阴影完全变黑。通过调整这个值,可以模拟出玻璃瓶遮挡光线的深浅程度。
这种方案比直接走ShadowCaster的Alpha测试要灵活得多,但它本质上还是"伪半透明阴影"——只通过一个强度系数人为控制阴影深浅,并不会根据物体厚度的不同产生不同的阴影浓度。
5.3 带厚度的真实透明阴影:基于深度差的方法
如果你想做更真实的透明阴影,例如一个玻璃球,中心部分的光线透过率低于边缘部分,那需要引入物体厚度信息。思路是:在ShadowCaster Pass中,不仅记录深度,还要让片元的深度差异影响阴影值。常见方案是利用物体本身前后面深度差来估算厚度,再映射到阴影强度。
这个方案在Unity里的实现思路大致是:
- 在透明物体的顶点着色器和片元着色器中,额外输出一个"背面深度"值,记录物体背面点在光源空间下的深度。
- 在当前阴影计算Pass里,用背面深度减去正面深度,得到物体在该位置的厚度。
- 根据这个厚度值来计算阴影强度:厚度越大,阴影越深。
hlsl复制// 伪代码示例,用于展示厚度计算逻辑
float frontDepth = i.shadowCoord.z / i.shadowCoord.w;
float backDepth = tex2D(_BackDepthTex, i.screenUV).r;
float thickness = saturate(backDepth - frontDepth);
float shadowStrength = saturate(1.0 - exp(-thickness * _Density));
这个方案需要处理的事情很多:要多一次相机渲染来采集背面深度、要处理屏幕空间UV的计算、还要保证跟主相机Pass的混合正确。所以在性能预算紧张的项目里,我一般不会直接用这种方案,而是优先使用"基于Alpha的伪半透明阴影",再配合贴图微调。
如果追求更逼真且不用自己写太多底层逻辑,另一个思路是使用Unity的Light Probes或者自定义的Shadow Mask来间接模拟半透明阴影,但那种方案的灵活度又比较低。
5.4 透明阴影调试的几个容易踩的坑
在透明阴影的调试过程中,有几个坑几乎每次都会遇到,我这里列出来,希望能帮你节省时间。
第一,ZWrite Off和ShadowCaster Pass的冲突。为了让透明物体在场景里呈现半透明叠加效果,很多Shader会把所有Pass的ZWrite关掉。但如果你在ShadowCaster Pass里忘记重新打开ZWrite,那么光源渲染阴影图时,透明物体根本不会写入深度,阴影投射自然失败。
第二,Phong式高光在阴影区域的表现。透明物体在阴影区域里仍然会显示高光,这很容易让场景看起来没有阴影。通常做法是将高光项也乘上阴影衰减值,但移动平台上这种做法可能会造成明显的暗部发暗。
第三,多种透明物体叠加时的顺序问题。场景里有多个透明物体时,阴影的渲染顺序会直接决定阴影的覆盖区域是否正确。如果A物体挡在B物体前面,但是渲染顺序靠后,阴影的Alpha混合可能会错误叠加,造成阴影浓度异常。
我在实际项目里一般会保证一个原则:ShadowCaster Pass必须是在一个不透明或者半透明渲染队列里的第一个Pass,且必须和主Pass保持一致的光照计算基础,否则阴影容易出现轮廓错位。
6. 把光照与阴影整合进一个完整Shader的实操笔记
6.1 一个支持多光源和透明阴影的基础Shader骨架
前面讲了那么多原理和细节,对于一个工程实践者来说,一份可以直接上手的Shader骨架比任何长篇大论都更实用。下面的Shader是我在移动端项目里用过的精简模板,它支持前向渲染、多光源叠加、透明阴影接收,你可以根据自己的材质逻辑来修改。
hlsl复制Shader "Custom/MultiLightTransparentShadow"
{
Properties
{
_MainTex ("Albedo (RGB)", 2D) = "white" {}
_Glossiness ("Smoothness", Range(0,1)) = 0.5
_Metallic ("Metallic", Range(0,1)) = 0.0
_ShadowAlpha ("Shadow Alpha", Range(0,1)) = 1.0
_ShadowIntensity ("Shadow Intensity", Range(0,1)) = 0.5
}
SubShader
{
Tags { "RenderType"="Transparent" "Queue"="Transparent" }
Pass
{
Name "ShadowCaster"
Tags { "LightMode" = "ShadowCaster" }
ZWrite On
ZTest LEqual
ColorMask 0
CGPROGRAM
#pragma vertex vertShadowCaster
#pragma fragment fragShadowCaster
#pragma multi_compile_shadowcaster
#include "UnityCG.cginc"
float _ShadowAlpha;
struct v2f
{
V2F_SHADOW_CASTER;
};
v2f vertShadowCaster(appdata_base v)
{
v2f o;
TRANSFER_SHADOW_CASTER_NORMALOFFSET(o)
return o;
}
float4 fragShadowCaster(v2f i) : SV_Target
{
clip(_ShadowAlpha - 0.001);
SHADOW_CASTER_FRAGMENT(i)
}
ENDCG
}
Pass
{
Tags { "LightMode" = "ForwardBase" }
Blend SrcAlpha OneMinusSrcAlpha
ZWrite Off
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma multi_compile_fwdbase
#include "UnityCG.cginc"
#include "Lighting.cginc"
#include "AutoLight.cginc"
sampler2D _MainTex;
float4 _MainTex_ST;
float _Glossiness;
float _Metallic;
float _ShadowIntensity;
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
float3 normal : NORMAL;
};
struct v2f
{
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
float3 worldNormal : TEXCOORD1;
float3 worldPos : TEXCOORD2;
float3 shadowCoord : TEXCOORD3;
};
v2f vert (appdata v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
TRANSFER_SHADOW(o)
return o;
}
fixed4 frag (v2f i) : SV_Target
{
fixed4 albedo = tex2D(_MainTex, i.uv);
float3 worldNormal = normalize(i.worldNormal);
float3 lightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
float ndotl = saturate(dot(worldNormal, lightDir));
fixed3 diffuse = albedo.rgb * _LightColor0.rgb * ndotl;
UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);
float shadowFactor = lerp(1.0, atten, _ShadowIntensity);
fixed3 finalColor = diffuse * shadowFactor;
return fixed4(finalColor, albedo.a);
}
ENDCG
}
Pass
{
Tags { "LightMode" = "ForwardAdd" }
Blend One One
ZWrite Off
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma multi_compile_fwdadd
#pragma multi_compile_fwdadd_fullshadows
#include "UnityCG.cginc"
#include "Lighting.cginc"
#include "AutoLight.cginc"
sampler2D _MainTex;
float4 _MainTex_ST;
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
float3 normal : NORMAL;
};
struct v2f
{
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
float3 worldNormal : TEXCOORD1;
float3 worldPos : TEXCOORD2;
float3 shadowCoord : TEXCOORD3;
};
v2f vert (appdata v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
TRANSFER_SHADOW(o)
return o;
}
fixed4 frag (v2f i) : SV_Target
{
fixed4 albedo = tex2D(_MainTex, i.uv);
float3 worldNormal = normalize(i.worldNormal);
#ifdef USING_DIRECTIONAL_LIGHT
float3 lightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
#else
float3 lightDir = normalize(_WorldSpaceLightPos0.xyz - i.worldPos);
#endif
float ndotl = saturate(dot(worldNormal, lightDir));
fixed3 diffuse = albedo.rgb * _LightColor0.rgb * ndotl;
UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);
return fixed4(diffuse * atten, 0.0);
}
ENDCG
}
}
Fallback "Diffuse"
}
这个Shader实际用下来有几个值得注意的点:
TRANSFER_SHADOW宏在顶点着色器里采样了阴影的坐标,但是如果在ForwardAdd Pass里使用,需要特别注意当前光源不是平行光的情况。因为TRANSFER_SHADOW一般在平行光下工作得最好,点光源和聚光灯下它会把光源空间坐标嵌入到阴影坐标里,能工作但更容易出现偏置问题。_ShadowIntensity这个属性直接控制了"半透明物体对阴影的敏感度"。当它为1时,阴影强度就是Shader内置的衰减值;当它为0时,物体完全不受阴影影响。这个参数在写UI或者特效材质时特别有用,因为你可能希望某个特效无论场景里有没有灯都能稳定显示不暗下来。
6.2 为什么我把Fallback设置成"Diffuse"
有一点经常被忽略:Fallback。我在上面这个Shader的Fallback里填了"Diffuse"。在Unity的Built-In管线下,Fallback的主要作用是:当透明物体找不到合适的ShadowCaster Pass时,Unity会尝试使用Fallback中的Shader来作为后备。如果"Diffuse"这个Fallback里内置的ShadowCaster逻辑都不可用,阴影就会完全消失。
在我调试透明阴影的过程中,这样一个简单的Fallback设置,往往比在Shader里写一堆复杂的自定义阴影逻辑更能解决问题。因为"Diffuse"内置的ShadowCaster Pass已经处理好了绝大多数平台上的阴影坐标转换和偏置问题。
但要注意的是,如果项目用的是URP,"Diffuse"这个Fallback并不支持URP的渲染管线,你需要把Fallback换成"Universal Render Pipeline/Lit"或者自定义的URP专用ShadowCaster。这个坑我踩过不止一次,每次从Built-In管线项目迁移到URP项目时,忘记改Fallback导致的透明阴影消失,排查了半个小时才发现原因。
6.3 多光源、透明阴影和性能够不够用的三角关系
最后聊一个很多人关心的问题:这些功能写进一个Shader之后,性能到底会怎样?
多光源叠加和透明阴影接收,本质上都是额外的Pass和额外的纹理采样。对于移动端的中低端设备,每一帧渲染这些Pass都会增加一定的GPU压力。我的建议是:把"透明阴影"和"多光源"分开来管理。主要角色、关键道具可以上完整版Shader,而大面积的普通透明物体(比如水面、大量玻璃窗户)则用简化版Shader——只支持Base Pass和基础阴影接收,不做多光源附加Pass,也不做复杂的TRANSFER_SHADOW动态阴影接收。
我在项目里做了一个优化:给所有透明材质增加了一个开关属性_EnableExtraLights,在Shader中通过[Toggle]来启用或禁用Additional Pass。当场景里的附加光源比较多时,可以快速批量关闭透明物体的附加光源计算,而不会影响它们的主光源阴影效果。
hlsl复制[Toggle(_ENABLE_EXTRA_LIGHTS)] _EnableExtraLights ("Enable Extra Lights", Float) = 1
然后在SubShader里用指令控制:
hlsl复制#pragma shader_feature _ENABLE_EXTRA_LIGHTS
在ForwardAdd Pass外面加上条件判断:
hlsl复制#ifdef _ENABLE_EXTRA_LIGHTS
Pass
{
Tags { "LightMode" = "ForwardAdd" }
// ...
}
#endif
这个开关在生产项目中非常实用。美术可以随时关闭某些透明物体上的额外光源计算,而不需要改动整个场景的光照布局。
7. 实测中的光影表现对比与性能数据参考
7.1 同一场景三种阴影方案的画面对比
我搭建了一个简单的测试场景:三个半透明玻璃瓶放在木地板上,主光源是平行光,另外场景里还有两盏点光源。测试了三种阴影方案:
| 方案 | 阴影表现 | 性能(中端手机,1080p) |
|---|---|---|
| 默认无ShadowCaster Pass | 无阴影,场景很"飘" | 1.2ms |
| 只做Alpha测试投射阴影 | 阴影轮廓清晰但完全黑色 | 1.8ms |
| Alpha + 阴影强度控制 | 阴影有深浅变化,接近半透明效果 | 2.1ms |
从视觉平衡和性能开销来看,Alpha + 阴影强度控制是性价比最高的。如果美术对阴影浓度要求不那么严格,甚至可以只把_ShadowIntensity设为一个固定值,省去实时计算的开销。
7.2 多光源场景下的Draw Call与Pass数量统计
多光源对Draw Call的影响是立竿见影的。以下数据来自同一个场景,用Frame Debugger统计得出:
| 光源数量 | ForwardBase Pass数 | ForwardAdd Pass数 | 总Draw Call |
|---|---|---|---|
| 1盏平行光 | 1 | 0 | 450 |
| 1盏平行光 + 2盏点光源 | 1 | 2 | 752 |
| 1盏平行光 + 4盏点光源 | 1 | 4 | 1056 |
这里的Draw Call数量包括了静止物体的静态合并和动态物体的Pass变化。可以看到,增加两盏点光源,Draw Call就增加了300多次,这在很多中端设备上已经是不小的开销了。
一个有效的优化是:把点光源的影响范围调小。当点光源的Range只覆盖到少量物体时,ForwardAdd Pass只会出现在这些物体上,而不是整个场景。在Unity的Frame Debugger里,你可以清晰地看到每个物体被渲染了哪些Pass,据此调整光源范围非常直观。
8. 落地实践中的几点心得和后续扩展方向
在实际项目里,把高级光照和阴影方案落地到具体场景时,我最常被问到的还是"为什么我照着教程写了,还是不对"这类问题。这类问题的答案,往往不在Shader代码本身,而在与Shader协作的引擎设置和材质参数上。
例如,当你写了透明阴影的ShadowCaster Pass,却始终看不到阴影时,先检查灯光组件上的Shadow Type是否为Soft Shadows或者Hard Shadows,而不是None。如果灯光阴影模式是None,那么所有ShadowCaster Pass都会直接失效。再检查Mesh Renderer的Cast Shadows属性是否为On或者Two Sided。如果它是Off,就算Shader写再好也白搭。
还有一个很容易被忽视的是光源的Shadow Strength。美术为了场景整体亮度平衡,把Shadow Strength调到了0,这样阴影也是完全消失的。
后面如果要继续深入,我觉得有几个方向值得研究:一个是Unity的Shadow Mask模式。它能将烘焙阴影和实时阴影混合,让透明阴影的接收更加自然;另一个是URP下Lightweight Render Pipeline的阴影管线和透明阴影处理方案,它在屏幕空间阴影上有不一样的实现逻辑,我自己也还在持续摸索中。
每次调试透明阴影不顺的时候,我都会先回到最基础的原理上推一遍:阴影是怎么形成的、Shadow Map里有什么、Alpha什么时候被剔除。推清楚了,大部分问题都能定位到具体环节,剩下的就是经验多少的问题了。希望这篇总结能让你少走一些弯路。
