UE引擎实现ShadowMap、体积光(C++)

前言

        整体上参考了YivanLee大佬的这两篇文:

虚幻4渲染编程(灯光篇)【第一卷:各种ShadowMap】

虚幻4渲染编程(灯光篇)【第二卷:体积光】

正文

1、ShadowMap

(1)创建工程

        先创建一个第三人称的C++工程,新增一个materials文件夹存放ShadowMap和体积光材质。

e4be06bb0f8f40b2aad35636f8ceec5d.png

(2)获取光源位置及变换矩阵

        ShadowMap简单来说在光源位置放一个摄像机,保存这个摄像机渲染出来的深度纹理。对于想要显示阴影的材质,获取当前像素的世界空间坐标,变换到光源摄像机的裁剪空间,用像素的Z分量(深度)与深度纹理对应UV的深度值比较。如果像素的Z分量大于深度纹理的深度值,表示该像素处于阴影中。

        为了在虚幻引擎中实现上述效果,首先我们需要捕获光源摄像机的深度纹理,这里需要“场景捕获2D”组件,将其放置在场景中充当光源。

4c4e8959ca624ce5bbf0419539a0f28e.png

        之后,在内容浏览器中右键->材质和纹理->渲染目标,创建渲染目标用于保存光源摄像机渲染出的深度纹理。

1e9b14e5ce20426290e420c20e01f321.png

        之后回到光源摄像机,在其细节栏中添加刚才创建的渲染目标,捕获源选择场景深度。投射类型选择透视投影,这里实现的是点光源的阴影(阴影会在不同方向扭曲变形),如果想实现平行光的阴影需要将投射类型改成正交投影(后续会简单介绍实现方法)。

afabea138b2749b4b3feaac9a1e3f40e.png

        至此,我们已经拿到了光源摄像机的深度纹理,接下来我们需要将像素的世界坐标转换到光源摄像机的裁剪空间坐标。这里需要用到OpenGL中MVP矩阵的相关知识。裁剪空间实际上是投影空间的子空间(即摄像机可见的部分),因此我们需要构造出光源摄像机的VP矩阵(View,projection)。 

        首先是View矩阵,参考LookAt矩阵的公式可知,我们需要获取光源摄像机的右向量,上向量,方向向量(这里说成前向量我觉得更好理解)以及摄像机位置。

534b7c59d1a94b548346ab8e3a7ef695.png

        我们给光源摄像机(即场景捕获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函数已经将投影矩阵转置了。

bb4cca6d1ffe4898883f6e1b2c7e1fce.png

a0cdde4b1899490294ecb68a6b9c43ce.png

        透视矩阵公式来源:透视投影矩阵推导

        之后,再获取光源位置(可选,可以在后续的体积光中计算某个点的光强度)。

    FLinearColor lightPos = FLinearColor(ViewInfo.Location.X, ViewInfo.Location.Y, ViewInfo.Location.Z, 1);

        至此,光源摄像机的VP矩阵我们已经获取到了,接下来我们需要将这些矩阵传入ShadowMap的材质中。这里使用到虚幻引擎的材质参数集合,内容浏览器中右键->材质和纹理->材质参数集创建。

4abdb74a91de4814b9976a53e87c2924.png

        之后双击刚创建的材质参数集进入详情页,创建所需的标量参数及向量参数。

3af8e82fb912494786cfde5eae9f31eb.png

        回到光源摄像机(即场景捕获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组件上(如地面)。然后进入材质详情面板。将上一小节创建的材质参数集拖到详情面板中即可获取材质参数集的数据。

98dc768acd1e4e17ad5e2b8e13a5fadc.png

        获取像素的世界坐标,通过Transform3x3Matrix节点将世界坐标依次变换到视口空间(View)、透视投影空间(Projection)。

f7fb2ec1107a4573be27a27a4022dca8.png

        这里我们进入Transform3x3Matrix节点看下它的实现(如下图)。这里考虑3X3矩阵的情况(不考虑W分量),设输入向量三个分量R,G,B。用于变换的矩阵行分量X(X1, X2, X3),Y(Y1, Y2, Y3),Z(Z1, Z2, Z3)。正常的矩阵乘法有:

eq?%5Cbegin%7Bbmatrix%7D%20X1%20%26%20X2%20%26%20X3%5C%5C%20Y1%20%26%20Y2%20%26%20Y3%5C%5C%20Z1%20%26%20Z2%20%26%20Z3%20%5Cend%7Bbmatrix%7D%20*%5Cbegin%7Bbmatrix%7D%20R%5C%5C%20G%5C%5C%20B%20%5Cend%7Bbmatrix%7D%20%3D%20%5Cbegin%7Bbmatrix%7D%20R*X1%20&plus;%20G*X2%20&plus;%20B*X3%5C%5C%20R*Y1%20&plus;%20G*Y2%20&plus;%20B*Y3%5C%5C%20R*Z1%20&plus;%20G*Z2%20&plus;%20B*Z3%20%5Cend%7Bbmatrix%7D  

        而该节点实现的矩阵乘法则是:

eq?%5Cbegin%7Bbmatrix%7D%20X1%20%26%20X2%20%26%20X3%5C%5C%20Y1%20%26%20Y2%20%26%20Y3%5C%5C%20Z1%20%26%20Z2%20%26%20Z3%20%5Cend%7Bbmatrix%7D%20*%5Cbegin%7Bbmatrix%7D%20R%5C%5C%20G%5C%5C%20B%20%5Cend%7Bbmatrix%7D%20%3D%20%5Cbegin%7Bbmatrix%7D%20R*X1%20&plus;%20G*Y1%20&plus;%20B*Z1%5C%5C%20R*X2&plus;%20G*Y2%20&plus;%20B*Z2%5C%5C%20R*X3%20&plus;%20G*Y3%20&plus;%20B*Z3%20%5Cend%7Bbmatrix%7D

        可以看到,变换矩阵是先转置在于输入向量相乘的。这也是为什么我们在第二小节需要将VP矩阵转置再送到材质参数集里。

e7dcbde7e1fe4ff69cf08acc0e6a3038.png

        像素的世界坐标经过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去减)。

e49a7cbac8854b029cdadccbb0d580b0.png
        通过UV获取到对应位置的深度之后,将其与投影空间下的Z值进行比较(这里需要加一点点偏移,不然会出现明暗条纹)。如果深度值小于投影空间下的Z值,说明该像素位于阴影中,渲染成黑色,反之为白色。

593b45639c0545bdae681491c122af16.png

        之后将输出值送给“自发光颜色”,大功告成。注意,这里插入的if是我用来处理X,Y不在(-1, 1)范围的情况的,这里就不额外介绍了。

6e37759a9958496eb9cc2f21ee179b72.png

(4)效果展示

bb06c7d0bf0647e3a5fa4cd49e438b63.png

e2c7f07dd38c410bbc41172d982809a8.png

 

(5)正交投影

        这里在简单介绍下利用正交投影实现平行光阴影。首先将“场景捕获2D”组件的投射类型改为正交。C++侧通过函数FOrthoMatrix获取正交投影矩阵,送入材质参数集的方式不变。在材质中,获取UV的方式改为:

931361f7828f43ceae1458c163089378.png

        这里不用乘0.5再加0.5了,直接加0.5即可。原因在于FOrthoMatrix函数获取的矩阵,对比正交矩阵公式可知该函数返回的矩阵长度就是1,不需要再除以2了。

        正交矩阵推导可参考:【计算机图形学基础】投影矩阵

7b2c8e001b4d4b759a3ff564ac9b09e6.png

2、体积光

(1)基本思路

        通过后处理的方式,使用RayMarching算法,计算每个屏幕像素的光强度,再与屏幕纹理叠加。

(2)创建后处理材质

        在虚幻引擎中,要使用后处理材质,首先需要一个后处理体积Actor作为载体。创建方式如下图。后处理材质贴在该体积上,玩家摄像机进入该体积时后处理材质生效。这里可以将该体积直接作为玩家角色的子Actor,使得后处理材质一直生效。

0cf7b43e4ff445df807a4e0393bdb61c.png

        新建一个材质,材质域选择后期处理,这样该材质就可以贴到后期处理体积上使用啦。后期处理简单来说就是对渲染流程生成的一张张屏幕大小的图片进行处理,也可以理解是图像处理。

607d09baf6e84c17a44a093cd9cf612f.png

        这里要使用材质里的custom节点(如下图),这是一个允许我们自己写HLSL代码的节点。输入参数及输出类型需要在细节一栏手动配置。这里的输入参数不需要定义类型,在代码中可以直接通过其变量名使用。

fc06424d2ce6477b830766d61ae2e8da.png

        这个节点虽然支持我们自己写代码,但是不能直接定义函数。这里有一个坑,我们可以查看当前材质的着色器代码。

6026a228ae8c4d518ba6e54e7b0ee451.png

        找到我们自定义的代码,可以发现我们的代码是放在一个预先定义好的函数里,函数内不能再定义函数。难道我们就不能在custom节点里定义函数了吗?其实是可以的,具体方法在第三小节介绍。

e0818e31dc834594a8e41db3c519d7ef.png

(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;

15cd60de4a3c4430bd3b6f7313a13dda.png

791671b07ad643c29767ac918105a519.png

        对于第二小节定义函数的问题,在custom的代码中,我们可以定义一个结构体,在结构体内定义函数。通过结构体对象我们就可以调用函数啦。这里的custom节点看着吓人,其实算法本身不复杂,麻烦的部分是将材质节点Transform3x3Matrix代码化(代码中的transform函数)。

(4)效果展示
b9d855144feb4740bd0e98d050b7ce40.png

5fe13963985f45919c0f64d75497b29d.png

 

所支持的Unity版本 5.2.0 及以上版本 WebGL Showcase | WebGL压力测试|文档|论坛 这个插件允许您通过生成真正容积的程序束来大大改善场景的照明。 这是模拟聚灯和手电筒的密度,深度和音量的完美,简单而便宜的方法。 简单高效的体积照明解决方案兼容各种平台(Windows PC,Mac OS X,Linux,WebGL,iOS,Android,VR)! 即使在移动设备上,也能为您的聚灯和手电筒模拟密度,深度和体积的完美,简单且便宜的方式! 它通过自动高效地生成真正的体积程序束来渲染高质量的线效果,从而极大地改善了场景的照明。 特征: - 真正的体积效果:即使你在束中也能工作。 - 非常容易使用和集成/需要零设置。 - 程序生成:一切都是在引擎盖下动态计算的。 - 在任何地方添加无限束:替代解决方案通常只需要实时灯:此插件不需要。您可以制作烘烤的量,甚至可以在没有任何线的情况下添加束。 - 动态3D噪声功能,用于模拟动画体积雾/雾/烟雾效果。 - 体积粉尘颗粒功能可模拟高度详细的防尘灯和微尘效果。 - 动态遮挡:可以通过移动几何体来阻挡束。 - 您可以实时移动和旋转束。 - 触发区域功能:您可以跟踪通过束的对象。 - 完全动态:在游戏时间内从脚本,动画师或时间轴更改或动画每个属性。 - Super FAST:不需要任何后处理,命令缓冲区和计算着色器:即使在移动设备和WebGL等低性能平台上也能很好地工作。 - VR Ready:支持Normal和VR Single pass立体声。 - 平滑交叉并与几何和相机混合。 - 自定义截头圆锥几何体。 - 支持许多图形变体:延迟和前向渲染路径,Gamma和线性颜色空间,HDR颜色,多种混合模式。 - 调整分层图层和图层顺序,以使用2D精灵调整渲染。 - 开箱即用的透视和正交相机。 - 支持Unity内置雾。 - WYSIWYG:在场景视图中立即可以看到每个修改:无需在编辑器和播放模式之间切换以查看您的更改。 - 完整源代码可用/无DLL。束设置和处理通过功能强大的API完全暴露。 - 详细的文件。 - 支持从Unity 5.2到最新的2017.X和2018.X版本。 - 示例场景包括:展示演示。 请注意,此资产不是全屏后期处理/图像效果。这与Unity内置的Sun Shafts图像效果不相似。 相反,体积束将产生优化的几何形状和材料谱。这种技术有几个优点: - 更精细:独立精确定制每个束。 - 您可以在任何地方添加束,即使在没有线的地方也是如此。 - 当连接到聚灯时,它支持实时,烘焙和混合模式。 - 您可以渲染束数量没有限制。 - 更容易与您自己的管道集成:无需与您自己的图像效果或后处理堆栈混合,没有命令缓冲区,不需要计算着色器功能。 - 运行得更快。没有后期处理添加到您的相机。 - 支持移动等低端平台。 如何使用它? 体积束设计非常易于使用。无需设置。您不必将多个对象链接在一起。您只需要使用一个简单的新组件。你可以通过2次点击添加一个新的束! 您可以通过调整一组用户友好的属性来精确定制每个束的渲染。为了获得更好看的效果,一些属性会自动绑定到附加的聚灯。 限制: 目前,此资产的当前版本有一些小的限制: - 此资产仅支持“聚灯”(形状像锥形的束)。不支持点源(线向各个方向平等)。 - “3D噪声”功能要求着色器功能等于或高于Shader Model 3.5 / OpenGL ES 3.0。 2012年之后发布的任何移动设备都应该支持它。 - 仅在Unity 5.5或更高版本上支持“体积粉尘颗粒”。 - “动态遮挡”功能计算遮挡的近似值,但尚不支持“部分遮挡”。
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值