-
Notifications
You must be signed in to change notification settings - Fork 40
PointLight
前置系列
本篇为SRP自定义渲染管线的点光源实现。先放个最终效果图
点光源向四面八方投射光亮,其辐射范围为一个球形,光照亮度随着辐射距离增加而减弱。
引用Unity文档中的一张图:
因此我们可以用最大辐射半径,光源颜色、光照强度三个变量来描述一个点光源。
在Shader中计算一个点所受的光照强度时,只需要计算其与点光源的距离,进行衰减即可。
不同的引擎,实现的点光源强度公式各有不同,但大致是遵循与距离平方呈反比的一个关系。为什么距离平方反比呢?可以参考如下这篇文章:
我们假设一个点光源的有效半径为r,空间中的一个点与光源距离为d。
令$x=\frac{d}{r}$
那么最直接的一个光强衰减公式为:
用函数绘图工具把曲线画出来,是这样的:
其中横坐标x是距离,纵坐标y是衰减因子。
可以看出当x为1时,y并不为0。因此当物体正好处于光源的有效半径边界处时,会产生光强突变(突然从有变为无)。
为了能平滑的使光强度在有效半径内衰减到0,不同的引擎有不同的实现。我看了一下URP中的实现,其对Mobile和Switch平台使用了一套公式,而对其余的平台使用了另一套公式,下面分别比较一下。
首先对于Mobile和Switch平台,URP使用的衰减公式如下:
$$ \begin{aligned} &smooth=saturate(\frac{r^2-x^2}{r^2-(0.8r)^2}) \ &L_{atten}=\frac{smooth}{x^2} \end{aligned}
$$
以上公式对应的衰减曲线如下:
可以看出,在x处于[0,0.8]
范围内时,曲线遵循1/x^2的衰减公式。而在[0.8,1]
范围内时,使用smooth参数使其迅速衰减到0。这种方式有一个缺点就是在0.8r处,衰减速度会突增。
再看URP在其余平台的衰减公式:
$$ \begin{aligned}
&smooth=saturate(1 - (\frac{x}{r})^4)^2 \ &L_{atten}=\frac{smooth}{x^2}
\end{aligned}
$$
衰减曲线如下:
该公式牺牲了与距离平方呈反比的精确性,但是保证了衰减的导数连续性。误差在距离光源越远的时候会越大,近处就还好。不过图形学上反正是看起来对那就是对的。所以此处我们准备采用这个公式来实现点光源强度的衰减。
首先考虑这么一个场景:
假设一个大场景中分布着100盏点光源,很明显并不是所有物体都会受100盏光源的影响。通常来说,从性能角度考虑,我们会根据光源距离物体的距离以及光源的重要程度进行排序,筛选出对物体贡献程度最高的几盏光源,来进行光照计算。 因此相比较平行光,我们在这里需要做如下额外的内容:
- 针对每个物体,计算出有哪些影响它的点光源要参与Shader阶段计算。
- 把这部分灯光数据传递到Shader中。
很可惜(也可能是很幸运),这部分功能目前Unity是封装在引擎中的,我们无法自定义,只能按照如下规则,来获取引擎计算后的结果:
- 在
DrawingSettings.perObjectData
中,开启LightData
和LightIndices
- 定义如下的CBUFFER,其中
unity_LightData.y
中记录了影响物体的光源数量,unity_LightIndices
每个分量记录了一盏灯光索引,累计最多8
盏灯光。这个索引即是cullingResults.visibleLights
的下标。
CBUFFER_START(UnityPerDraw)
real4 unity_LightData;
real4 unity_LightIndices[2];
CBUFFER_END
注意: 当我们在LightInput.hlsl中定义UnityPerDraw
的时候,会跟UnityShaderVariables.cginc
中的定义冲突,那是因为我们之前的Shader,引进了UnityCG.cginc
,而UnityCG.cginc
又引用了UnityShaderVariables.cgic
。 所以从现在开始,需要把UnityCG.cginc
从Shader引用中移除,并自己补充缺失的相关函数。
首先写一个CommonInput.hlsl,在里面定义UnityCG.cginc
移除后,相关缺失的内容:
float3 _WorldSpaceCameraPos;
float4x4 unity_MatrixVP;
#define TRANSFORM_TEX(tex, name) ((tex.xy) * name##_ST.xy + name##_ST.zw)
///UnityPerDraw是Unity引起内置约定好的一个CBUFFER,里面的变量名都是约定好的,不能修改
CBUFFER_START(UnityPerDraw)
float4x4 unity_ObjectToWorld;
float4x4 unity_WorldToObject;
float4 unity_LODFade; // x is the fade value ranging within [0,1]. y is x quantized into 16 levels
float4 unity_WorldTransformParams; // w is usually 1.0, or -1.0 for odd-negative scale transforms
float4 unity_LightData;
float4 unity_LightIndices[2];
CBUFFER_END
_WorldSpaceCameraPos
和unity_MatrixVP
都是引擎会自动赋值的,我们这只要定义就好了。
UnityPerDraw
和里面的变量名字,也都是引擎约定好的,我们只需要定义。
再写一个SpaceTransform.hlsl
,里面把坐标变换的一些函数也补上:
float4 ObjectToHClipPosition(float3 positionOS){
float4 positionWS = mul(unity_ObjectToWorld,float4(positionOS,1));
return mul(unity_MatrixVP,positionWS);
}
#define UnityObjectToClipPos ObjectToHClipPosition
这样兼容工作就完成了。
然后在LightInput.hlsl
中定义:
//场景同时生效的最大非主光源数量
#define MAX_OTHER_VISIBLE_LIGHT_COUNT 32
//非主光源的位置和范围,xyz代表位置,w代表范围
float4 _XOtherLightPositionAndRanges[MAX_OTHER_VISIBLE_LIGHT_COUNT];
//非主光源的颜色
half4 _XOtherLightColors[MAX_OTHER_VISIBLE_LIGHT_COUNT];
MAX_OTHER_VISIBLE_LIGHT_COUNT是PerCamera的,意思是一个摄像机中同时能生效32盏灯光。 要注意与前面的PerObject最多8盏灯光作好区分。
在Shader中完成变量定义后,我们需要在C#端的渲染管线中,将相关数据传递进GPU(主要是LightInput.hlsl中新定义的)
主要代码:
private void SetupOtherLightDatas(ref CullingResults cullingResults){
var visibleLights = cullingResults.visibleLights;
var lightMapIndex = cullingResults.GetLightIndexMap(Allocator.Temp);
var otherLightIndex = 0;
var visibleLightIndex = 0;
foreach(var l in visibleLights){
var visibleLight = l;
switch(visibleLight.lightType){
case LightType.Directional:
lightMapIndex[visibleLightIndex] = -1;
break;
case LightType.Point:
lightMapIndex[visibleLightIndex] = otherLightIndex;
SetPointLightData(otherLightIndex,ref visibleLight);
otherLightIndex ++;
break;
default:
lightMapIndex[visibleLightIndex] = -1;
break;
}
visibleLightIndex ++;
}
for(var i = visibleLightIndex; i < lightMapIndex.Length;i ++){
lightMapIndex[i] = -1;
}
cullingResults.SetLightIndexMap(lightMapIndex);
Shader.SetGlobalVectorArray(ShaderProperties.OtherLightPositionAndRanges,_otherLightPositionAndRanges);
Shader.SetGlobalVectorArray(ShaderProperties.OtherLightColors,_otherLightColors);
}
这个函数里面做了两件事情:
- 过滤出PointLight的数据,并赋值给数组
_XOtherLightPositionAndRanges[]
和_XOtherLightColors[]
- 修改LightIndexMap
首先看PointLight数据的赋值操作,比较简单:
private void SetPointLightData(int index,ref VisibleLight light){
Vector4 positionAndRange = light.light.gameObject.transform.position;
positionAndRange.w = light.range;
_otherLightPositionAndRanges[index] = positionAndRange;
_otherLightColors[index] = light.finalColor;
}
两个数组里分别记录了点光源的位置、有效范围以及颜色。
然后看LightIndexMap。 这个概念比较难理解一些,主要是负责了光源索引的再映射。几乎找不到相关的文档说明,我自己的理解大概是如下:
- 当Unity绘制一个物体的时候,首先会按重要程度排序光源。这里的光源是不分类型(即包括了平行光)以及不管其是否可见的。
- 从第一个光源开始检查,假设第一个光源的索引为i
- 然后Unity用i去LightIndexMap中作为key查询,得到value.
- 如果value是-1,那么表示该光源不起效,跳过,继续往下查询。
- 否则将value作为最终的索引值,写入到
unity_LightIndices
的分量中. - 当
unity_LightIndices
8个分量都写满或者灯光都遍历完成,就完成了unity_LightIndices
索引的建立。
LightIndexMap在默认情况下,key和value是相同的。 因为我们只过滤出PointLight,所以需要修改LightIndexMap进行再映射。这样unity_LightIndices
的分量,才能对应_XOtherLightPositionAndRanges[]
和_XOtherLightColors[]
的索引。
ScriptableRenderContext绘制的drawSettings
需要为perObjectData开启LightData和LightIndices功能。
drawSetting.perObjectData |= PerObjectData.LightData;
drawSetting.perObjectData |= PerObjectData.LightIndices;
基本思想就是遍历影响该物体的点光源,使用指定的光照模型为其着色(这里使用BlinnPhong模型)。着色公式为:
最终颜色 = 距离衰减因子 * 灯光颜色 * (漫反射项 + 高光项)
关键代码如下:
for(int i = 0; i < lightCount; i ++){
XOtherLight otherLight = GetOtherLight(i);
float3 lightPosition = otherLight.positionRange.xyz;
//range是光源的有效范围
float range = otherLight.positionRange.w;
float rangeSqr = range * range;
float3 lightVector = lightPosition - positionWS;
float3 lightDir = normalize(lightVector);
float distanceToLightSqr = dot(lightVector,lightVector);
//距离衰减系数
float atten = DistanceAtten(distanceToLightSqr,rangeSqr);
//高光项
half3 specular = BlinnPhongSpecular(viewDir,normal,lightDir,property.shininess) * property.specularColor;
//漫反射项
half3 diffuse = LambertDiffuse(normal,lightDir) * property.diffuseColor;
color += atten * otherLight.color.rgb * (diffuse + specular) ;
}