Unity 2D Light (3) - 半影

image

半影方案

以前用來生成lightMesh的端點掃描的方案並不適合生成ShadowMesh,主要緣由是光源體積邊緣的點和光源中心點的端點順序可能不一樣。雖然端點排序很快,但也不可能每一個半影區域都排一次,即便有優化方案,代碼的複雜度也會很高。前端

使用Shader繪製陰影(包括半影)比較簡單,並且效率很高。我的以爲它不能徹底替代生成lightMesh的方案。使用Shader實現的陰影僅僅是視覺效果,很難將受影或受光區域反饋給Unity。好比說角色在光照區域下有一些Buff之類的效果,優化好的lightMesh能夠比用射線檢測的效率高不少。git

目前我所知的半影方案有兩種:算法

  1. 繪製ShadowMesh(Mesh爲全部陰影區域),明確區分出半影區域,而後使用半影貼圖繪製半影區域。參考:dynamic 2d soft shadows
  2. 繪製ShadowMesh(Mesh爲全部陰影區域),在片元着色器裏計算遮擋值來繪製半影區域。參考:如何在unity實現足夠快的2d動態光照

看了SF soft Shadow 2d的陰影實現源碼,發現與方案2比較相似。它的ShadowMesh是在頂點做色器裏用一個很是巧妙的方法計算的。遮擋值計算比較複雜,雖然搞明白了它怎麼實現的,可是不清楚原理來源,屬於知其然而不知其因此然。c#

採用方案:使用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)的正方形:.net

image

若是修改頂點的W值3d

// UnityObjectToClipPos會將W修改成1,因此替換成如下代碼
o.vertex = mul(UNITY_MATRIX_VP, mul(unity_ObjectToWorld, float4(v.vertex.rgb, 0.5)));

結果是變大了。code

image

實際變化是以原點到頂點的向量方向除以W的值。orm

僅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);
}

image

image

當AB與CD相同且w趨近於0,那麼AB則無限遠,結果ABCD形狀就是原點對線段CD的投影。

image

// 簡寫,效率更高更合適。
// 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);
}

image

原理不是很清楚,由於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);
	}
}

image

image

目前基本上能夠替代以前的硬陰影。

4. 添加半影區域

image

  • AB與Light-Start垂直。
  • 灰色的半影區域能夠不計算,不須要徹底擬真的半影區。
  • 結束端點和開始端點的半影計算是鏡像問題。

A點的計算比較直觀的作法是Light-Start的單位向量旋轉90°乘以光源的半徑,可是因爲Light是原點,因此A點算法能夠簡化爲:

float _LightVolume; // 光源的體積半徑
vert {
    float2 A = _LightVolume * float2(-1, 1) * normalize(segStartPos).yx;
}

image

以前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)));
	}
}

image 修改_LightVolume結果

5. 瑕疵處理

當光源很是接近投影物時會致使出錯。

image

這是由於計算投影的點到了投影物的背面。

image

解決方法是判斷投影射線與投影邊的法線的是否同向,逆向爲正確。

如圖,B-End與法線seNormal方向相同,B'-End相反:

image

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 爲錯誤投影區域
}

image

投影區域遮擋值計算

首先須要在片元着色器得出模型座標,參考: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動態光照

image

自投影

投影物自身也有陰影。

image

同LightMesh同樣,一樣能夠無論,投影物單獨繪製。另一種解決方法是把投影物投影到自身的那條邊去掉。

image

在C#腳本中判斷那條須要去除應該比較麻煩,有一種比較簡單的方法是將開始端點和和結束端點交換,那麼在計算這條邊的遮擋值將老是爲0。

在計算遮擋值時會判斷P-Light和PA的左右,以前只有在半影處P-Light纔會在PA左邊,交換後P-Light總會在PA左邊(P-Light在PA右側會超出Mesh範圍)而且Light-P-Start角度老是大於A-P-Start。因此計算結果老是0。

image

肯定須要交換自投影邊只須要將以前排序的中心點由光源位置改成投影物中心便可。

// SortSegment(light.transform.position, segments);
SortSegment(caster.transform.position, segments);

image 自投影邊爲AD,AB

image

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);
}

image

最後結果大體是這樣,其中用於計算遮擋值的A-Start向量是必然在第二象限裏,其餘值必然在第1、四象限。

片元着色器裏,penumbras會逐漸插值到第1、四象限。A-Start在插值到1、四象限的過程當中,i.penumbras.x / i.penumbras.y遮擋值會遞增到0而後直到第一或者四象限,因此使用step(i.penumbras.y, 0.0)來避免第四象限的負值。

image

一樣的方法計算右側遮擋值,二者相加-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);
}

替換陰影貼圖

image

源碼

link

參考

如何在unity實現足夠快的2d動態光照

dynamic 2d soft shadows

相關文章
相關標籤/搜索