Unity 2D Light (3) - 半影
半影方案
之前用来生成lightMesh的端点扫描的方案并不适合生成ShadowMesh,主要原因是光源体积边缘的点和光源中心点的端点顺序可能不同。虽然端点排序很快,但也不可能每个半影区域都排一次,即使有优化方案,代码的复杂度也会很高。
使用Shader绘制阴影(包括半影)比较简单,而且效率很高。个人觉得它不能完全替代生成lightMesh的方案。使用Shader实现的阴影仅仅是视觉效果,很难将受影或受光区域反馈给Unity。比如说角色在光照区域下有一些Buff之类的效果,优化好的lightMesh可以比用射线检测的效率高很多。
目前我所知的半影方案有两种:
- 绘制ShadowMesh(Mesh为所有阴影区域),明确区分出半影区域,然后使用半影贴图绘制半影区域。参考:dynamic 2d soft shadows
- 绘制ShadowMesh(Mesh为所有阴影区域),在片元着色器里计算遮挡值来绘制半影区域。参考:如何在unity实现足够快的2d动态光照
看了SF soft Shadow 2d的阴影实现源码,发现与方案2比较类似。它的ShadowMesh是在顶点作色器里用一个非常巧妙的方法计算的。遮挡值计算比较复杂,虽然搞明白了它怎么实现的,但是不清楚原理来源,属于知其然而不知其所以然。
采用方案:使用sf shadow中的方法来实现ShadowMesh,遮挡值计算使用方案2中方法,大部分计算都在着色器里。
ShadowMesh
单个线段投影区域计算方法
在顶点着色器里需要将模型空间顶点转化为裁剪空间顶点。
o.vertex = mul(UNITY_MATRIX_MVP, v.vertex);
// unit 会自动转化为
o.vertex = UnityObjectToClipPos(v.vertex);
顶点和uv都为(0,0) (1,0) (0,1) (1,1)的正方形:
如果修改顶点的W值
// UnityObjectToClipPos会将W修改为1,所以替换成以下代码
o.vertex = mul(UNITY_MATRIX_VP, mul(unity_ObjectToWorld, float4(v.vertex.rgb, 0.5)));
结果是变大了。
实际变化是以原点到顶点的向量方向除以W的值。
仅uv.y=1的时候修改w值。
if(i.uv.y == 1){
o.vertex = mul(UNITY_MATRIX_VP, mul(unity_ObjectToWorld, float4(v.vertex.rgb, 0.5)));
} else {
o.vertex = UnityObjectToClipPos(v.vertex);
}
当AB与CD相同且w趋近于0,那么AB则无限远,结果ABCD形状就是原点对线段CD的投影。
// 简写,效率更高更合适。
// o.vertex = mul(UNITY_MATRIX_VP, mul(unity_ObjectToWorld, float4(v.vertex.rgb, 1 - uv.y)));
// 为了方便理解我都是用的条件语句
if(i.uv.y == 1){
// 让w趋近为0,直接为0会出错,应该是之后的齐次除法导致的。
o.vertex = mul(UNITY_MATRIX_VP, mul(unity_ObjectToWorld, float4(v.vertex.rgb, 0.0001)));
} else {
o.vertex = UnityObjectToClipPos(v.vertex);
}
原理不是很清楚,因为w在空间变换MV阶段只影响平移,猜测可能是投影和齐次变化那里导致的。因为是非正常用法,目前就不打算深究了。
投影物投影区域计算
1. 线段端点顺序
线段无需排序,但是线段端点的开始结束排序影响三角网格的正反(嫌麻烦可以直接Cull Off)和之后的投影物自投影的去除。先同LightMesh一样按照逆时针开始端点在前、结束端点在后。
2. Mesh数据
在c#脚本上为每个投影物的每条线段准备一个正方形Mesh的数据(4个顶点)。
var verts = new List<Vector3>();
var tangents = new List<Vector4>();
var uvs = new List<Vector2>();
var triangles = new List<int>();
var toLightCoord = light.transform.worldToLocalMatrix;
int i = 0;
foreach (var caster in shadowCasters)
{
// 从阴影投射物体的模型坐标转换到 光源的模型坐标 的转换矩阵
var transMatrix = toLightCoord * caster.transform.localToWorldMatrix;
var segments = caster.GetSegments();
// 同LightMesh,逆时针开始端点在前、结束端点在后
SortSegment(light.transform.position, segments);
foreach (var seg in segments)
{
var startPos = transMatrix.MultiplyPoint(seg.start);
var endPos = transMatrix.MultiplyPoint(seg.end);
var segmentData = new Vector4(startPos.x, startPos.y, endPos.x, endPos.y);
// 4个顶点通道暂时用不到,可以将Matrial所用数据放到顶点通道里来优化
verts.Add(Vector3.zero); verts.Add(Vector3.zero); verts.Add(Vector3.zero); verts.Add(Vector3.zero);
// 使用切线通道放置线段数据
tangents.Add(segmentData); tangents.Add(segmentData); tangents.Add(segmentData); tangents.Add(segmentData);
// uv数据,用来在顶点着色器中判断顶点所属位置
uvs.Add(new Vector2(0, 0)); uvs.Add(new Vector2(1, 0)); uvs.Add(new Vector2(0, 1)); uvs.Add(new Vector2(1, 1));
// 两个三角面片
// 因为以线段端点作为顶点,所有排序以端点排序为准即逆时针排序
// Cull Off 则无所谓正反序
triangles.Add(i * 4 + 0); triangles.Add(i * 4 + 1); triangles.Add(i * 4 + 2);
triangles.Add(i * 4 + 1); triangles.Add(i * 4 + 3); triangles.Add(i * 4 + 2);
i++;
}
}
shadowMesh.vertices = verts.ToArray();
shadowMesh.triangles = triangles.ToArray();
shadowMesh.uv = uvs.ToArray();
shadowMesh.tangents = tangents.ToArray();
3. 顶点着色器计算投影区域
与LightMesh不同,这里阴影的颜色为1,非阴影为0,这么做是为了方便之后混合阴影。
vert {
// 开始端点、结束端点
float2 segStartPos = v.segment.xy;
float2 segEndPos = v.segment.zw;
// 通过uv.x获取当前端点位置
float2 currentPos = lerp(segStartPos, segEndPos, v.uv.x);
// 简写
// o.vertex = mul(UNITY_MATRIX_VP, mul(unity_ObjectToWorld, float4(currentPos, 0, 1 - v.uv.y)));
if (v.uv.y == 1) {
o.vertex = mul(UNITY_MATRIX_VP, mul(unity_ObjectToWorld, float4(currentPos, 0, 0.0001)));
}
else {
o.vertex = UnityObjectToClipPos(currentPos);
}
}
目前基本上可以替代之前的硬阴影。
4. 添加半影区域
- AB与Light-Start垂直。
- 灰色的半影区域可以不计算,不需要完全拟真的半影区。
- 结束端点和开始端点的半影计算是镜像问题。
A点的计算比较直观的做法是Light-Start的单位向量旋转90°乘以光源的半径,但是由于Light是原点,所以A点算法可以简化为:
float _LightVolume; // 光源的体积半径
vert {
float2 A = _LightVolume * float2(-1, 1) * normalize(segStartPos).yx;
}
之前uv.y = 1
投影射线是Light-Start,现在改为A-Start,结束端用B-End。
vert {
float2 A = _LightVolume * float2(-1, 1) * normalize(segStartPos).yx;
float2 B = _LightVolume * float2(1, -1) * normalize(segEndPos).yx;
float2 projectionOffset = lerp(A, B, v.uv.x);
if (v.uv.y == 1) {
o.vertex = mul(UNITY_MATRIX_VP, mul(unity_ObjectToWorld, float4(currentPos - projectionOffset, 0, 0.0001)));
}
}
修改_LightVolume结果
5. 瑕疵处理
当光源非常接近投影物时会导致出错。
这是因为计算投影的点到了投影物的背面。
解决方法是判断投影射线与投影边的法线的是否同向,逆向为正确。
如图,B-End与法线seNormal方向相同,B'-End相反:
vert {
float2 seVec = segEndPos - segStartPos;
float2 seNormal = seVec.yx*float2(-1.0, 1.0);
// 简写
//projectionVecDirFactor = dot(seNormal, currentPos - projectionOffset * v.uv.y - currentPos * (1.0 - v.uv.y));
if (v.uv.y == 1) {
projectionVecDirFactor = dot(seNormal, currentPos - projectionOffset); // 点乘判断方向
}
else {
projectionVecDirFactor = 0;
}
}
frag {
projectionArea = projectionArea*step(projectionVecDirFactor, 0) // projectionVecDirFactor > 0 为错误投影区域
}
投影区域遮挡值计算
首先需要在片元着色器得出模型坐标,参考:Unity从深度缓冲重建世界空间位置。默认是没有深度信息,但是一般来说2D游戏大部分使用正交相机,所以可以不需要深度。如果用的是透视相机那么可能需要在c#脚本手动计算深度,我这里用的是正交相机。
vert {
o.screenPos = ComputeScreenPos(o.vertex);
}
frag {
float4 ndcPos = (i.screenPos / i.screenPos.w) * 2 - 1;
float3 viewVec = float3(unity_OrthoParams.xy * ndcPos.xy, 0);
// 观察空间z分量赋值为想要的深度
float3 viewPos = float3(viewVec.xy, 0);
float3 worldPos = mul(UNITY_MATRIX_I_V, float4(viewPos, 1)).xyz;
// 世界坐标 - 光源位置 = 模型坐标
float2 objPos = worldPos.xy - _LightPos.xy;
}
有了模型坐标,线段信息,光源位置那么便可以计算遮挡值。关于遮挡值的计算详细参考:如何在unity实现足够快的2d动态光照。
自投影
投影物自身也有阴影。
同LightMesh一样,同样可以不管,投影物单独绘制。另外一种解决方法是把投影物投影到自身的那条边去掉。
在C#脚本中判断那条需要去除应该比较麻烦,有一种比较简单的方法是将开始端点和和结束端点交换,那么在计算这条边的遮挡值将总是为0。
在计算遮挡值时会判断P-Light和PA的左右,之前只有在半影处P-Light才会在PA左边,交换后P-Light总会在PA左边(P-Light在PA右侧会超出Mesh范围)并且Light-P-Start角度总是大于A-P-Start。所以计算结果总是0。
确定需要交换自投影边只需要将之前排序的中心点由光源位置改为投影物中心即可。
// SortSegment(light.transform.position, segments);
SortSegment(caster.transform.position, segments);
自投影边为AD,AB
SF soft Shadow 2d 的遮挡值计算(未验证)
这是左侧的遮挡值计算
// 逆矩阵
float2x2 invert2x2(float2 basisX, float2 basisY) {
float2x2 m = float2x2(basisX, basisY);
return float2x2(m._m11, -m._m10, -m._m01, m._m00) / determinant(m);
}
vert {
float2 projectionVec = currentPos - projectionOffset; // 投影向量
if (v.uv.y == 1) {
o.penumbras = mul(invert2x2(A, segStartPos), projectionVec); // 空间变换,以Light-A为X轴,Light-Start为Y轴将投影向量转回模型空间内 ?? 不是很确定,因为这两个向量都不是单位向量
}
else {
o.penumbras = mul(invert2x2(A, segStartPos), currentPos - segStartPos); // uv.x = 0 为float2(0,0),uv.x = 1 时将投影线段Start-End以Light-A为X轴,Light-Start为Y轴将投影向量转回模型空间内?? 不是很确定,因为这两个向量都不是单位向量
}
}
frag {
float p = clamp(i.penumbras.x / i.penumbras.y, -1.0, 1.0);
p = p * (3.0 - p * p) * 0.25 + 0.5; // 平滑函数、和smoothstep(0, 1, x)类似,在变化开始和结束停留更长,使半影更明显
float occlusion = lerp(p, 1.0, step(i.penumbras.y, 0.0)); // 防止插值到第四象限的值
return occlusion*step(projectionVecDirFactor, 0);
}
最后结果大致是这样,其中用于计算遮挡值的A-Start向量是必然在第二象限里,其他值必然在第一、四象限。
片元着色器里,penumbras会逐渐插值到第一、四象限。A-Start在插值到一、四象限的过程中,i.penumbras.x / i.penumbras.y
遮挡值会递增到0然后直到第一或者四象限,所以使用step(i.penumbras.y, 0.0)
来避免第四象限的负值。
同样的方法计算右侧遮挡值,两者相加-1即可得到最终的遮挡值。
Pseudo Code:
// 逆矩阵 float2x2 invert2x2(float2 basisX, float2 basisY) { float2x2 m = float2x2(basisX, basisY); return float2x2(m._m11, -m._m10, -m._m01, m._m00) / determinant(m); } vert { float2 projectionVec = currentPos - projectionOffset; if (v.uv.y == 1) { float2 penumbraA = mul(invert2x2(A, segStartPos), projectionVec); float2 penumbraB = mul(invert2x2(B, segEndPos), projectionVec); o.penumbras = float4(penumbraA, penumbraB); } else { float2 penumbraA = mul(invert2x2(A, segStartPos), currentPos - segStartPos); float2 penumbraB = mul(invert2x2(B, segEndPos), currentPos - segEndPos); o.penumbras = float4(penumbraA, penumbraB); } }
frag {
float2 p = clamp(i.penumbras.xz / i.penumbras.yw, -1.0, 1.0);
p = p * (3.0 - p * p) * 0.25 + 0.5;
float2 value = lerp(p, 1.0, step(i.penumbras.yw, 0.0));
float occlusion = (value[0] + value[1] - 1.0);
return occlusion*step(projectionVecDirFactor, 0);
}