Shader笔记:光照与阴影1

引:旋转动画(三角函数)

clike 复制代码
float3 rotationY(float3 vertex){
    float c = cos(_Time.y*_Speed);
    float s = sin(_Time.y*_Speed);
    float3x3 m = {c,0,s,
                  0,1,0,
                 -s,0,c};
    return mul(m,vertex);
}
v2f vert (a2v v)
{
    v2f o;
    o.pos = UnityObjectToClipPos(rotationY(v.vertex));
    return o;
}

fixed4 frag (v2f i) : SV_Target
{
	// ...
    return col;
}

不过这样写的话地上的阴影是没更新的,阴影还是静态的,这里就引出了关于阴影渲染的问题:

基础内容

ShadowCaster Pass

在这之前需要先阅读如下3个网页

clike 复制代码
Pass
{
    Name "ShadowCaster"
    Tags { "LightMode"="ShadowCaster" }
    CGPROGRAM
    #pragma vertex vert
    #pragma fragment fragShadowCaster
    #pragma multi_compile_shadowcaster

    #include "UnityCG.cginc"

    struct a2v
    {
        float4 vertex : POSITION;
    };

    struct v2f
    {
        float4 pos : SV_POSITION;
    };

    float _Speed;

    float3 rotationY(float3 vertex)
    {
        float c = cos(_Time.y * _Speed);
        float s = sin(_Time.y * _Speed);
        float3x3 m = float3x3(c, 0, s, 0, 1, 0, -s, 0, c);
        return mul(m, vertex);
    }

    v2f vert (a2v v)
    {
        v2f o;
        float3 rotatedVertex = rotationY(v.vertex.xyz);
        o.pos = UnityObjectToClipPos(float4(rotatedVertex, 1.0));
        return o;
    }
    float4 fragShadowCaster(v2f i) : SV_Target
    {
        return 0;
    }
    ENDCG
}

通过在原着色器上增加这个Pass,使得阴影可以被动态渲染

ShadowCaster是 Unity 中用于生成阴影的一种特殊的 Pass。它负责计算某个片元相对于光源的深度信息,从而在渲染阴影贴图时能够正确地投射阴影。原理很简单:光源视角下某片元深度大于ZBuffer(其实就是这个阴影贴图)记录的数值则意味着该片元被遮挡(无法被灯光照射到故而产生阴影)。

这里主要是在计算阴影时让顶点和主要着色的Pass的顶点旋转位置一致,从而生成动态合理的阴影。

*注意使用指令#pragma multi_compile_shadowcaster

ShadowCaster内容与用法

阴影投射是一个有着色器参与的过程,其结果通常是一个阴影纹理。其中着色器参与主要是指ShadowCaster Pass。什么是ShadowCaster?就是能投射阴影的。按道理所有启用Cast Shadow的物体的着色器都应该有这个Pass,这个Pass的主要目的是变换顶点到期待产生投影的位置。

ShadowCaster Pass 在片元着色器返回值没有意义。其主要职责是将物体的深度信息写入阴影贴图,以便后续渲染时使用。这个深度信息可以视作以光源为视点的ZBuffer,对于多个光源,则会产生多张深度信息图。

这个Pass所做计算的关键在于顶点着色器将物体坐标转换到裁剪空间,从而隐式地计算深度。也就是说最终用到的是它计算深度的部分,即

clike 复制代码
 o.pos = UnityObjectToClipPos(v.vertex);

有必要指出的是:在这个Pass下使用的VP矩阵等操作都是相对于光源的,这和其他Pass不一样。

那么这时候问题就来了:会不会出现一种情况,某个片元出现在摄像机的视锥体内但是没有出现在光源视点的深度图里?

啪的一下,很快啊,我就打开了GPT,他是这么说的:

  • 对于定向光(Directional Light),深度信息图的方向与光源的方向一致。定向光的视角可以被认为是从无限远处照射来的,所以在深度贴图中,光源的视点朝向光源方向,而深度信息图记录了沿光源方向的视线上的深度信息。
  • 对于点光源(Point Light),深度信息图会有六张,分别对应点光源发出的六个方向(正X、负X、正Y、负Y、正Z、负Z)。这些深度贴图记录了从点光源出发的不同方向的深度信息。(这LearnOpenGLCN的点阴影一节也提到了,把六个方向的阴影渲染为CubeMap)
  • 对于聚光灯(Spot Light),深度信息图的方向是光源的锥体方向。深度贴图的视角是从光源的位置沿着其锥体方向的视线,记录在该锥体内的深度信息。

好吧,其实LearnOpenGLCN也提到了这个问题:

他这里主要意思是这部分错误的阴影来源有两部分:

  • 坐标范围之外
  • 远裁面之外

这些地方没被记录深度信息,如果直接视为"在阴影中"是不恰当的,最保险的方法是都认为不在阴影范围内


那在渲染的时候拿到一个片元就会在每个光源的视角下依次比较深度值,如果大于深度信息里记载的(说明有其它片元更靠近光源,即该片元被其他片元遮挡)则认为需要渲染阴影

确定一个片元要渲染上阴影后,就该谈论渲染方式了,这具体取决于想要实现的阴影效果

这里暗含了两个信息:

  1. 阴影产生必须有光源(所以必须设置好Light组件)
  2. 阴影投射到其他Mesh上(MeshRenderer必须配置 Cast Shadows 和 Receive Shadows 属性)

自阴影的话是另一个话题了,见后文

实时阴影渲染的时机与方式

在前向渲染中,阴影着色和光照计算通常在渲染物体时一起完成(可以,但不必须)

Unity Shader入门精要则是这样描述的:

|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| 开启了光源的阴影效果后,底层渲染引擎首先会在当前渲染物体的着色器中找到`LightMode="ShadowCaster"`的 Pass,如果没有,它就会在 Fallback 指定的着色器中继续寻找,如果仍然没有找到,该物体就无法向其他物体投射阴影(但它仍然可以接收来自其他物体的阴影)。当找到了一个`LightMode="ShadowCaster"`的 Pass 后,Unity 会使用该 Pass 来更新光源的阴影映射纹理。 |

放到专门的Pass去做这个是因为,Base Pass确实有可能要用这个结果,需要先计算,二来由于只使用深度信息,专门的Pass干这个会显得更加轻量。


总而言之:

关于阴影的投射与接受:想投射阴影就得计算阴影映射纹理(所以得开启ShadowCaster Pass),想接受阴影就得对阴影映射纹理采样。

关于渲染队列与阴影映射贴图的计算时机:不透明物体和透明物体的阴影映射贴图的计算时机是分离的。可以设想这样一个场景:

光源处理方式

在前向渲染中主要是:

  1. 逐顶点处理
  2. 逐像素处理
  3. 球谐函数(Spherical Harmonics,SH)处理

Light组件渲染模式的选项规定了该光源是否是重要的(Important):重要的要进行逐像素处理,不重要的则进行逐顶点或者SH

回答之前的问题

问题:为什么那个阴影的静态的?明明物体在动。

很简单,阴影映射贴图是在渲染不透明物体之前进行的。那时候还没有执行不透明物体的着色器,进而物体顶点着色器内的动画也没有被应用。顶点着色器内的动画不会影响实际的Transform,所以那时候渲染的阴影贴图还是按照物体最初的位置状态渲染的,因此阴影没变化。

总而言之

  1. 基于shadowmap计算实时阴影
  2. 和预先烘焙好的阴影混合

阴影渲染产生的问题

知乎 - games202-实时阴影(第3-4课时)
CSDN- 【Unity Shader】Unity中自阴影优化方案(好吧这俩有重合)

概念辨析:软阴影与硬阴影

一言以蔽之

硬阴影:边缘清晰,无渐变,适用于小光源或点光源。计算简单,但不适合模拟现实中大多数自然光照的阴影。

软阴影:边缘柔和,有渐变,适用于面积光源。计算复杂,但能够模拟更逼真的光照效果,适合大多数实际应用场景。

主要核心就是边缘过渡是否柔和

阴影失真

这是一个阴影映射贴图采样具有的问题

就直接取LearnOpenGLCN的图了,很简单的问题,就是光源视角下的深度信息,精度不够,远处多个片元可能在这个深度图的同一个像素采样(多个片元认为自己被同一束光线照射)。

但是这个光线的方向有可能是斜着的(下图可以看到,不过我觉得画错了,明暗部分颠倒了 ),此时有一部分片元到相机的距离完全大于光束长度,所以被认为是处于光照区域。这种情况发生在多个地方,形成条纹状阴影。

阴影偏移(shadow bias)则是将光线的长度(就是那个光源视点下的深度缓冲区值)统一都偏移一个 Δ \Delta Δ,然后平面上的点到光源的距离就都大于光束的长度(被认为能照到),因此就不会有阴影出现

悬浮

当阴影偏移值过于显著的时候,就会出现下面影子悬浮(Peter Panning)的问题

此时可以使用"正面剔除"解决。

锯齿问题

源自阴影映射贴图精度不够(可以理解为沿光源视角把这张深度图贴到场景上,那深度图的一个像素会被多个片元采样)

解决方法

  1. 增加深度贴图的分辨率
  2. PCF(percentage-closer filtering)
  3. CSM(Cascaded Shadow Maps,级联阴影映射)
  4. Shadow Map Filtering
    • VSM(Variance Shadow Maps)
    • ESM(Exponential Shadow Maps)
  5. PCSS(Percentage-Closer Soft Shadows)

PCF核心思想是从深度贴图中多次采样,每一次采样的纹理坐标都稍有不同。每个独立的样本可能在也可能不再阴影中。所有的次生结果接着结合在一起,进行平均化(类似于平均池化),我们就得到了柔和阴影。

不过还是只能从远处开,近处不太行。(见下图)

术语概念辨析

这是对上一小节的补充.

级联阴影映射 (CSM) 的核心思想是将视锥体分割成多个子视锥体,并为每个子视锥体渲染单独的阴影贴图 。距离摄像机较近的子视锥体分配更高的阴影贴图分辨率,而距离较远的子视锥体则使用较低的分辨率 。这种方法可以在保持阴影质量的同时,有效利用阴影贴图资源。

层级阴影贴图 (Hierarchical Shadow Maps) 采用的方法更为复杂。它使用树状结构(通常是四叉树)来存储阴影信息 。树的每个节点代表场景中的一部分,叶节点存储该区域的阴影贴图 。在渲染阴影时,根据像素的深度选择合适的节点进行采样。这种方法可以更好地处理场景中物体分布不均匀的情况,并减少阴影锯齿。

VSM(Variance Shadow Maps) 使用方差阴影映射,通过存储深度的平方信息来减少阴影边缘的锯齿,能够处理更柔和的阴影过渡,减少锯齿。缺点是可能会引入光漏现象(light leaking)。

ESM(Exponential Shadow Maps) 是将深度值进行指数化处理,减少锯齿的同时提供平滑的阴影过渡。可以减少锯齿并且能够生成更自然的阴影,不过可能会导致阴影过度模糊。

PCSS核心原理是根据遮挡物与被遮挡物之间的距离来改变阴影的柔和程度。因为实际光照下,靠近遮挡物的阴影边缘会更加清晰,而远离遮挡物的阴影边缘会更加模糊。这涉及从被认为处于阴影区域的像素向光源方向(多次)采样判断距离,计算量会变大。

透明物体的阴影

Medium - Transparent and Crystal Clear: Writing Unity URP Shaders with Code, Part 3

Unity 论坛 - Shadow Intensity for transparent objects

上图直观地展示了透明物体在前文描述的渲染方式下无法得到正确的结果,拿右侧的物体来说,至少理应得到的不应该是全黑的阴影。

还有一个问题是透明物体介质未必均匀,这样的话会导致光传播路径变化,因而导致影子形状和颜色都不一定对。

解决方案

  1. 抖动阴影(Dithered shadows)
  2. 根据物体透明度混合阴影
  3. 修改管线

算了,这个也单独写一篇文章吧。(服了,每篇文章都得挖几个坑留在下一篇文章解决)

代码层面的一些技术点

MRT与SSM

Unity5在支持MRT的显卡可以使用屏幕空间阴影映射技术(SSM),而非传统的阴影采样技术。具体说来,其实就是把各个光源视角下的信息,汇总到正儿八经渲染相机视角下,得到相机视角下各个片元是否处于阴影的情况。

(总感觉拿到这个贴图可以做很多效果哎,所以后面说一下阴影映射纹理怎么获取)

阴影投射的配置

csharp 复制代码
UsePass "Legacy Shaders/VertexLit/SHADOWCASTER"

这样声明(引用)一个已有的Pass可以快速地完成阴影投射

此外,即使一个Shader没有定义相关的ShadowCaster,但是比如说使用了fallback "Specular",然后这个着色器会继续fallback "VertexLit",最终会在这个着色器里找到ShadowCasterPass,所以还是可以产生阴影的。

是的,这说明了fallback很重要。


Cast Shadow默认是背面剔除的,但是可以选择TwoSide

阴影三剑客(冯乐乐书)

其实就是在说三个宏,冯乐乐书中的"阴影三剑客"

当着色器能够Receive Shadows时,意味着可以知道哪些区域处于阴影。显然地,拿到这些区域可以绘制一些更加风格化的阴影效果。

我的意思是,应当明白如何获取到阴影区域和根据阴影区域计算一个阴影的强度。

这就涉及到了这三个位于#include "AutoLight.cginc的宏

  • SHADOW_COORDS
  • TRANSFER_SHADOW
  • SHADOW_ATTENUATION

官方的着色器实例恰好展示了这3个宏如何用:

clike 复制代码
Shader "Lit/Diffuse With Shadows"
{
    Properties
    {
        [NoScaleOffset] _MainTex ("Texture", 2D) = "white" {}
    }
    SubShader
    {
        Pass
        {
            Tags {"LightMode"="ForwardBase"}
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "UnityCG.cginc"
            #include "Lighting.cginc"

            // compile shader into multiple variants, with and without shadows
            // (we don't care about any lightmaps yet, so skip these variants)
            #pragma multi_compile_fwdbase nolightmap nodirlightmap nodynlightmap novertexlight
            // shadow helper functions and macros
            #include "AutoLight.cginc"

            struct v2f
            {
                float2 uv : TEXCOORD0;
                SHADOW_COORDS(1) // put shadows data into TEXCOORD1
                fixed3 diff : COLOR0;
                fixed3 ambient : COLOR1;
                float4 pos : SV_POSITION;
            };
            v2f vert (appdata_base v)
            {
                v2f o;
                o.pos = UnityObjectToClipPos(v.vertex);
                o.uv = v.texcoord;
                half3 worldNormal = UnityObjectToWorldNormal(v.normal);
                half nl = max(0, dot(worldNormal, _WorldSpaceLightPos0.xyz));
                o.diff = nl * _LightColor0.rgb;
                o.ambient = ShadeSH9(half4(worldNormal,1));
                // compute shadows data
                TRANSFER_SHADOW(o)
                return o;
            }

            sampler2D _MainTex;

            fixed4 frag (v2f i) : SV_Target
            {
                fixed4 col = tex2D(_MainTex, i.uv);
                // compute shadow attenuation (1.0 = fully lit, 0.0 = fully shadowed)
                fixed shadow = SHADOW_ATTENUATION(i);
                // darken light's illumination with shadow, keep ambient intact
                fixed3 lighting = i.diff * shadow + i.ambient;
                col.rgb *= lighting;
                return col;
            }
            ENDCG
        }

        // shadow casting support
        UsePass "Legacy Shaders/VertexLit/SHADOWCASTER"
    }
}

在最底下的参考链接中可以找到AutoLight.cginc的内容,不难发现有如下定义

clike 复制代码
// ---- Depth map shadows

#if defined (SHADOWS_DEPTH) && defined (SPOT)

#if !defined(SHADOWMAPSAMPLER_DEFINED)
UNITY_DECLARE_SHADOWMAP(_ShadowMapTexture);
#endif
#if defined (SHADOWS_SOFT)
uniform float4 _ShadowOffsets[4];
#endif

inline fixed unitySampleShadow (float4 shadowCoord)
{
	// DX11 feature level 9.x shader compiler (d3dcompiler_47 at least)
	// has a bug where trying to do more than one shadowmap sample fails compilation
	// with "inconsistent sampler usage". Until that is fixed, just never compile
	// multi-tap shadow variant on d3d11_9x.

	#if defined (SHADOWS_SOFT) && !defined (SHADER_API_D3D11_9X)

	// 4-tap shadows

	float3 coord = shadowCoord.xyz / shadowCoord.w;
	#if defined (SHADOWS_NATIVE)
	half4 shadows;
	shadows.x = UNITY_SAMPLE_SHADOW(_ShadowMapTexture, coord + _ShadowOffsets[0]);
	shadows.y = UNITY_SAMPLE_SHADOW(_ShadowMapTexture, coord + _ShadowOffsets[1]);
	shadows.z = UNITY_SAMPLE_SHADOW(_ShadowMapTexture, coord + _ShadowOffsets[2]);
	shadows.w = UNITY_SAMPLE_SHADOW(_ShadowMapTexture, coord + _ShadowOffsets[3]);
	shadows = _LightShadowData.rrrr + shadows * (1-_LightShadowData.rrrr);
	#else
	float4 shadowVals;
	shadowVals.x = SAMPLE_DEPTH_TEXTURE ( _ShadowMapTexture, coord + _ShadowOffsets[0].xy );
	shadowVals.y = SAMPLE_DEPTH_TEXTURE ( _ShadowMapTexture, coord + _ShadowOffsets[1].xy );
	shadowVals.z = SAMPLE_DEPTH_TEXTURE ( _ShadowMapTexture, coord + _ShadowOffsets[2].xy );
	shadowVals.w = SAMPLE_DEPTH_TEXTURE ( _ShadowMapTexture, coord + _ShadowOffsets[3].xy );
	half4 shadows = (shadowVals < coord.zzzz) ? _LightShadowData.rrrr : 1.0f;
	#endif

	// average-4 PCF
	half shadow = dot (shadows, 0.25f);

	#else

	// 1-tap shadows

	#if defined (SHADOWS_NATIVE)
	half shadow = UNITY_SAMPLE_SHADOW_PROJ(_ShadowMapTexture, shadowCoord);
	shadow = _LightShadowData.r + shadow * (1-_LightShadowData.r);
	#else
	half shadow = SAMPLE_DEPTH_TEXTURE_PROJ(_ShadowMapTexture, UNITY_PROJ_COORD(shadowCoord)) < (shadowCoord.z / shadowCoord.w) ? _LightShadowData.r : 1.0;
	#endif

	#endif

	return shadow;
}
#define SHADOW_COORDS(idx1) float4 _ShadowCoord : TEXCOORD##idx1;
#define TRANSFER_SHADOW(a) a._ShadowCoord = mul (unity_World2Shadow[0], mul(_Object2World,v.vertex));
#define SHADOW_ATTENUATION(a) unitySampleShadow(a._ShadowCoord)

#endif

显然

  • SHADOW_COORDS(N)是声明一个对阴影纹理采样的坐标,N是寄存器的值,这意味着多光源阴影会有多个
  • TRANSFER_SHADOW则具体地为上面的寄存器填充数据。即计算顶点在阴影贴图中的坐标,用于将坐标传递给片段着色器。
  • SHADOW_ATTENUATION输入一个阴影坐标,返回的是一个阴影强度,这是一个介于 0 和 1 之间的值,其中 0 表示完全遮蔽(全黑),1 表示完全无阴影(无遮蔽)。

当然也意味着顶点着色器的输出结构体v2f v必须命名为v,且这个结构体里必须有一个变量float4 _ShadowCoord,不过好在使用了SHADOW_COORDS就必然有。且输入顶点着色器的结构体中必须有一个变量叫vertex用来记录顶点坐标


还有,你会发现输入的顶点着色器的结构体没定义,怎么辉石呢?原来是在UnityCG.cginc有这样的定义

cpp 复制代码
struct appdata_base {
    float4 vertex : POSITION;
    float3 normal : NORMAL;
    float4 texcoord : TEXCOORD0;
};

struct appdata_tan {
    float4 vertex : POSITION;
    float4 tangent : TANGENT;
    float3 normal : NORMAL;
    float4 texcoord : TEXCOORD0;
};

struct appdata_full {
    float4 vertex : POSITION;
    float4 tangent : TANGENT;
    float3 normal : NORMAL;
    float4 texcoord : TEXCOORD0;
    float4 texcoord1 : TEXCOORD1;
    fixed4 color : COLOR;
#if defined(SHADER_API_XBOX360)
	half4 texcoord2 : TEXCOORD2;
	half4 texcoord3 : TEXCOORD3;
	half4 texcoord4 : TEXCOORD4;
	half4 texcoord5 : TEXCOORD5;
#endif
};

另一个三剑客(Shader圣经的巨坑)

Shader圣经挖了一个坑,他讲的阴影渲染是用的另一个方法,也是三个宏,位于UnityCG.cginc,但是讲了还不如不讲,晕头转向的。

  • V2F_SHADOW_CASTER
  • TRANSFER_SHADOW_CASTER_NORMALOFFSET(o)
  • SHADOW_CASTER_FRAGMENT(i)

书中是这样介绍的:

V2F_SHADOW_CASTER包含了多个用于在顶点位置插值和法线贴图中计算阴影的语义,这意味着该宏有:顶点位置输出(vertex : SV_POSITION)、法线输出(normal_world : TEXCOORD1)、切线输出(tangent_world : TEXCOORD2)和副切线输出(binormal_world : TEXCOORD3)。

TRANSFER_SHADOW_CASTER_NORMALOFFSET(o)负责将顶点位置的坐标空间变换到裁剪空间,还允许我们计算法线偏移,以便将阴影包含在法线贴图中。

SHADOW_CASTER_FRAGMENT负责阴影投射的颜色输出。

clike 复制代码
// 大概用法
struct v2f{
	V2F_SHADOW_CASTER;
};
v2f vert(appdata v){
	v2f o;
	TRANSFER SHADOW CASTER NORMALOFFSET(O)
	return o;
}
fixed4 frag(v2f i):SV_Target{
	SHADOW_CASTER_FRAGMENT(i)
}

主要问题是什么呢,是他前面说了用到usepass,然后后面讲的简直就是云里雾里,相当跳脱。

我对这部分的内容做一个简单的总结,意思就是会单独用一个Pass渲染阴影到贴图上

LIGHTING_COORDS

c 复制代码
// AutoLight.cginc 最尾部就是这些
#ifdef POINT
#   define DECLARE_LIGHT_COORDS(idx) unityShadowCoord3 _LightCoord : TEXCOORD##idx;
#   define COMPUTE_LIGHT_COORDS(a) a._LightCoord = mul(unity_WorldToLight, mul(unity_ObjectToWorld, v.vertex)).xyz;
#   define LIGHT_ATTENUATION(a)    (tex2D(_LightTexture0, dot(a._LightCoord,a._LightCoord).rr).r * SHADOW_ATTENUATION(a))
#endif

#ifdef SPOT
#   define DECLARE_LIGHT_COORDS(idx) unityShadowCoord4 _LightCoord : TEXCOORD##idx;
#   define COMPUTE_LIGHT_COORDS(a) a._LightCoord = mul(unity_WorldToLight, mul(unity_ObjectToWorld, v.vertex));
#   define LIGHT_ATTENUATION(a)    ( (a._LightCoord.z > 0) * UnitySpotCookie(a._LightCoord) * UnitySpotAttenuate(a._LightCoord.xyz) * SHADOW_ATTENUATION(a) )
#endif

#ifdef DIRECTIONAL
#   define DECLARE_LIGHT_COORDS(idx)
#   define COMPUTE_LIGHT_COORDS(a)
#   define LIGHT_ATTENUATION(a) SHADOW_ATTENUATION(a)
#endif

#ifdef POINT_COOKIE
#   define DECLARE_LIGHT_COORDS(idx) unityShadowCoord3 _LightCoord : TEXCOORD##idx;
#   define COMPUTE_LIGHT_COORDS(a) a._LightCoord = mul(unity_WorldToLight, mul(unity_ObjectToWorld, v.vertex)).xyz;
#   define LIGHT_ATTENUATION(a)    (tex2D(_LightTextureB0, dot(a._LightCoord,a._LightCoord).rr).r * texCUBE(_LightTexture0, a._LightCoord).w * SHADOW_ATTENUATION(a))
#endif

#ifdef DIRECTIONAL_COOKIE
#   define DECLARE_LIGHT_COORDS(idx) unityShadowCoord2 _LightCoord : TEXCOORD##idx;
#   define COMPUTE_LIGHT_COORDS(a) a._LightCoord = mul(unity_WorldToLight, mul(unity_ObjectToWorld, v.vertex)).xy;
#   define LIGHT_ATTENUATION(a)    (tex2D(_LightTexture0, a._LightCoord).w * SHADOW_ATTENUATION(a))
#endif

#define UNITY_LIGHTING_COORDS(idx1, idx2) DECLARE_LIGHT_COORDS(idx1) UNITY_SHADOW_COORDS(idx2)
#define LIGHTING_COORDS(idx1, idx2) DECLARE_LIGHT_COORDS(idx1) SHADOW_COORDS(idx2)
#define UNITY_TRANSFER_LIGHTING(a, coord) COMPUTE_LIGHT_COORDS(a) UNITY_TRANSFER_SHADOW(a, coord)
#define TRANSFER_VERTEX_TO_FRAGMENT(a) COMPUTE_LIGHT_COORDS(a) TRANSFER_SHADOW(a)

DECLARE_LIGHT_COORDS用于生成光照坐标,不过平行光是没有这个的。
COMPUTE_LIGHT_COORDS计算光照坐标,并将其存储在 a._LightCoord 中,平行光依旧没这个
LIGHT_ATTENUATION光照衰减平行光也是没有的,直接
TRANSFER_VERTEX_TO_FRAGMENT就是计算了光照坐标和阴影坐标,对于平行光来说,光照坐标就是阴影坐标

光源阴影二合一

接上文,随后使用宏UNITY_LIGHT_ATTENUATION完成最终光影效果。(据说早期是LIGHT_ATTENUATION这个,反正现在是不用关心LIGHT_ATTENUATION的)

  1. 这个宏定义在AutoLight.cginc
  2. 适用于前向渲染,不适用于延迟渲染
  3. URP可以,HDRP不用这个,因为HDRP有HDAdditionalLightData组件
  4. 这个宏会自动声明atten变量

常见用法:

clike 复制代码
UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);
return fixed4(ambient + (diffuse + specular) * atten, 1.0);

0的话通常描述完全衰减,主要就看使用方式了,可以只衰减反射部分

平行光的话是不衰减的,其他光会存在平方衰减或者线性衰减的情况,也可以在宏定义中找到。

获取阴影映射纹理

Unity 论坛 - 获取屏幕空间阴影的纹理 [URP]

首先,命令缓冲区要和SRP区分开,前者是对已有管线的补充,后者则是对管线的完全重建、是根本性的更改。命令缓冲区是在已有的流程中插入额外的图形操作,比如后处理效果、特殊的光照效果、额外的渲染通道等。

并且通过将多个渲染命令打包到一个缓冲区中,可以减少 GPU 的状态切换次数,从而提高渲染性能。还可以使用命令缓冲区来改变不同相机的渲染行为,比如在某些特定的图层上应用不同的渲染策略。

就使用而言,通常是于挂载在Camera下的脚本中创建缓冲区,然后通过在相机上调用.AddCommandBuffer(CameraEvent evt, CommandBuffer buffer)将制定的Buffer绑定在相关的Event上,在相关Event之后就会执行这个Buffer里的内容

对于获取阴影纹理,则对CB对象使用.Blit(BuiltinRenderTextureType, RenderTexture)命令来获取

Unity Documentation - BuiltinRenderTextureType是渲染期间产生的内置临时渲染纹理的获取方法,内容含义见官方文档。

自遮挡与自阴影

知乎 - 图形引擎实战:自阴影渲染分享

这个球谐阴影疑似可以处理一定程度的自阴影,优缺点见下文内。

知乎 - URP实现球谐阴影

多光源问题

对于N个物体和M个光源,其计算复杂度通常是N*M,因此虽然可以多光源,但是面临两方面问题

  1. 计算量翻倍
  2. 原有的SHADOW_COORDS似乎不能用了,因为一个缓冲区只能对应一个光源(因此要多Pass,见底下Additional部分)

Edit -> Project Setting -> Quality->Rendering下设置最多支持逐像素的光照光源数目。

在前向渲染路径下渲染一个物体时,Unity会根据场景中各个光源的设置和影响程度来进行排序,这其中有一定数目的光源会进行逐像素处理,然后至多 4 个光源进行逐顶点处理。而其它的光源则会使用SH处理。SH处理虽然很快,但是得到的值是一个粗略的近似值。

风格化阴影

风格化阴影这个问题,可以有这几种常见的技术点:

  1. 物理笔触绘制风格的(例如钢笔画)
  2. Halftone
  3. SDF面部阴影(针对人物脸部的)
  4. 阴影边缘色调偏移

针对前两种,上图是一个例子,其实就是把预先定义好的纹理贴在屏幕空间。波点图在制作时把侧边缘搞成渐变到透明的,然后随亮度或者N·L值加个step即可实现随明度不同而大小不同的波点。当然这个波点图完全可以搞成SDF就不需要纹理图了。上面这个图是一个很古早的Blender资产,搜komikaze就可以搜到。

如果是对于风格化笔触,把预先定义好的纹理贴在屏幕空间只是其中一种方式,还有别的方式处理,不过会更加复杂。

阴影边缘色调偏移见上图(来源于网络),而SDF则是一个更需要长篇大论的话题。

综上,本小节单开一篇。

Pass的调用顺序

一般情况下,先声明的 Pass 会先执行。但是Pass可能携带不同的标签,根据渲染管线的不同,可能会先调用某个标签的pass,例如

  1. ForwardBase总是会先于ForwardAdd渲染
  2. Fallback中的pass如果执行总是在最后

注意到Unity官方文档是这样说的:

|-------------------------------------------------------------------------------------------------------------------------------------|
| LightMode 标签是一个预定义的通道标签,Unity 使用它来确定是否在给定帧期间执行该通道,在该帧期间 Unity 何时执行该通道,以及 Unity 对输出执行哪些操作。 注意: LightMode 标签与 LightMode 枚举无关,后者与光照有关。 |

如果不为一个Pass指定LightMode,URP会为这个Pass的LightMode取默认值SRPDefaultUnlit

对于URP使用的Pass的LightMode,参见这篇文章Unity Documentation - URP ShaderLab Pass tags

注意:URP不支持下面的LightMode tags: Always, ForwardAdd, PrepassBase, PrepassFinal, Vertex, VertexLMRGBM, VertexLM

有时透明队列中的多通道着色器会不按顺序渲染通道

Base与Additional

所有物体都会至少执行一个 Base Pass,用于渲染主要的视觉效果。

Additional Pass则被用于处理主光源之外的光源所造成的额外影响。它在 Base Pass 之后执行,用于细化和增强光照效果。通常使用加法混合(Additive Blending)也就是Blend One One(划重点!)

注意观察两种pass下return的情况,附加pass是不关心ambient的:

clike 复制代码
// base pass
return fixed4(ambient + (diffuse + specular) * atten, 1.0);
// additional pass
return fixed4((diffuse + specular) * atten, 1.0);

注意,当我们指定了一个BasePass,例如LightMode = "ForwardBase",仍旧会在代码中声明:

clike 复制代码
 #pragma multi_compile_fwdbase

LightMode告诉渲染管线这是一个干啥用的Pass,但是后面这个指令则告诉管线去生成多种变种,确保你的 Shader 在不同光照设置下都能正确工作。

不同的光照设置是指:是否使用光照贴图、是否开启阴影等。它会增加 Shader 的大小,但可以提高渲染效率,因为 Unity 只需要加载和使用与当前光照设置匹配的 Shader 变体。


Additional Pass每次只会处理一个光源,不会把除了主光源外的多个光源在一次Pass流程中全处理。

我把尾部参考文档的"复杂的光照"一文的一个Additional Pass的核心代码复制了过来:

clike 复制代码
#ifdef USING_DIRECTIONAL_LIGHT
    fixed atten = 1.0;
#else
    #if defined(POINT)
        float3 lightCoord = mul(unity_WorldToLight, float4(i.worldPos, 1)).xyz;
        fixed atten = tex2D(_LightTexture0, dot(lightCoord, lightCoord).rr).UNITY_ATTEN_CHANNEL;
    #elif defined(SPOT)
        float4 lightCoord = mul(unity_WorldToLight, float4(i.worldPos, 1));
        fixed atten = (lightCoord.z > 0) * tex2D(_LightTexture0, lightCoord.xy / lightCoord.w + 0.5).w * tex2D(_LightTextureB0, dot(lightCoord, lightCoord).rr).UNITY_ATTEN_CHANNEL;
    #else
        fixed atten = 1.0;
    #endif
#endif

点光源和聚光灯的lightCoord

上面代码展示出来的采样方式值得探究:

将世界坐标i.worldPos转换到光源的坐标空间lightCoorddot(lightCoord, lightCoord)则变成了光源到点距离的平方。

取平方是因为点光源的光强度衰减是平方衰减,光强 I I I和距离 r r r有 I = c ⋅ 1 r 2 I=c·\frac 1 {r^2} I=c⋅r21的关系,其中 c c c是常系数。

因为 tex2D第二个参数是float2 uv,所以需要.rr,相当于两个通道都取r通道(标量值只有r有效)

所以这里就是上面的.rr。冯乐乐书说

float distance = length(_WorldSpaceLightPos0.xyz - i.worldPosition.xyz);
// 线性衰减
atten = 1.0 / distance;

WikiBooks - Cg Programming/Unity/Light Attenuation

UNITY_ATTEN_CHANNEL是取得到衰减纹理中衰减值所在的分量

_LightTexture0_LightTextureB0是内置变量,用作查找表(LUT),其中B0额外是给点光源和聚光灯(带Cookie的光照)的光衰减用的。

所有的光源都走_LightTexture0,但是如果有cookie额外走_LightTextureB0

_LightTexture0 的信息是在Light Pass中生成的

查找表的定位是数学函数的近似(可以理解为对数学计算结果"打表")

WikiBooks - Cg Programming/Unity/Cookies

延迟渲染与Gbuffer

该路径下所有(不透明)几何体首先渲染到GBuffer,这个G是Geometry的G。在GBuffer中存储有关材质的信息(颜色、镜面反射、光滑度等等,不同版本的Unity其GBuffer下多个Buffer的内容可能有差异)。

特点:

  • 延迟渲染第一个 Pass 用来填充GBuffer的内容,每个物体都会执行且执行一次。
  • 每个像素按顺序着色:渲染时间将主要取决于影响每个像素的光源数量。
  • 由于延迟渲染的特性,它天然支持多个光源和复杂的光照计算,并且不会因为光源的增多导致性能显著下降。

缺点:

  • 不支持真正的抗锯齿功能,可以使用其他技术替代:FXAA(Fast Approximate Anti-Aliasing)、SMAA(Subpixel Morphological Anti-Aliasing)、TAA(Temporal Anti-Aliasing)等
  • 无法处理半透明物体(对于透明对象以及某些包含复杂着色器的对象,仍然需要额外的前向渲染通道)
  • 延迟渲染要求设备支持MRT(Multiple Render Targets,多重渲染目标),因为需要一次绘制调用输出多张纹理到GBuffer。
  • 在移动端,需要硬件支持 OpenGL ES 3.0 以上
  • 正交相机不支持延迟渲染

当处理包含许多动态光源的场景时(例如具有人工光照的内部空间,或室外与室内光照相结合的项目),通常建议使用延迟渲染。

光照流程

Unity Documentation - 选择和配置渲染管线和光照解决方案(建议反复阅读)

参考与拓展阅读

阿里云 - 复杂的光照(上)
简书 - URP多光源阴影处理
博客园 - Unity中的shadows(三)receive shadows(他对于一些宏有着解释,并且上一篇投射阴影也可以看看)
Unity Docs - URP 文档微型网站
Unity Discussion - 关于URP(以URP为主题的各种讨论)
YouTube - Custom Shadows in Unity URP using Shader Graph
Github - TwoTailsGames / Unity-Built-in-Shaders可以在这个仓库里看到包括AutoLight.cginc和UnityCG.cginc在内的多个引用文件的代码内容。
王烁的博客 - 关于Unity部分 简介说是盛大游戏项目组引擎负责人、技术专家,听起来有点NB的
知乎 - Unity Shader - 阴影方案总结

相关推荐
向宇it25 分钟前
【从零开始入门unity游戏开发之——unity篇02】unity6基础入门——软件下载安装、Unity Hub配置、安装unity编辑器、许可证管理
开发语言·unity·c#·编辑器·游戏引擎
oneouto1 小时前
selenium学习笔记(二)
笔记·学习·selenium
sealaugh321 小时前
aws(学习笔记第十九课) 使用ECS和Fargate进行容器开发
笔记·学习·aws
LuH11243 小时前
【论文阅读笔记】Scalable, Detailed and Mask-Free Universal Photometric Stereo
论文阅读·笔记
向宇it5 小时前
【从零开始入门unity游戏开发之——unity篇01】unity6基础入门开篇——游戏引擎是什么、主流的游戏引擎、为什么选择Unity
开发语言·unity·c#·游戏引擎
m0_748256785 小时前
WebGIS实战开源项目:智慧机场三维可视化(学习笔记)
笔记·学习·开源
红色的山茶花5 小时前
YOLOv9-0.1部分代码阅读笔记-loss.py
笔记
胡西风_foxww7 小时前
【es6复习笔记】Promise对象详解(12)
javascript·笔记·es6·promise·异步·回调·地狱
向宇it10 小时前
【从零开始入门unity游戏开发之——C#篇26】C#面向对象动态多态——接口(Interface)、接口里氏替换原则、密封方法(`sealed` )
java·开发语言·unity·c#·游戏引擎·里氏替换原则
吉大一菜鸡13 小时前
FPGA学习(基于小梅哥Xilinx FPGA)学习笔记
笔记·学习·fpga开发