前言
整体上参考了YivanLee大佬的这两篇文:
虚幻4渲染编程(灯光篇)【第一卷:各种ShadowMap】
虚幻4渲染编程(灯光篇)【第二卷:体积光】
正文
1、ShadowMap
(1)创建工程
先创建一个第三人称的C++工程,新增一个materials文件夹存放ShadowMap和体积光材质。
(2)获取光源位置及变换矩阵
ShadowMap简单来说在光源位置放一个摄像机,保存这个摄像机渲染出来的深度纹理。对于想要显示阴影的材质,获取当前像素的世界空间坐标,变换到光源摄像机的裁剪空间,用像素的Z分量(深度)与深度纹理对应UV的深度值比较。如果像素的Z分量大于深度纹理的深度值,表示该像素处于阴影中。
为了在虚幻引擎中实现上述效果,首先我们需要捕获光源摄像机的深度纹理,这里需要“场景捕获2D”组件,将其放置在场景中充当光源。
之后,在内容浏览器中右键->材质和纹理->渲染目标,创建渲染目标用于保存光源摄像机渲染出的深度问题。
之后回到光源摄像机,在其细节栏中添加刚才创建的渲染目标,捕获源选择场景深度。投射类型选择透视投影,这里实现的是点光源的阴影(阴影会在不同方向扭曲变形),如果想实现平行光的阴影需要将投射类型改成正交投影(后续会简单介绍实现方法)。
至此,我们已经拿到了光源摄像机的深度纹理,接下来我们需要将像素的世界坐标转换到光源摄像机的裁剪空间坐标。这里需要用到OpenGL中MVP矩阵的相关知识。裁剪空间实际上是投影空间的子空间(即摄像机可见的部分),因此我们需要构造出光源摄像机的VP矩阵(View,projection)。
首先是View矩阵,参考LookAt矩阵的公式可知,我们需要获取光源摄像机的右向量,上向量,方向向量(这里说成前向量我觉得更好理解)以及摄像机位置。
我们给光源摄像机(即场景捕获2DActor)添加C++组件,在其BeginPlay()中添加如下代码获取上述数据。其中向量ViewColX、ViewColY、ViewColZ、ViewColW为View矩阵每行的分量。可以看到,我们构造出来的View矩阵实际是LookAt矩阵的转置矩阵。原因后面会解释。另外,这里不要使用虚幻自带的函数计算View矩阵,这是因为虚幻引擎中X分量是前向量,而虚幻提供的透视投影矩阵函数是以Z分量为前向量计算的。因此我们需要自己构建出以Z分量为前向量的View矩阵。
ASceneCapture2D* owner = Cast<ASceneCapture2D>(GetOwner());
if (owner) {
owner->CalcCamera(0, ViewInfo);
FVector forwardV = owner->GetActorForwardVector();
FVector rightV = owner->GetActorRightVector()
FVector upV = owner->GetActorUpVector();
FVector loc = ViewInfo.Location;
FVector inw = FVector(-FVector::DotProduct(rightV, loc), -FVector::DotProduct(upV, loc), -FVector::DotProduct(forwardV, loc));
// 获取View矩阵列向量
FLinearColor ViewColX = FLinearColor(rightV.X, upV.X, forwardV.X, 0);
FLinearColor ViewColY = FLinearColor(rightV.Y, upV.Y, forwardV.Y, 0);
FLinearColor ViewColZ = FLinearColor(rightV.Z, upV.Z, forwardV.Z, 0);
FLinearColor ViewColW = FLinearColor(-FVector::DotProduct(rightV, loc), -FVector::DotProduct(upV, loc), -FVector::DotProduct(forwardV, loc), 1);
}
接着,我们需要构造出投影矩阵,由于光源摄像机用的是透视投影,这里也需要构造透视投影矩阵。我们使用虚幻引擎自带的函数创建,代码如下。
// 构建投影矩阵
float FOV = ViewInfo.FOV;
//float AspectRatio = ViewInfo.OrthoWidth/ ViewInfo.OffCenterProjectionOffset.X;
float heigh = ViewInfo.OrthoWidth / ViewInfo.AspectRatio;
float NearPlane = ViewInfo.OrthoNearClipPlane;
float FarPlane = ViewInfo.OrthoFarClipPlane;
// 注意:FOV要送入弧度
float rad = FMath::DegreesToRadians(FOV / 2);
ProjectionMatrix = FPerspectiveMatrix(rad, ViewInfo.OrthoWidth, heigh, NearPlane, FarPlane);
// 构建投影矩阵行向量
FLinearColor ProjectionMatrixColX = FLinearColor(ProjectionMatrix.M[0][0], ProjectionMatrix.M[0][1], ProjectionMatrix.M[0][2], ProjectionMatrix.M[0][3]);
FLinearColor ProjectionMatrixColY = FLinearColor(ProjectionMatrix.M[1][0], ProjectionMatrix.M[1][1], ProjectionMatrix.M[1][2], ProjectionMatrix.M[1][3]);
FLinearColor ProjectionMatrixColZ = FLinearColor(ProjectionMatrix.M[2][0], ProjectionMatrix.M[2][1], ProjectionMatrix.M[2][2], ProjectionMatrix.M[2][3]);
FLinearColor ProjectionMatrixColW = FLinearColor(ProjectionMatrix.M[3][0], ProjectionMatrix.M[3][1], ProjectionMatrix.M[3][2], ProjectionMatrix.M[3][3]);
可以看到这里没有对投影矩阵做转置,要明白其原因我们需要对比透视投影公式以及FPerspectiveMatrix函数源代码。可以看到虽然矩阵公式有所差异(其中的差异本人目前还没有完全理解),但FPerspectiveMatrix函数已经将投影矩阵转置了。
透视矩阵公式来源:透视投影矩阵推导
之后,再获取光源位置(可选,可以在后续的体积光中计算某个点的光强度)。
FLinearColor lightPos = FLinearColor(ViewInfo.Location.X, ViewInfo.Location.Y, ViewInfo.Location.Z, 1);
至此,光源摄像机的VP矩阵我们已经获取到了,接下来我们需要将这些矩阵传入ShadowMap的材质中。这里使用到虚幻引擎的材质参数集合,内容浏览器中右键->材质和纹理->材质参数集创建。
之后双击刚创建的材质参数集进入详情页,创建所需的标量参数及向量参数。
回到光源摄像机(即场景捕获2DActor)C++组件的BeginPlay()函数,获取刚才创建的材质参数集并将VP矩阵、光源位置等信息传入。代码如下。
UMaterialParameterCollection* ParameterCollection = LoadObject<UMaterialParameterCollection>(NULL, TEXT("MaterialParameterCollection'/Game/materials/matrixTransform.matrixTransform'"));
UMaterialParameterCollectionInstance* mpinst = GetWorld()->GetParameterCollectionInstance(ParameterCollection);
if (mpinst) {
mpinst->SetVectorParameterValue(FName("viewXcol"), ViewColX);
mpinst->SetVectorParameterValue(FName("viewYcol"), ViewColY);
mpinst->SetVectorParameterValue(FName("viewZcol"), ViewColZ);
mpinst->SetVectorParameterValue(FName("viewWcol"), ViewColW);
mpinst->SetVectorParameterValue(FName("perspectiveXcol"), ProjectionMatrixColX);
mpinst->SetVectorParameterValue(FName("perspectiveYcol"), ProjectionMatrixColY);
mpinst->SetVectorParameterValue(FName("perspectiveZcol"), ProjectionMatrixColZ);
mpinst->SetVectorParameterValue(FName("perspectiveWcol"), ProjectionMatrixColW);
mpinst->SetVectorParameterValue(FName("lightPos"), lightPos);
mpinst->SetScalarParameterValue(FName("zfar"), ViewInfo.OrthoFarClipPlane);
mpinst->SetScalarParameterValue(FName("znear"), ViewInfo.OrthoNearClipPlane);
}
至此,C++侧的准备工作完成,接下来是材质。
(3)创建材质
内容浏览器右键->材质创建shadowMap材质,并将其加载到需要显示阴影的Actor组件上(如地面)。然后进入材质详情面板。将上一小节创建的材质参数集拖到详情面板中即可获取材质参数集的数据。
获取像素的世界坐标,通过Transform3x3Matrix节点将世界坐标依次变换到视口空间(View)、透视投影空间(Projection)。
这里我们进入Transform3x3Matrix节点看下它的实现(如下图)。这里考虑3X3矩阵的情况(不考虑W分量),设输入向量三个分量R,G,B。用于变换的矩阵行分量X(X1, X2, X3),Y(Y1, Y2, Y3),Z(Z1, Z2, Z3)。正常的矩阵乘法有:
而该节点实现的矩阵乘法则是:
可以看到,变换矩阵是先转置在于输入向量相乘的。这也是为什么我们在第二小节需要将VP矩阵转置再送到材质参数集里。
像素的世界坐标经过VP矩阵变换后,得到了其在透视投影空间中的坐标。根据透视除法公式,我们给X,Y分量除以View空间下像素坐标的Z分量(通过透视投影矩阵公式可知透视投影空间下的W分量等于View空间下的Z分量),将摄像机可见部分的X、Y坐标限制在(-1, 1)之间。之后再将其压到(0, 1)之间作为UV去采样渲染目标的深度纹理(渲染目标也是通过拖入材质详情中使用),通过除2(乘0.5)加0.5实现(-1, 1)到(0, 1)。注意虚幻的UV左上角是(0, 0),右下角是(1, 1),而投影空间中心为(0, 0),右是X正方向,上是Y正方向,因此V分量需要取反(用1去减)。
通过UV获取到对应位置的深度之后,将其与投影空间下的Z值进行比较(这里需要加一点点偏移,不然会出现明暗条纹)。如果深度值小于投影空间下的Z值,说明该像素位于阴影中,渲染成黑色,反之为白色。
之后将输出值送给“自发光颜色”,大功告成。注意,这里插入的if是我用来处理X,Y不在(-1, 1)范围的情况的,这里就不额外介绍了。
(4)效果展示
(5)正交投影
这里在简单介绍下利用正交投影实现平行光阴影。首先将“场景捕获2D”组件的投射类型改为正交。C++侧通过函数FOrthoMatrix获取正交投影矩阵,送入材质参数集的方式不变。在材质中,获取UV的方式改为:
这里不用乘0.5再加0.5了,直接加0.5即可。原因在于FOrthoMatrix函数获取的矩阵,对比正交矩阵公式可知该函数返回的矩阵长度就是1,不需要再除以2了。
正交矩阵推导可参考:【计算机图形学基础】投影矩阵
2、体积光
(1)基本思路
通过后处理的方式,使用RayMarching算法,计算每个屏幕像素的光强度,再与屏幕纹理叠加。
(2)创建后处理材质
在虚幻引擎中,要使用后处理材质,首先需要一个后处理体积Actor作为载体。创建方式如下图。后处理材质贴在该体积上,玩家摄像机进入该体积时后处理材质生效。这里可以将该体积直接作为玩家角色的子Actor,使得后处理材质一直生效。
新建一个材质,材质域选择后期处理,这样该材质就可以贴到后期处理体积上使用啦。后期处理简单来说就是对渲染流程生成的一张张屏幕大小的图片进行处理,也可以理解是图像处理。
这里要使用材质里的custom节点(如下图),这是一个允许我们自己写HLSL代码的节点。输入参数及输出类型需要在细节一栏手动配置。这里的输入参数不需要定义类型,在代码中可以直接通过其变量名使用。
这个节点虽然支持我们自己写代码,但是不能直接定义函数。这里有一个坑,我们可以查看当前材质的着色器代码。
找到我们自定义的代码,可以发现我们的代码是放在一个预先定义好的函数里,函数内不能再定义函数。难道我们就不能在custom节点里定义函数了吗?其实是可以的,具体方法在第三小节介绍。
(3)实现RayMarching算法
RayMarching算法的原理网上有很多讲解,这里主要讲在虚幻引擎的材质中如何实现RayMarching算法。首先我们拿到像素点对应的世界坐标,以摄像机位置为起点,摄像机位置到该世界坐标的方向为步进方向。通过custom节点实现步进算法,输出该像素点的光强度,最后再与场景纹理叠加。custom节点代码、以及细节配置如下:
struct MB {
float3 transform(float3 inp, float3 x, float3 y, float3 z, float3 w)
{
float3 outx = inp.x * x;
float3 outy = inp.y * y;
float3 outz = inp.z * z;
float3 outxy = outx + outy;
float3 outzw = outz + w;
return outxy + outzw;
}
}BaseModel;
float lindensity = 0.0f;
float lengthperstep = 10;
float lightinsperlit = 1500;
float lightinsperunlit = 6000;
// pos为步进中的坐标,以摄像机的位置为起点
float3 pos = cameraPos;
for (int i = 0; i < (int)maxLength; i++)
{
// 坐标转换到光源摄像机View空间
float3 posInView = BaseModel.transform(pos, ViewXcol.xyz, ViewYcol.xyz, ViewZcol.xyz, ViewWcol.xyz);
// 坐标转换到光源摄像机透视投影空间
float3 posInPer = BaseModel.transform(posInView, PerXcol.xyz, PerYcol.xyz, PerZcol.xyz, PerWcol.xyz);
// 透视除法
posInPer.x = posInPer.x / posInView.z;
posInPer.y = posInPer.y / posInView.z;
float2 uv;
uv.x = (posInPer.x * 0.5 + 0.5);
uv.y = 1 - (posInPer.y * 0.5 + 0.5) ;
if (uv.x > 1 || uv.y > 1 || uv.x < 0 || uv.y < 0 || posInPer.z < 0) {
// 该坐标不在光源摄像机视口范围,不处理
pos = pos + (lengthperstep * lightVecNor);
continue;
}
// 光源摄像机深度纹理采样
float depth = Texture2DSample(DtextureMap, DtextureMapSampler, uv) + 1.5;
if (depth > posInPer.z) {
// 该坐标在光源内,加一点光强度
lindensity +=(lightinsperlit / (distance(pos, lightPos)*distance(pos, lightPos)));
}
else {
// 该坐标在阴影内,减一点光强度,这里是为了让暗的部分更突出
lindensity -= (lightinsperunlit / (distance(pos, lightPos)*distance(pos, lightPos)));
}
// lightVecNor为摄像机位置到像素坐标方向的单位向量
pos = pos + (lengthperstep * lightVecNor);
}
return lindensity;
对于第二小节定义函数的问题,在custom的代码中,我们可以定义一个结构体,在结构体内定义函数。通过结构体对象我们就可以调用函数啦。这里的custom节点看着吓人,其实算法本身不复杂,麻烦的部分是将材质节点Transform3x3Matrix代码化(代码中的transform函数)。