TA不一样(十三)

前言:本节实现各种阴影,包括平行光阴影,聚光灯和点光源阴影,软阴影。

平行光阴影

1.实现原理

ShadowMap的实现主要分为两个pass

  • 第一个pass:在光源位置创建一个深度摄像机,以光源位置作为视点,将每个像素点的深度值(z-depth)也就是距离光源最近的对象距离记录在 Z-buffer 中,生成深度图
  • 第二个pass:从正常摄像机渲染场景,将每个fragment 到光源的距离和 Shadow Map 中保存的深度值进行比较,如果大于后者则说明被其他物体遮挡处于阴影之中

2.编写代码注意点
  • 在顶点输出数据结构中添加 SHADOW_COORDS(index) (声明阴影纹理坐标),index为可用的空闲寄存器序号,如 SHADOW_COORDS(4) 相当于 float4 _ShadowCoord : TEXCOORD4
  • 在顶点着色器使用 TRANSFER_SHADOW(o) 宏。它会根据当前的光照模式和平台特性,计算出正确的阴影采样坐标并赋值
  • 在片元着色器这使用 SHADOW_ATTENUATION(i)UNITY_LIGHT_ATTENUATION(atten, i, worldPos) 宏。这些宏会使用之前计算的 _ShadowCoord 对阴影贴图进行采样,返回一个阴影衰减因子(0 表示完全在阴影中,1 表示完全受光)
  • 为了让 SHADOW_COORDS 和相关宏正常工作,你通常需要:引入头文件‌(#include "AutoLight.cginc")、LightMode 通常需要设置为 "ForwardBase""ForwardAdd"、 Pass 中添加 #pragma multi_compile_fwdbase(对于主平行光)或 #pragma multi_compile_fwdadd,以确保 Unity 生成处理阴影所需的变体、添加fallback "specular"(建议添加)。

3.代码实现

步骤1:生成深度图

复制代码
Shader "MyCustom/CastShadow"
{
    Properties
    {
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 100

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
            };

            struct v2f
            {
                float2 depth : TEXCOORD0;
                float4 vertex : SV_POSITION;
            };

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.depth = o.vertex.zw;
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                
                // 执行了透视除法‌。得到的 depth 值是‌归一化设备坐标 (NDC)‌ 中的 Z 值
                float depth = i.depth.x / i.depth.y;


                // 平台兼容性处理:统一映射到 [0, 1]
                #if defined (SHADER_TARGET_GLSL)
                    depth = depth * 0.5 + 0.5;      // (-1, 1) -> (0, 1)
                #elif defined (UNITY_REVERSED_Z)
                    depth = 1 - depth;              // (1, 0) -> (0, 1)
                #endif

                // 编码为 RGBA 颜色
                float4 col = EncodeFloatRGBA(depth);
                return col;
            }
            ENDCG
        }
    }
}

步骤2:生成阴影

复制代码
Shader "MyCustom/ShadowMap"
{
    SubShader
    {
        Pass
        {
            NAME "DirectionalShadow"
            
            Blend DstColor Zero
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma shader_feature _PCFSOFTSHADOW

            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex       : POSITION;
                float3 normal       : NORMAL;
            };

            struct v2f
            {
                float4 shadowCoord  : TEXCOORD0;
                float4 worldPos     : TEXCOORD1;
                float3 worldNormal  : TEXCOORD2;
                float4 pos          : SV_POSITION;
            };

            // 步骤1生成的图
            sampler2D _MyCustomShadowMap;
            float4 _MyCustomShadowMap_TexelSize;

            // 灯光视图投影矩阵,将世界空间转换到灯光的视界空间
            // 外部C#计算:GL.GetGPUProjectionMatrix(cam.projectionMatrix, false) * cam. worldToCameralatrix 
            // cam为平行光上的摄像机
            float4x4 _MyCustomLightSpaceMatrix;
            float _MyCustomShadowStrength;

            float _pcfSoftShadow(float depth, float2 uv, float bias)
            {
                float shadow = 0;
                
                // 3x3 网格采样
                for (int x = -1; x <= 1; ++x)
                {
                    for (int y = -1; y <= 1; ++y)
                    {
                        float2 _uv = uv + float2(x, y) * _MyCustomShadowMap_TexelSize.xy;

                        float4 col = tex2D(_MyCustomShadowMap, _uv);
                        float sampleDepth = DecodeFloatRGBA(col);

                        // 累加每个采样点的阴影结果
                        shadow += sampleDepth + bias < depth ? _MyCustomShadowStrength : 1;
                    }
                }

                // 取平均值,产生柔和过渡
                return shadow / 9;

            }

            v2f vert (appdata v)
            {
                v2f o;
                o.pos = UnityObjectToClipPos(v.vertex);

                // 1. 转换到世界空间
                float4 worldPos = mul(unity_ObjectToWorld, v.vertex);

                // 2. 使用预计算的灯光视图投影矩阵,将世界坐标转换到灯光裁剪空间
                o.shadowCoord = mul(_MyCustomLightSpaceMatrix, worldPos);
                o.worldNormal = UnityObjectToWorldNormal(v.normal);
                o.worldPos = worldPos;
                
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                // 透视除法,得到 NDC 坐标 (-1 到 1)
                float2 uv = i.shadowCoord.xy / i.shadowCoord.w;
                uv = uv * 0.5 + 0.5;      // (-1, 1) -> (0, 1)

                // 从世界空间拿到的深度值
                float depth = i.shadowCoord.z / i.shadowCoord.w;

                #if defined (SHADER_TARGET_GLSL)
                    depth = depth * 0.5 + 0.5;
                #elif defined (UNITY_REVERSED_Z)
                    depth = 1 - depth;
                #endif

                // 计算 Bias(偏移量)
                // 由于阴影贴图分辨率有限,表面自遮挡会导致错误的阴影条纹
                // 根据法线与光线的夹角动态调整偏移。当表面与光线接近平行时(ndotl 接近 0),需要更大的 Bias 来避免误判
                float3 lightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
                float ndotl = dot(normalize(i.worldNormal), lightDir);
                float bias = max(0.05 * (1.0 - ndotl), 0.005);

                #ifdef _PCFSOFTSHADOW
                    // 模式一:软阴影(PCF Soft Shadow)
                    float shadow = _pcfSoftShadow(depth, uv, bias);
                #else
                    // 模式二:硬阴影(Hard Shadow)
                    float4 col = tex2D(_MyCustomShadowMap, uv);
                    // 解码阴影贴图中存储的深度值
                    float sampleDepth = DecodeFloatRGBA(col);
                    // 如果 贴图深度 + Bias < 当前像素深度,说明当前像素在遮挡物后面,处于阴影中
                    float shadow = sampleDepth + bias < depth ? _MyCustomShadowStrength : 1;
                #endif


                // 避免采样到错误的边缘数据
                if (uv.x > 1 || uv.y > 1 || uv.x < 0 || uv.y < 0)
                    shadow = 1;

                return shadow;
            }

            ENDCG
        }
    }
}

Bias优化

1.问题成因

在实时渲染中,阴影会出现条纹。阴影条纹问题通常被称为‌阴影痤疮(Shadow Acne)‌或‌自阴影错误‌。这是基于阴影贴图(Shadow Mapping)技术的一个经典缺陷。阴影条纹表现为模型表面出现不规则的黑白相间条纹或斑点,仿佛物体自己在给自己投射阴影。其根本原因在于‌深度比较时的精度误差‌,具体由以下三个因素共同导致:

  • 离散采样与分辨率限制:阴影贴图是一张有限分辨率的 2D 纹理(如 1024x1024 或 2048x2048)。当场景中的几何体非常复杂或距离光源较远时,阴影贴图中的单个像素(Texel)可能覆盖较大的世界空间区域。这意味着阴影贴图记录的深度值是离散的、近似化的,而非连续精确的。

  • 浮点数精度误差:GPU 在进行深度计算时使用浮点数。当光线以极低的角度( grazing angle )照射表面时,表面在灯光视角下的深度变化非常剧烈。由于浮点数的精度限制,渲染当前像素时计算出的深度值(Receiver Depth)与阴影贴图中存储的深度值(Occluder Depth,即同一表面之前被记录的值)之间会产生微小的数值偏差。

  • 深度比较逻辑缺陷:理论上,对于一个平面,currentDepth 应该等于 shadowMapDepth。但由于上述的插值误差和精度问题,有时会出现 currentDepth 略大于 shadowMapDepth 的情况。这导致本应受光的表面像素被误判为"位于遮挡物之后",从而错误地渲染为阴影。这种误判在表面上随机分布,形成了条纹状的视觉瑕疵。


2.引入 Bias(偏移量)的解决方案

为了解决 Shadow Acne,最通用且有效的方法是引入 ‌Depth Bias(深度偏移)‌。其核心思想是:‌在生成阴影贴图或进行深度比较时,人为地将遮挡物的深度值"推远"一点,或者将接收者的深度判定阈值放宽,确保表面不会误判自己为遮挡物。这样,当再次比较同一表面的深度时,currentDepth 就不会轻易超过 shadowMapDepth + bias,从而消除自阴影。


3.普通Bias的弊端及引入斜率缩放偏移(Normal Bias)

对所有像素应用一个固定的深度偏移值,无法适应不同角度的表面。角度过大或过小依然会造成问题。Normal Bias根据表面法线与光线方向的夹角动态调整偏移量,能更精确地消除条纹。

GD = FG * tanθ

FG = (视锥体大小/shadow map大小)

cosθ = dot(n,l) / (\|n\| \*\|l\|) = dot(n,l) 当n,l为单位向量

tanθ = sqrt(1 - cosθ * cosθ)

补充:当θ过大时,计算出的GD过大,使用法线方向的GH代替(GH平行n,H为CD上的垂足), 则

GH = GD * sinθ (基于法线方向的bias)


聚光灯阴影

在shader实现上没有什么区别,在灯光空间矩阵计算略有不同。(注:cam为光源绑定的摄像机,Shader为传入的阴影shader)


  • 平行光C#灯光空间转换:

// 将物体从世界坐标转到当前光源坐标

Matrix4x4 projectionMatrix = GL.GetGPUProjectionMatrix(cam.projectionMatrix, false);

Shader.SetGlobalMatrix("_MyCustomLightSpaceMatrix", projectionMatrix * cam.worldToCameraMatrix);


  • 聚光灯C#灯光空间转换:

// 矫正shadowmap上下颠倒

nMat = new Matrix4x4();

nMat.SetRow(0, new Vector4(1f, 0f, 0f, 0f));

nMat.SetRow(1, new Vector4(0f, -1f, 0f, 0f));

nMat.SetRow(2, new Vector4(0f, 0f, 1f, 0f));

nMat.SetRow(3, new Vector4(0f, 0f, 0f, 1f));

// 将物体从世界坐标转到当前光源坐标

Matrix4x4 projectionMatrix = GL.GetGPUProjectionMatrix(cam.projectionMatrix, false);

Shader.SetGlobalMatrix("_MyCustomLightSpaceMatrix", projectionMatrix * nMat * cam.worldToCameraMatrix);

// 传入摄像机空间,在这个空间中计算normal bias

Shader.SetGlobalMatrix("_MyCustomWorldToCameraMatrix", cam.worldToCameraMatrix);

// 将视锥体大小和shadow map大小传入计算normal bias

Shader.SetGlobalFloat("_MyCustomFrustumSize", projectionMatrix.m11);

Shader.SetGlobalFloat("_MyCustomShadowMapSize", shadowMapSize);


点光源阴影

平行光、聚光灯都只用‌单张2D纹理‌存储阴影,而点光源是全向360°覆盖的光源,无法用单一2D贴图完整记录所有方向的深度,必须使用‌立方体贴图(Cube Map)。

立方体贴图由6张独立的2D面组成,分别对应点光源的±X、±Y、±Z六个方向,每个面都有自己的视锥体,共同包裹住整个点光源的球形照射范围。

深度值为物体在灯光空间位置到最远截面的距离

cs 复制代码
// 记录当前摄像机
Quaternion currentQuat = cam.transform.rotation;
float currentFOV = cam.fieldOfView;

// 1: 在当前点光空间生成shadow cube
// 绘制场景
cam.transform.rotation = Quaternion.identity;

// +x
cam.transform.eulerAngles = new Vector3(0, 90, 180);
shadowCube.SetPixels(RT2Colors(shadowMap), CubemapFace.PositiveX);

// -z
cam.transform.eulerAngles = new Vector3(0, 0, 180);
shadowCube.SetPixels(RT2Colors(shadowMap), CubemapFace.NegativeZ);

// -x
cam.transform.eulerAngles = new Vector3(0, 270, 180);
shadowCube.SetPixels(RT2Colors(shadowMap), CubemapFace.NegativeX);

// +z
cam.transform.eulerAngles = new Vector3(0, 180, 180);
shadowCube.SetPixels(RT2Colors(shadowMap), CubemapFace.PositiveZ);

// +y
cam.transform.eulerAngles = new Vector3(-90, 0, 0);
shadowCube.SetPixels(RT2Colors(shadowMap), CubemapFace.PositiveY);

// -y
cam.transform.eulerAngles = new Vector3(90, 0, 0);
shadowCube.SetPixels(RT2Colors(shadowMap), CubemapFace.NegativeY);

// 保存当前的cubemap
shadowCube.Apply();

// 2: 将shadow cube计算需要的数据传递给custom shader
Shader.SetGlobalTexture("_MyCustomShadowCube", shadowCube);
// 绘制完毕之后,camera赋回初值
cam.transform.rotation = currentQuat;
cam.fieldOfView = currentFOV;

// 使用摄像机最远截面计算深度值
Shader.SetGlobalFloat("_MyCustomFarPlane", cam.farClipPlane);

锥体光源的Normal Bias


PCSS软阴影

1.原理及流程介绍

之前的PCF软阴影通过采样周围像素的阴影平均计算实现模糊软阴影,而PCSS软阴影是模拟真实世界中"接触阴影硬、远处阴影软"的特性。

W_Light: 光源面积尺寸

D_Blocker:遮挡物的深度

D_Receiver:被投影物的深度

Blocker Search:通过多重采样,计算出平均遮挡深度 D_Blocker

Penumbra Size:计算圆盘半径大小 W_Penumbra

Filtering:通过多重采样,计算出平均 Visibility(实际上就是调用PCF算法)

2.代码实现
cs 复制代码
// --------步骤一辅助函数--------
// 1.寻找阻挡物
void _findBlocker(float depth, _ShadowCoord coord, float bias,float2 rotation, out float avgBlockerDepth, out float numBlockers)
{
    float blockerSum = 0;

    // (depth - _MyCustomNearPlane) / depth:这是一个归一化的深度因子。随着 depth(当前像素深度)增加,这个比值趋近于 1;当 depth 接近 _MyCustomNearPlane 时,比值趋近于 0。这意味着‌距离光源越远,搜索范围越大‌。
    float sampleSize = (depth - _MyCustomNearPlane) / depth * _ShadowSoftness / _MyCustomShadowMapSize;

    // 使用预定义的 64 个泊松分布偏移量。相比规则网格,泊松分布能更好地避免采样别名效应,使阴影边缘更自然。
    for (int i = 0; i < 64; ++i)
    {
        #ifdef _MYCUSTOM_POINTLIGHT
            // 点光源的坐标可能是 3D 方向向量或立方体贴图 UV,因此只旋转 XY 分量,保持 Z/W 分量不变
            _ShadowCoord _coord = coord + float3(_rotate(PoissonOffsets[i] * sampleSize, rotation), 0);
        #else
            _ShadowCoord _coord = coord + _rotate(PoissonOffsets[i] * sampleSize, rotation);
        #endif
        float sampleDepth = 0;
        if (_sampleDepth(depth, _coord, bias, sampleDepth))
        {
            // 累加所有遮挡物的深度值并返回
            blockerSum += sampleDepth;
            // 统计遮挡物的数量并返回
            numBlockers += 1;
        }
    }

    avgBlockerDepth = blockerSum / numBlockers;
}

// 返回是否是阻挡物并返回其深度值
bool _sampleDepth(float depth, _ShadowCoord coord, float bias, out float sampleDepth)
{
    // 点光采集ShadowCube,其他光采集ShadowMap
    #ifdef _MYCUSTOM_POINTLIGHT    
        float4 col = texCUBE(_MyCustomShadowCube, normalize(coord));
        sampleDepth = DecodeFloatRGBA(col);
        sampleDepth = pow(sampleDepth, 2.2);
    #else
        float4 col = tex2D(_MyCustomShadowMap, coord);
        sampleDepth = DecodeFloatRGBA(col);
    #endif
        return sampleDepth + bias < depth;
}
// ----------------

// // --------步骤三辅助函数--------
float _pcfFilter(float depth, _ShadowCoord coord, float bias, float penumbra, float2 rotation)
{
    float shadow = 0;
    for (int i = 0; i < 64; ++i)
    {
        #ifdef _MYCUSTOM_POINTLIGHT
            _ShadowCoord _coord = coord + float3(_rotate(PoissonOffsets[i] * penumbra, rotation), 0);
        #else
            _ShadowCoord _coord = coord + _rotate(PoissonOffsets[i] * penumbra, rotation);
        #endif
            shadow += _shadowValue(depth, _coord, bias);
    }
    return shadow / 64;
}

// 如果是阻挡物,返回阴影强度
float _shadowValue(float depth, _ShadowCoord coord, float bias)
{
    float sampleDepth = 0;
    float shadow = _sampleDepth(depth, coord, bias, sampleDepth) ? _MyCustomShadowStrength : 0;
    return shadow;
}
// ----------------

float _pcssSoftShadow(float depth, _ShadowCoord coord, float bias, float2 texcoord)
{
    // PCSS 需要进行大量采样(这里用了64次)。如果采样图案是固定的网格,会在阴影边缘产生明显的周期性条纹(Banding Artifacts)。
    // 使用 ‌蓝噪声纹理 (Blue Noise)‌。蓝噪声具有高频分布均匀且低频能量少的特性比纯随机噪声更平滑,比规则网格更自然。
    // 为每个像素生成一个随机的旋转角度 rotation,在后续采样时旋转采样图案,将固定的条纹误差打散成高频噪声,视觉上更柔和。
    float random = tex2D(_ShadowBlueNoise, texcoord * _ShadowBlueNoiseRepeat * _ScreenParams.xy).r;
    random = random * 2 - 1;  // (0, 1) -> (-1, 1)
    float rotationAngle = random * 3.1415926;
    float2 rotation = float2(cos(rotationAngle), sin(rotationAngle));

    // Step 1. 求得遮挡物的平均深度
    float avgBlockerDepth = 0;
    float numBlockers = 0;
    _findBlocker(depth, coord, bias, rotation, avgBlockerDepth, numBlockers);

    if (numBlockers < 1)
    {
        // 该区域不在阴影内
        return 0;
    }

    // Step 2. 求得半影大小
    float penumbra = (depth - avgBlockerDepth) / avgBlockerDepth * _ShadowSoftness / _MyCustomShadowMapSize;

    // Step 3. pcf filter计算阴影
    float shadow = _pcfFilter(depth, coord, bias, penumbra, rotation);
    return shadow;
}
相关推荐
yp5325 天前
TA不一样(十一)
unity shader
yp5327 天前
TA不一样(十)
unity shader
benben0441 年前
《Unity Shader入门精要》学习笔记二
unity·unity shader
是嘟嘟啊2 年前
【Unity】线性代数基础:矩阵、矩阵乘法、转置矩阵、逆矩阵、正交矩阵等
线性代数·unity·矩阵·矩阵乘法·逆矩阵·unity shader·矩阵转置
avi91112 年前
Unity Shader - 2024 工具篇
unity·游戏引擎·unity shader