
半影方案
之前用來生成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 = https://www.cnblogs.com/lain-vv/archive/2020/11/15/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 = segmentDelta.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 = https://www.cnblogs.com/lain-vv/archive/2020/11/15/lerp(p, 1.0, step(i.penumbras.yw, 0.0));
float occlusion = (value[0] + value[1] - 1.0);
return occlusion*step(projectionVecDirFactor, 0);
}
替換陰影貼圖

參考
如何在unity實作足夠快的2d動態光照
dynamic 2d soft shadows
轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/217330.html
標籤:其他
