虚幻引擎之SecondPass支持
文章目录
1 前言
当前,虚幻引擎提供了材质编辑器供用户实现各种渲染效果。
但某些渲染效果需要不止一个Pass才能完成。如:对卡通风格的角色进行表面的光照着色,并进行描边。
注:当然,描边的功能也可以通过后处理的方式来实现。这里仅是举例说明。
看似在Unity3D通过写多Pass的着色器可以轻松实现的功能,UE却不支持。
因而,需要对引擎源码进行一定的修改从而实现支持SecondPass的功能。
2 一个Mesh多个材质 与 一个材质多Pass
在进行具体实现的介绍之前,让我们先了解下以下两个概念:
- 一个Mesh多个材质;
- 一个材质多个Pass;
二者是两个不同的概念。
一个Mesh多个材质,指的是:对于一个Mesh划分为多个部分,各个部分的材质不尽相同。
例如,一辆车,车身使用Index0的材质,轮胎使用Index1的材质。
一个材质多个Pass,指的是:对于一个Mesh,使用相同或不同的材质对这个Mesh绘制多遍。
例如,前面提到的卡通渲染,第一遍,使用对表面进行光照计算的材质进行渲染,第二遍,我们使用描边材质渲染出卡通角色的轮廓。
3 网格渲染管线(Mesh Drawing Pipeline)
在介绍SecondPass的实现细节之前,首先,需要了解虚幻引擎是如何收集和渲染Mesh的。
Mesh Drawing Pipeline提供了详细的介绍。
涉及到的核心数据结构有:
- FPrimitiveSceneProxy;
- FMeshBatch;
- FMeshDrawCommand;
- FMeshProcessor;
其中,FPrimitiveSceneProxy为图元在渲染线程中的表示,它是游戏线程UPrimitiveComponent的代理。在一次渲染的过程中,渲染器会收集收集FPrimitiveSceneProxy,将其转换成为FMeshBatch。再通过FMeshProcessor将FMeshBatach转换为FMeshDrawCommand。最后,通过RHICommandList调用进行绘制。
上述提供了虚幻对Mesh进行渲染的大概逻辑。
具体的逻辑还有优化:如对于静态的Mesh,当其渲染状态不改变的情况下,它所生成的FMeshDrawCommand(渲染指令)是会被Cache(缓存起来),无需再次进行收集和转换等过程,直接可以进行渲染绘制。
经过上述的了解,回到当前文章讨论的问题,即 如何支持SecondPass ?
可以想到,最终的渲染数据来自于FPrimitiveSceneProxy转换得到的FMeshBatch。则需要在渲染收集图元的过程中,添加两个FMeshBatch即可。其中一次为FirstPass的材质,第二次为SecondPass的材质。
那么,概括一下接下来应该如何实现:
- 添加一个SecondPass材质的接口,用于编辑设置所需渲染的材质;
- 将材质资源传递到对应的FPrimitiveSceneProxy,在渲染器收集FMeshBatch时,生成SecondPass的FMeshBatch;
4 StaticMesh的SecondPass
核心功能所涉及修改的文件如下:
StaticMesh.h/cpp
StaticMeshComponent.h/cpp
StaticMeshResources.h
StaticMeshRender.cpp
StaticMesh.h
在 UStaticMesh 类中添加一个用于设置 SecondPass 材质的数组:
// UE源码位置,在该行代码后添加
TArray<FStaticMaterial> StaticMaterials;
// 笔者添加
/** List of SecondPass materials applied to this mesh. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = StaticMesh)
TArray<class UMaterialInterface*> SecondPassMaterials;
StaticMesh.cpp
在 UStaticMesh::PostLoad 函数中添加:
// 笔者添加
if (SecondPassMaterials.Num() != StaticMaterials.Num())
{
SecondPassMaterials.SetNumZeroed(StaticMaterials.Num());
}
// UE源码位置,在该行代码前添加
if(BodySetup == NULL)
{
//...
}
通过上述两个文件的修改,即完成了 SecondPass材质的设置接口的添加。
StaticMeshComponent.h
在 UStaticMeshComponent 类中,添加以下代码:
// 用于覆写一个UStaticMeshComponent所有SubMesh的所有SecondPass材质的接口
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "StaticMesh", meta = (DisplayName = "Second Pass Material"))
class UMaterialInterface* SecondPassMaterial = nullptr;
// 获得对应 材质索引 的SecondPass材质接口
UMaterialInterface* GetSecondPassMaterial(int32 MaterialIndex) const;
StaticMeshComponent.cpp
实现GetSecondPassMaterial接口
UMaterialInterface* UStaticMeshComponent::GetSecondPassMaterial(int32 MaterialIndex) const
{
if (StaticMesh && StaticMesh->SecondPassMaterials.IsValidIndex(MaterialIndex) && StaticMesh->SecondPassMaterials[MaterialIndex])
{
return StaticMesh->SecondPassMaterials[MaterialIndex];
}
return SecondPassMaterial;
}
在UStaticMeshComponent::GetUsedMaterials函数添加:
- 将有效的材质添加到OutMaterials数组中;
if( GetStaticMesh() && GetStaticMesh()->RenderData )
{
//... 省略部分源码
// 笔者添加
// Second Pass Materials
for (int32 MatIdx = 0; MatIdx < StaticMesh->SecondPassMaterials.Num(); ++MatIdx)
{
if (StaticMesh->SecondPassMaterials.IsValidIndex(MatIdx) && StaticMesh->SecondPassMaterials[MatIdx])
{
OutMaterials.Add(StaticMesh->SecondPassMaterials[MatIdx]);
}
}
}
// 笔者添加
if (SecondPassMaterial)
{
OutMaterials.Add(SecondPassMaterial);
}
StaticMeshResources.h
对FSectionInfo类添加一个SecondPass材质,并对构造函数进行修改:
/** Information about an element of a LOD. */
struct FSectionInfo
{
/** Default constructor. */
FSectionInfo()
: Material(NULL)
#if WITH_EDITOR
, bSelected(false)
, HitProxy(NULL)
#endif
, FirstPreCulledIndex(0)
, NumPreCulledTriangles(-1)
// 笔者添加
, SecondPassMaterial(NULL)
)
{}
/** The material with which to render this section. */
UMaterialInterface* Material;
// 笔者添加
UMaterialInterface* SecondPassMaterial;
//... 省略部分源码
};
StaticMeshRender.cpp
在FStaticMeshSceneProxy::FLODInfo::FLODInfo函数中添加
// Gather the materials applied to the LOD.
Sections.Empty(MeshRenderData->LODResources[LODIndex].Sections.Num());
for(int32 SectionIndex = 0;SectionIndex < LODModel.Sections.Num();SectionIndex++)
{
const FStaticMeshSection& Section = LODModel.Sections[SectionIndex];
FSectionInfo SectionInfo;
// Determine the material applied to this element of the LOD.
SectionInfo.Material = InComponent->GetMaterial(Section.MaterialIndex);
// 笔者添加
UMaterialInterface* SecondPassMaterial = SectionInfo.SecondPassMaterial;
//...
}
在FStaticMeshSceneProxy::DrawStaticElements函数中添加代码,将SecondPass材质转换为FMeshBatch。
DrawStaticElements的核心功能就是将材质和顶点数据等转换为FMeshBatch用于后续的渲染。
//... 省略部分源码
for (int32 BatchIndex = 0; BatchIndex < NumBatches; BatchIndex++)
{
FMeshBatch BaseMeshBatch;
if (GetMeshElement(LODIndex, BatchIndex, SectionIndex, PrimitiveDPG, bIsMeshElementSelected, true, BaseMeshBatch))
{
if (NumRuntimeVirtualTextureTypes > 0)
{
// Runtime virtual texture mesh elements.
FMeshBatch MeshBatch(BaseMeshBatch);
SetupMeshBatchForRuntimeVirtualTexture(MeshBatch);
for (ERuntimeVirtualTextureMaterialType MaterialType : RuntimeVirtualTextureMaterialTypes)
{
MeshBatch.RuntimeVirtualTextureMaterialType = (uint32)MaterialType;
PDI->DrawMesh(MeshBatch, FLT_MAX);
}
}
{
PDI->DrawMesh(BaseMeshBatch, FLT_MAX);
}
// 笔者添加,GetSecondMeshElement用于获得SecondPass材质的信息
if (GetSecondMeshElement(LODIndex, BatchIndex, SectionIndex, PrimitiveDPG, bIsMeshElementSelected, true, BaseMeshBatch))
{
FMeshBatch MeshBatch(BaseMeshBatch);
PDI->DrawMesh(MeshBatch, FLT_MAX);
}
//... 省略部分源码
}
}
FStaticMeshSceneProxy::GetSecondMeshElement 函数为笔者模仿GetMeshElement实现的。
几乎所有的代码都一致,仅是替换了材质:
bool FStaticMeshSceneProxy::GetSecondMeshElement(
int32 LODIndex,
int32 BatchIndex,
int32 SectionIndex,
uint8 InDepthPriorityGroup,
bool bUseSelectionOutline,
bool bAllowPreCulledIndices,
FMeshBatch& OutMeshBatch
) const
{
const ERHIFeatureLevel::Type FeatureLevel = GetScene().GetFeatureLevel();
const FStaticMeshLODResources& LOD = RenderData->LODResources[LODIndex];
const FStaticMeshVertexFactories& VFs = RenderData->LODVertexFactories[LODIndex];
const FStaticMeshSection& Section = LOD.Sections[SectionIndex];
const FLODInfo& ProxyLODInfo = LODs[LODIndex];
// Get SecondPass Material 核心代码
UMaterialInterface* MaterialInterface = nullptr;
MaterialInterface = ProxyLODInfo.Sections[SectionIndex].SecondPassMaterial;
if (MaterialInterface == nullptr)
{
return false;
}
FMaterialRenderProxy* MaterialRenderProxy = MaterialInterface->GetRenderProxy();
if (MaterialRenderProxy == nullptr)
{
return false;
}
const FMaterial* Material = MaterialRenderProxy->GetMaterial(FeatureLevel);
//... 省略部分源码
}
至此,通过上述代码的修改,即可实现静态Mesh的SecondPass设置。
5 SkeletalMesh的SecondPass
和上面一致,先给出核心功能所涉及修改的文件:
SkeletalMesh.h/cpp
SkeletalMeshTypes.h
SkinnedMeshComponent.h/cpp
SkeletalMesh.h
在 USkeletalMesh 类中添加一个用于设置 SecondPass 材质的数组:
// UE源码位置,在该行代码后添加
TArray<FSkeletalMaterial> Materials;
// 笔者添加
UPROPERTY(EditAnywhere, Category = SkeletalMesh)
TArray<UMaterialInterface*> SecondPassMaterials;
USkinnedMeshComponent.h
在USkinnedMeshComponent类中,添加以下代码:
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Mesh", meta = (DisplayName = "SecondPass Material "))
UMaterialInterface* SecondPassMaterial = nullptr;
UMaterialInterface* GetSecondPassMaterial(int32 MaterialIndex) const;
USkinnedMeshComponent.cpp
实现GetSecondPassMaterial接口
UMaterialInterface* USkinnedMeshComponent::GetSecondPassMaterial(int32 MaterialIndex) const
{
if (SkeletalMesh && SkeletalMesh->SecondPassMaterials.IsValidIndex(MaterialIndex) && SkeletalMesh->SecondPassMaterials[MaterialIndex])
{
return SkeletalMesh->SecondPassMaterials[MaterialIndex];
}
return SecondPassMaterial;
}
在USkinnedMeshComponent::GetUsedMaterials函数添加:
if( SkeletalMesh )
{
//... 省略部分源碼
// 筆者添加 SecondPass Materials
for (int32 MatIdx = 0; MatIdx < SkeletalMesh->SecondPassMaterials.Num(); ++MatIdx)
{
if (SkeletalMesh->SecondPassMaterials.IsValidIndex(MatIdx) && SkeletalMesh->SecondPassMaterials[MatIdx])
{
OutMaterials.Add(SkeletalMesh->SecondPassMaterials[MatIdx]);
}
}
}
// 筆者添加
if (SecondPassMaterial)
{
OutMaterials.Add(SecondPassMaterial);
}
SkeletalMeshTypes.h
对FSectionElementInfo类添加一个SecondPass材质,并对构造函数进行修改:
/** info for section element in an LOD */
struct FSectionElementInfo
{
FSectionElementInfo(UMaterialInterface* InMaterial, bool bInEnableShadowCasting, int32 InUseMaterialIndex, UMaterialInterface* InOutlineMaterial = nullptr)
: Material( InMaterial )
// 笔者添加
, SecondPassMaterial(InOutlineMaterial)
, bEnableShadowCasting( bInEnableShadowCasting )
, UseMaterialIndex( InUseMaterialIndex )
#if WITH_EDITOR
, HitProxy(NULL)
#endif
{}
UMaterialInterface* Material;
// 笔者添加
UMaterialInterface* SecondPassMaterial;
};
SkeletalMesh.cpp
修改FSkeletalMeshSceneProxy构造函数:
// 笔者添加
UMaterialInterface* SecondPassMaterial = Component->GetSecondPassMaterial(UseMaterialIndex);
LODSection.SectionElements.Add(
FSectionElementInfo(
Material,
bSectionCastsShadow,
UseMaterialIndex,
SecondPassMaterial /* 笔者添加 */
));
MaterialsInUse_GameThread.Add(Material);
FSkeletalMeshSceneProxy::GetMeshElementsConditionallySelectable 添加以下代码:
// UE源码位置,在该行代码后添加
GetDynamicElementsSection(Views, ViewFamily, VisibilityMap, LODData, LODIndex, SectionIndex, bSectionSelected, SectionElementInfo, bInSelectable, Collector);
// 笔者添加
if (SectionElementInfo.SecondPassMaterial)
{
FSectionElementInfo SecondPassInfo(SectionElementInfo.SecondPassMaterial, false,SectionElementInfo.UseMaterialIndex);
GetDynamicElementsSection(Views, ViewFamily, VisibilityMap, LODData, LODIndex, SectionIndex, bSectionSelected, OutlinePassInfo, bInSelectable, Collector);
}
- GetMeshElementsConditionallySelectable被GetDynamicMeshElements调用,调用GetDynamicElementsSection收集FMeshBatch。
至此,通过上述代码的修改,即可实现动态Mesh的SecondPass设置。
6 SecondPass的应用
本文一直提到了SecondPass在卡通渲染中的描边功能的实现。那么,接下来就一起在UE中实现一个最简单的用于描边的材质吧。
模型的勾边使用Inverted Hull算法。
其基本思想就是:使用正面剔除,进行法线外扩,从而在模型外围实现勾边。
正面剔除
Inverted Hull算法需要使用正面剔除FrontFaceCulling。
由于虚幻原生不支持设置剔除面,可以结合TwoSidedSign材质节点和Masked混合模式实现。
TwoSidedSign当三角形是正面的时候返回1,反面返回-1。乘以-1之后就反过来了。则有:三角形的正面值为-1,反面为1。设置给Opacity mask。从而实现剔除正面。
原理如下:
- 由于描边材质是Mask类型,当是正面的时候Opacity为-1,小于默认的Opacity Clip值(0.333),会被clip掉;正面的不会。
顶点沿法线方向外扩
材质的World Position Offset用于接收顶点的偏移量。
描边颜色
将材质着色模型设置为Unlit模式,将EmissiveColor设置为描边颜色。
综上即可几点,即可实现一个最简单的描边材质,完整的材质如下:
效果优化
上述的方法,描边线的粗细会随着距离远近二变化。
为了确保描边线的粗细不会随远近变化,需要在NDC坐标系下外扩固定的距离;
但在由于材质蓝图里面无法直接影响NDC坐标,能控制的是World Position Offset,需要想其他办法:
- Clip Space在转成NDC之前需要除以w,这个值是View Space的Z值,需要消除这个动态的Z值影响。需要将外扩的距离乘以Z。
这样操作后接入World Position Offset可以保证在NDC的外扩值是固定的。
7 小结
上文描述的修改,提供了一个在UE中实现SecondPass材质渲染的核心功能细节。
此处给出一个效果示例:
不过,引擎的功能是要提供给美术、程序员使用的,还需要对相应的UI面板进行一定的自定义优化,以提供更加友好的使用环境。这个部分的实现就留给各位读者。