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

相关文章
相关标签/搜索