【Unity Shader】剖析Unity Surface Shader背后机制(二)

概要

上一篇结尾,给出了一个最简单的Diffuse的surface shader翻译成vertex/fragment shader之后的代码。乍看上去可能一头雾水,下面将会一一分解。
整体来看,相较于surface shader,unity自动生成了两个pass(ForwardBase,ForwardAdd),这两个pass的作用,在上一篇中也已经说明。接下去,对照着代码,我们来分解下unity具体做了些什么。

ForwardBase

Tags { "LightMode" = "ForwardBase" }

告诉渲染管线,这个pass作为ForwardBase处理,缺少它将画不出任何东西。

#pragma multi_compile_fwdbase

unity官方文档,只有multi_compile的说明,对于multi_compile_fwdbase可谓只字未提,网上唯一能搜到的一篇也是一语带过(虽然不甚详细,但是非常感谢作者分享)。后来打开unity shader面板的Variants里看到:

DIRECTIONAL LIGHTMAP_OFF DIRLIGHTMAP_OFF SHADOWS_OFF
DIRECTIONAL LIGHTMAP_ON DIRLIGHTMAP_OFF SHADOWS_OFF
DIRECTIONAL LIGHTMAP_ON DIRLIGHTMAP_ON SHADOWS_OFF
DIRECTIONAL LIGHTMAP_OFF DIRLIGHTMAP_OFF SHADOWS_SCREEN
DIRECTIONAL LIGHTMAP_ON DIRLIGHTMAP_OFF SHADOWS_SCREEN
DIRECTIONAL LIGHTMAP_ON DIRLIGHTMAP_ON SHADOWS_SCREEN
DIRECTIONAL LIGHTMAP_OFF DIRLIGHTMAP_OFF SHADOWS_OFF VERTEXLIGHT_ON
DIRECTIONAL LIGHTMAP_OFF DIRLIGHTMAP_OFF SHADOWS_SCREEN VERTEXLIGHT_ON
DIRECTIONAL LIGHTMAP_OFF DIRLIGHTMAP_OFF SHADOWS_SCREEN SHADOWS_NATIVE
DIRECTIONAL LIGHTMAP_ON DIRLIGHTMAP_OFF SHADOWS_SCREEN SHADOWS_NATIVE
DIRECTIONAL LIGHTMAP_ON DIRLIGHTMAP_ON SHADOWS_SCREEN SHADOWS_NATIVE
DIRECTIONAL LIGHTMAP_OFF DIRLIGHTMAP_OFF SHADOWS_SCREEN SHADOWS_NATIVE VERTEXLIGHT_ON

很多看过unity生成的vertex/fragment代码的同学,会感到很困惑,代码里充斥着各种条件编译代码,然而unity也没有给出文档,到底有哪些keyword?各自的作用是什么?
看到这个就非常清楚了,unity为forwardbase pass定义的keyword都在这里,而multi_compile_fwdbase就是unity专门为forwardbase预定义的multi_compile。
如果再打开unity最终编译成的glsl代码的话,可以看到,unity为每一组keywards都生成了单独的代码。
如果缺少这行代码的话,那么unity默认会编译

DIRECTIONAL LIGHTMAP_ON DIRLIGHTMAP_ON SHADOWS_OFF

这一组条件,碰到其他的情况,那么渲染就会出错。
我们看到,上述每一组条件,都是DIRECTIONAL,那么如果场景中没有平行光呢?点光源或者聚光灯还会起作用么?
答案是不会。forwardbase pass只能以逐像素的方式处理平行光,点光源和聚光灯都会被忽略掉,对应的_LightColor0都将是黑色。注意上面黑体的逐像素,因为可能有人会说,我场景里只有一个点光源,可以把场景照亮。是的,但它是以逐顶点的方式照亮的,后面将会细说。

// vertex-to-fragment interpolation data
#ifdef LIGHTMAP_OFF
struct v2f_surf {
  float4 pos : SV_POSITION;
  float2 pack0 : TEXCOORD0;
  fixed3 normal : TEXCOORD1;
  fixed3 vlight : TEXCOORD2;
  LIGHTING_COORDS(3,4)
};
#endif
#ifndef LIGHTMAP_OFF
struct v2f_surf {
  float4 pos : SV_POSITION;
  float2 pack0 : TEXCOORD0;
  float2 lmap : TEXCOORD1;
  LIGHTING_COORDS(2,3)
};

unity定义了v2f结构,有无lightmap的两个版本。

LIGHTING_COORDS(3,4)

这个定义在AutoLight.cginc中,它定义了光照和阴影坐标。因为forwardbase只支持平行光,因为平行光的特性,受到的光照强度是一样的,所以并不需要光照坐标,如果投射阴影的话,只需要阴影坐标来采样shadow map。(稍后再forwardadd章节中,会详细讨论不同光源的光照计算)
现在只要知道,这个宏的定义类似如下所示。参数3,4就是下个可用的TEXCOORD序号。

#define LIGHTING_COORDS(idx1,idx2) float3 _LightCoord : TEXCOORD##idx1; SHADOW_COORDS(idx2)

接下去就是填充v2f结构,计算齐次空间坐标,uv坐标,normal或者lightmap的uv坐标。就如我们平时写vertex/fragment shader那样,就不累述了。

float3 shlight = ShadeSH9 (float4(worldN,1.0));

这里是计算球谐光照,没有作为像素光照和顶点光照处理的光源,都在这里计算,包括light probe。

o.vlight += Shade4PointLights (
    unity_4LightPosX0, unity_4LightPosY0, unity_4LightPosZ0,
    unity_LightColor[0].rgb, unity_LightColor[1].rgb, unity_LightColor[2].rgb, unity_LightColor[3].rgb,
    unity_4LightAtten0, worldPos, worldN );

这里计算顶点光照,unity只能处理4个顶点光源。unity在上层处理掉了很多东西。如果光源影响到该物体,那么光源的属性(位置,原色,衰减)都会被设置,反之则会直接被忽略。具体计算过程在UnityCG.cginc中,如下所示:

float3 Shade4PointLights (
    float4 lightPosX, float4 lightPosY, float4 lightPosZ,
    float3 lightColor0, float3 lightColor1, float3 lightColor2, float3 lightColor3,
    float4 lightAttenSq,
    float3 pos, float3 normal)
{
    // to light vectors
    float4 toLightX = lightPosX - pos.x;
    float4 toLightY = lightPosY - pos.y;
    float4 toLightZ = lightPosZ - pos.z;
    // squared lengths
    float4 lengthSq = 0;
    lengthSq += toLightX * toLightX;
    lengthSq += toLightY * toLightY;
    lengthSq += toLightZ * toLightZ;
    // NdotL
    float4 ndotl = 0;
    ndotl += toLightX * normal.x;
    ndotl += toLightY * normal.y;
    ndotl += toLightZ * normal.z;
    // correct NdotL
    float4 corr = rsqrt(lengthSq);
    ndotl = max (float4(0,0,0,0), ndotl * corr);
    // attenuation
    float4 atten = 1.0 / (1.0 + lengthSq * lightAttenSq);
    float4 diff = ndotl * atten;
    // final color
    float3 col = 0;
    col += lightColor0 * diff.x;
    col += lightColor1 * diff.y;
    col += lightColor2 * diff.z;
    col += lightColor3 * diff.w;
    return col;
}

可以看到,顶点光照就是用简单的Lambert光照方程。

float4 corr = rsqrt(lengthSq);
ndotl = max (float4(0,0,0,0), ndotl * corr);

需要注意的是,因为法向量是归一化的,而光源方向未归一化,所以要乘以顶点离光源距离的平方根的倒数,得到光源和法线夹角的余弦。
lightAttenSq这个值也是由unity设定的,平行光恒为1,点光源和聚光灯随着距离增大而递减。

TRANSFER_VERTEX_TO_FRAGMENT(o);

这个的定义也是在AutoLight.cginc中,它计算之前定义的光照和阴影坐标的值。
点光源宏的定义如下:

#define TRANSFER_VERTEX_TO_FRAGMENT(a) a._LightCoord = mul(_LightMatrix0, mul(_Object2World, v.vertex)).xyz; TRANSFER_SHADOW(a)

实际就是把顶点坐标转换到光源空间中。

fixed atten = LIGHT_ATTENUATION(IN);

这个也是定义在AutoLight.cginc中,它就是把之前得到的光照和阴影坐标,采样光照图和shadow map,来得出光源的最终衰减值。
点光源的宏定义如下:

#define LIGHT_ATTENUATION(a)    (tex2D(_LightTexture0, dot(a._LightCoord,a._LightCoord).rr).UNITY_ATTEN_CHANNEL * SHADOW_ATTENUATION(a))

那么这里的_LightTexture0又是什么?在哪里设置的?别急,在forwardadd章节会讲到,这里对这个宏定义的作用有个认识就可以了。

c = LightingLambert (o, _WorldSpaceLightPos0.xyz, atten);

unity定义的光照方程都在Lighting.cginc这个文件中,需要注意的是,因为forwarbase只支持平行光,而如果光源类型是平行光,那么_WorldSpaceLightPos0这个变量保存的直接是光源方向。所以这里直接作为第二个参数,传入光照方程。当然也可以定义自己的光照方程,具体戳这里

ForwardAdd

Tags { "LightMode" = "ForwardAdd" }

同样告诉渲染管线,这个pass作为forwardadd处理。这里处理额外的像素光源。

#pragma multi_compile_fwdadd

这个也是unity为forwardadd pass定制的multi_compile,打开Viriants看到如下:

POINT
DIRECTIONAL
SPOT
POINT_COOKIE
DIRECTIONAL_COOKIE

unity会把forwardadd pass分别编译成适用于上述5种不同光源类型的版本。如果缺少这一行代码,那么unity只会编译DIRECTIONAL这一种情况。

ZWrite Off Blend One One Fog { Color (0,0,0,0) }

注意一下add pass有别于base pass的地方,因为之前base写过深度了,所以add就不用再次写深度了,以叠加的方式渲染到缓存,并且不受雾效影响。
整个add pass相较于base pass显得精简很多,它只考虑实时的光照计算,没有顶点光照,SH,lightmap,阴影这一系列东西。但是,它支持点光源,聚光灯以及cookie。接下去分别讲下,不同光源的光照处理(这里不讨论阴影部分的处理)。

平行光

平行光可以说是最简单的,因为它没有衰减。在AutoLight.cginc中找到unity中对平行光光照的定义:

#ifdef DIRECTIONAL
    #define LIGHTING_COORDS(idx1,idx2) SHADOW_COORDS(idx1)
    #define TRANSFER_VERTEX_TO_FRAGMENT(a) TRANSFER_SHADOW(a)
    #define LIGHT_ATTENUATION(a)    SHADOW_ATTENUATION(a)
#endif

可以看到,只有对阴影部分处理的代码,光照部分是空的。因此,不考虑阴影的话,平行光的衰减值永远是1。

点光源

在AutoLight.cginc中找到unity中对点光源光照的定义:

#ifdef POINT
#define LIGHTING_COORDS(idx1,idx2) float3 _LightCoord : TEXCOORD##idx1; SHADOW_COORDS(idx2)
uniform sampler2D _LightTexture0;
uniform float4x4 _LightMatrix0;
#define TRANSFER_VERTEX_TO_FRAGMENT(a) a._LightCoord = mul(_LightMatrix0, mul(_Object2World, v.vertex)).xyz; TRANSFER_SHADOW(a)
#define LIGHT_ATTENUATION(a)    (tex2D(_LightTexture0, dot(a._LightCoord,a._LightCoord).rr).UNITY_ATTEN_CHANNEL * SHADOW_ATTENUATION(a))
#endif

_LightMatrix0是由unity设置的变换矩阵,把世界空间的顶点,变换到光源空间中。而_LightTexture0是一张由unity生成的渐变图,8位的alpha图,如下图所示:
这里写图片描述
[图-1 点光源的_LightTexture0贴图]

UNITY_ATTEN_CHANNEL 也就是alpha通道。最终用光照坐标来采样_LightTexture0得到光照的衰减值。

聚光灯

同样在AutoLight.cginc中找到对聚光灯的光照定义:

#ifdef SPOT
#define LIGHTING_COORDS(idx1,idx2) float4 _LightCoord : TEXCOORD##idx1; SHADOW_COORDS(idx2)
uniform sampler2D _LightTexture0;
uniform float4x4 _LightMatrix0;
uniform sampler2D _LightTextureB0;
#define TRANSFER_VERTEX_TO_FRAGMENT(a) a._LightCoord = mul(_LightMatrix0, mul(_Object2World, v.vertex)); TRANSFER_SHADOW(a)
inline fixed UnitySpotCookie(float4 LightCoord)
{
    return tex2D(_LightTexture0, LightCoord.xy / LightCoord.w + 0.5).w;
}
inline fixed UnitySpotAttenuate(float3 LightCoord)
{
    return tex2D(_LightTextureB0, dot(LightCoord, LightCoord).xx).UNITY_ATTEN_CHANNEL;
}
#define LIGHT_ATTENUATION(a)    ( (a._LightCoord.z > 0) * UnitySpotCookie(a._LightCoord) * UnitySpotAttenuate(a._LightCoord.xyz) * SHADOW_ATTENUATION(a) )
#endif

可以看到,聚光灯相较于点光源,新增了一张光照贴图,用来表示聚光灯的光照范围,如下图所示:
这里写图片描述
[图-2 聚光灯的_LightTexture0贴图]

这里写图片描述
[图-3 聚光灯的_LightTextureB0贴图]
这也符合聚光灯的特性,除了距离的衰减之外,投射到平面的光照范围再通过采样[图-2]来确定,哪里应该被照亮。

总结

以上对于由unity把一个最简单的surface shader转换为vertex/fragment shader之后的代码,做了具体的分析。但也并非面面俱到。限于作者的水平,对于SH,为何用dot(LightCoord, LightCoord).xx来作为uv坐标采样[图-1]光照图,用LightCoord.xy / LightCoord.w + 0.5来采样[图-2],也不理解,如果有大牛知道,还望告知,不胜感激。

  • 3
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值