关于StaticMesh的读写与创建

4

主题

9

帖子

16

积分

新手上路

Rank: 1

积分
16
发表于 2023-2-2 13:45:16 | 显示全部楼层
前言

——UE的UStaticMesh类是一个大坑。它的类结构很复杂,里面存了多份网格数据,这半个月我也是一直在卷这里面的东西,但到现在也只是会用一些常用的API来实现数据的读写和创建。
——类中还有很多像是RHI,Factory等我还没有接触,对完整的SM类结构感兴趣的可以看看下面的两篇文章。
——本文将针对其中的RenderData,RawMesh(RM)及MeshDescription(Desc)进行解析,会简单分析一下这三份网格数据的基本操作与异同。
1、模型数据的存储结构

——首先简单铺垫一下一般的模型数据的存储结构。以前搞U3D没怎么关注过这些底层细节,到了UE里因为API很繁琐踩了很多坑,但反而因此加深了对这些东西的了解,也算是因祸得福了_(:з)∠)_
——对于模型或者说Mesh,首先有两个大的上层概念是LOD和Section(Sec)。
——LOD一种常见的用途就是根据模型距离相机的远近动态切换不同面数的模型进行渲染,越远用的面数越低。
——Section,或者有些地方也叫子网格或多维子材质(或者说Sec是这两个东西的底层原理),就是在Max等DCC软件里,未进行分离操作的一个网格上可能会出现使用2个及以上不同材质的情况,这时会给导出的顶点数据中存一份材质ID来区分不同的Sec。(比如一个正方体的6个面使用6个不同的材质,那导出的三角面顶点总共2*6*3=36个,其中材质ID=0、1、2、3、4、5的顶点各有6个)
——然后就是每个顶点中的数据,主要的就是坐标、UV、法线切线、顶点色还有索引这些。其中UV还可能有多个通道,RM中默认留给UV通道数组的长度是8。


——上面的三层数据间的关系乍得一看是LOD->Sec->顶点数据,但实际上Sec主要只是记录了材质ID相同的若干组顶点的索引范围,而并不是每个Sec里单独存了一组索引从0开始的顶点。
——Sec和顶点数据间更像是同级关系,用Sec可以更方便的访问一部分顶点数据。(有点绕也没关系- -看下面的打印结果以及后面的C++就知道了)
2、RenderData、RM和Desc的差别

——然后是RenderData、RM和Desc的差别。首先可以看看下面打印出的数据对照表(打印用的函数类会在后面给出代码的资源链接)。
RenderData


RenderData Sections


RM&Desc


分别打印4组数据进行对照


——单从数组长度和元素顺序来看可以合理推测出以下结论:
①RM和Desc的数据基本相同(除了副切线这种需要二次计算,或者顶点色这种取决于原模型有没有刷的可能不同,但即便不同也就是其中一个长度=0),二者与RenderData一般不同
②RM和Desc的数据更接近模型的原始数据(比如原模型的LOD只有1级,RM和Desc的LOD也就只能拿到LOD0,如上面的输出);RenderData则对应UE场景中的最终渲染效果,它基于RM或Desc生成,后续可能会有一些多LOD,多UV的自动计算
③RenderData中的坐标、UV、法线切线、顶点色数组的长度相同(因为要丢给shader的输入结构里并行计算,所以这些理论上必须一一对应);RM和Desc则是除了坐标之外的其他东西与三角索引的长度相同
④RenderData中的索引数组长度和RM,Desc保持一致,但顺序显然有变化。包括上面的数组长度变化在内,推测原因和RM or Desc->RenderData的顶点拆分有关,但这个过程的具体实现我目前还不清楚
3、带权重的Mesh

——也就是UE里的SkeletalMesh(SK),这里就是提一嘴不讲。这据说又是另外一个更大的坑,我还没接触,到时候再说吧= =
UE蓝图

——蓝图里好像并没有现成的从SM里拿网格数据的节点。RM没有,Desc虽然有些节点看上去像,但又都需要Desc作为输入,而又唯独没有从SM拿Desc的节点。


——可能是因为搞得东西逐渐底层化,主要作为上层支持的蓝图开始逐渐吃力了_(:з)∠)_所以姑且先搁置。
——其实UE还有个叫ProceduralMesh(PM)的东西可以用节点连,PM也是我觉得处理逻辑(舒适度)最接近U3D的方式。
——不过因为本篇讲的东西已经够多了,PM之后有时间再单开一篇来说吧。
UE C++

1、RenderData

——场景中的最终渲染效果使用的CPU端数据就是RenderData中的数据。
——先来看下RenderData的调用结构。


——其实差不多就是前言说到的存储结构。可以看到我们常用的坐标、UV、法线切线之类的在这里面都有,那我们就可以为所欲为了。
//Buffer缓存
const FStaticMeshLODResources& CurrentLOD = StaticMesh->RenderData->LODResources[lodID];

const FStaticMeshVertexBuffers& VBuffers = CurrentLOD.VertexBuffers;
const FRawStaticIndexBuffer& IdBuffer = CurrentLOD.IndexBuffer;

const FPositionVertexBuffer& PosBuffer = VBuffers.PositionVertexBuffer;
const FColorVertexBuffer& ColBuffer = VBuffers.ColorVertexBuffer;
const FStaticMeshVertexBuffer& VertBuffer = VBuffers.StaticMeshVertexBuffer;

//数据遍历
for (uint32 i = 0; i < PosBuffer.GetNumVertices(); i++) PosBuffer.VertexPosition(i);//坐标
for (uint32 i = 0; i < ColBuffer .GetNumVertices(); i++) ColBuffer .VertexColor(i);//顶点色

for (uint32 i = 0; i < VertBuffer.GetNumVertices(); i++)
{
        VertBuffer.VertexTangentX(i);//切线
        VertBuffer.VertexTangentY(i);//副切线
        VertBuffer.VertexTangentZ(i);//法线(没错,TangentZ是法线,坑爹的命名)
}

for (uint32 i = 0; i < VertBuffer.GetNumVertices(); i++)//第几个顶点的UV
{
        for (uint8 uvID = 0; uvID < VertBuffer.GetNumTexCoords(); uvID++)//第几个UV通道
        {
                VertBuffer.GetVertexUV(i, uvID);
        }
}

for (uint32 i = 0; i < IdBuffer.GetNumIndices(); i++) IdBuffer.GetIndex(i);//三角索引

//Section数据(遍历材质ID)
const FStaticMeshLODResources::FStaticMeshSectionArray& Sections = CurrentLOD.Sections;
for (uint8 secID = 0; secID < Sections.Num(); secID++)
{
        const FStaticMeshSection& CurrentSec = Sections[secID];
        for (uint32 i = CurrentSec.MinVertexIndex; i <= CurrentSec.MaxVertexIndex; i++)//这里的index对应VBuffers中的顶点索引,而不是IdBuffer中的三角索引
        {
                CurrentSec.MaterialIndex;
        }
}
2、RawMesh

——RM是三个里面自身结构最简单的。它经常作为一个数据转换的中间变量存在(比如本文的打印函数中的各种读写)。
——RM在源码里就是个没什么嵌套的结构体,里面有几个坐标、UV之类的数组变量,要读写直接http://RM.XXX就完事了。


——需要特别说一嘴的是怎么拿一个SM的RM,以及将一个RM写进SM里,说白了就是下面两个API。
FRawMesh RM;
SM->GetSourceModel(lodID).LoadRawMesh(RM);//读

//写
if (
        RM.FaceMaterialIndices.Num() > 0 && RM.FaceSmoothingMasks.Num() > 0 &&//防止SaveRawMesh崩溃
        RM.IsValid()
)
{
        SM->GetSourceModel(lodID).SaveRawMesh(RM);
}
——需要注意的有3件事:
1、SaveRawMesh前务必检查RM的有效性。只用IsValid方法还不够,源码SaveRawMesh->ConvertFromRawMesh->743行左右有材质ID数组越界风险,建议加上面两个判断
2、上面两个方法仅适用于Editor模式,如果要打包Runtime改SM考虑用SM->BuildFromMeshDescription系列方法
3、SaveRawMesh的时候会把有些自定义写进RM的信息刷掉,因为默认开了些自动计算,需要改一下MeshBuildSettings,下面以关闭自动计算法线为例
FStaticMeshSourceModel& SourceModel = SM->GetSourceModel(lodID);
FMeshBuildSettings& Settings = SourceModel.BuildSettings;
Settings.bRecomputeNormals = false;
Settings.bRecomputeTangents = false;
Settings.bComputeWeightedNormals = false;
SourceModel.SaveRawMesh(RM);
3、Description

——Desc和RM的数据基本一模一样,但FMeshDescription类却复杂的一批,其中多了很多API与类型封装(据说UE官方有逐步用Desc替代RM的打算)。
——其中的API甚至有访问某个顶点相邻的边、相邻顶点这种东西(感觉可以用来还原DCC中的一些功能)。
3.1、拿数组
——首先,拿一个SM某个lod下的Desc用SM->GetMeshDescription(lodID)即可。
——然后先说一下Desc里能操作的图元类型。直接用一个Desc去->归纳一下能发现主要有Polygon(Group)(作用类似于RenderData中的Section)、Triangle、Edge、Vertex(Instance)这4种东西,但却没找到我们想要的UV、法线之类的东西。
——这里可以参考RenderData的结构,合理猜测UV之类的东西可能是藏在了Vertex相关的东西下,即最下面这4个能拿到的东西里。又因为前两个带个Attribute,英语不好的我百度翻译一下是【属性】的意思,所以这两个是重点嫌疑人。


——这里就不卖关子了。以坐标为例,你可以用下面这种让人折寿的方式拿到顶点坐标数组。
const TVertexAttributesRef<FVector>& Pos = Desc->VertexAttributes().GetAttributesRef<FVector>(MeshAttribute::Vertex::Position);
——既然“折寿”,那就说明有更优雅的方法,直接看下面这段代码。
FMeshDescription* CurrentDesc = SMeshIN->GetMeshDescription(lodID);
FStaticMeshAttributes Attribute(*CurrentDesc);

const TVertexAttributesRef<FVector>& Pos = Attribute.GetVertexPositions();
const TVertexInstanceAttributesRef<FVector2D>& UVs = Attribute.GetVertexInstanceUVs();
const TVertexInstanceAttributesRef<FVector>& NDirs = Attribute.GetVertexInstanceNormals();
const TVertexInstanceAttributesRef<FVector>& TDirs = Attribute.GetVertexInstanceTangents();
const TVertexInstanceAttributesRef<FVector4>& Cols = Attribute.GetVertexInstanceColors();//注意范围0~1,和RenderData,RM 0~255不同
const FTriangleArray& Tris = CurrentDesc->Triangles();
——通过将Desc丢到一个FStaticMeshAttributes的构造函数里便可一步获得对应的数组(其实翻源码就知道内部实现还是那个折寿的方法)
——这步顺便也给了我们一个提示,即Desc将坐标放到了Vertex中,其他的放到了VertexInstance中,根本原因就是它们用的是两套索引,Vertex走顶点索引,Instance走三角索引,这点和RM是一样的。
3.2、拿数组元素
——好不容易拿到了数组,但痛苦还没有结束。你直接[ ]进去一个数会发现报错,直接.出来的方法也和TArray的不太一样。因为这些数组和常规的TArray不一样,是UE自己整的一个更上层的数据类型。看一下就会发现,比如坐标数组的[ ]的符号重载函数里要填的是一个FVertexID类型的东西。


——你要是再点点别的数组,还可能会发现各种奇奇怪怪的FXXXID,他们都继承自一个叫FElementID的结构体。


——但点开后发现好像也没什么额外的处理,甚至几乎就是给int32 IDValue套了层结构体的皮。比如还是坐标数组,你想访问第一个元素,一般习惯是Pos[0],这里直接拿构造套层皮Pos[FVertexID(0)]即可。
——据说这种看似累赘的操作在C++这种强类型语言中有规范代码的作用- -···嗯···不太懂什么意思。但这东西当时还是让我疑惑很久。
——那么接下来就有两种遍历方法了,要不用常规的for循环,只不过每次取索引的时候用FXXXID套层皮;要不借下面官方提供的GetElementIDs接口拿到对应的ID数组,然后去foreach。至于这两种方式拿到的数组顺序一不一样,至少我目前测过的案例中打印出来都是一样的。
//缓存ID数组
const TMeshElementArray<FMeshVertex, FVertexID>::TElementIDs& FVertexIDs = CurrentDesc->Vertices().GetElementIDs();
const TMeshElementArray<FMeshVertexInstance, FVertexInstanceID>::TElementIDs& FVertexInstanceIDs = CurrentDesc->VertexInstances().GetElementIDs();

//数据遍历
for (const FVertexID& fVertID : FVertexIDs) Pos[fVertID];//坐标
bool bHasVCol = FStaticMeshOperations::HasVertexColor(*CurrentDesc);//判断是否有顶点色
for (const FVertexInstanceID& fInstanceID : FVertexInstanceIDs)
{
        //UV
        for (uint8 uvID = 0; uvID < UVs.GetNumIndices(); uvID++) UVs.Get(fInstanceID, uvID);

        //法线切线
        TDirs[fInstanceID];
        NDirs[fInstanceID];

        //顶点色
        if (bHasVCol) Cols[fInstanceID];
}
——需要额外说明的是三角索引和材质ID的读法。官方做法是拿了个Desc->Triangles().GetElementIDs(),也就是三角面ID数组去遍历每个三角面,然后在这个循环里手动循环3次,以此拿到三角面的三个顶点的ID作为三角索引,像下面这样。
//材质ID、索引
TArray<uint32> VertIDs;
for (const FVertexID& fVertID : FVertexIDs) VertIDs.Add(fVertID.GetValue());//参考源码,记录GetElementIDs中的顶点索引顺序,但这个顺序一般和数组索引相同
for (const FTriangleID& fTriID : Tris.GetElementIDs())
{
        //材质ID
        CurrentDesc->GetPolygonPolygonGroup(FPolygonID(fTriID)).GetValue();

        //三角索引
        for (uint8 cornerID = 0; cornerID < 3; cornerID++)
        {
                FVertexInstanceID vertID = CurrentDesc->GetTriangleVertexInstance(fTriID, cornerID);//三角面ID转InstanceID,效果等价于FVertexInstanceID vertID(3 * fTriID.GetValue() + cornerID)
                VertIDs[CurrentDesc->GetVertexInstanceVertex(vertID).GetValue()];//InstanceID转顶点索引(顶点索引按顺序丢进去就是三角索引数组)
                //Desc->还有很多其他的GetAB的方法,基本就是基于A的一些信息去拿B的意思
        }
}
3.3、其他遍历方式
——在源码的ConvertToRawMesh方法中,连UV、法线切线、顶点色也是套在Corner循环中去拿的,甚至外面还套了层FPolygonID的循环。但经过我测试,不管是在DCC中分材质、分父子模型、分离附加还是导出多边面,FPolygonID和FTriangleID的遍历结果都完全一样···它好像和那些FXXXID一样只是为了代码规范,所以我才省略掉了。
——既然FPolygonID和FTriangleID是等价的,那么材质ID的遍历也就理应能写成下面的形式,实际打印后也是对的上的。
for (const FPolygonID& polyID : CurrentDesc->Polygons().GetElementIDs())
{
        CurrentDesc->GetPolygonPolygonGroup(polyID).GetValue();
}       
——而对三角索引的遍历来说,不需要VertIDs,也不需要用三角面ID去转换,直接按如下操作也能达到相同的效果。
for (const FVertexInstanceID& instanceID : FVertexInstanceIDs)
{
        CurrentDesc->GetVertexInstanceVertex(instanceID).GetValue();
}
——当然,我不确定有没有什么我没测试到的情况会有差别。或者还有种可能是,这些套路是官方为开发新功能预留的。(比如多边面渲染什么的)
——推荐参考一下之前一直在提的源码,也就是FStaticMeshOperations类下的ConvertToRawMesh与ConvertFromRawMesh方法进行学习。我当时也是看了这个才绕明白了Desc的套路。
——另外,关于RM中提到的BuildFromMeshDescription的细节,可以参考下面这篇文章。
4、资源链接

链接:
提取码: fftn
——直接拿去用应该编译的时候还是会报【类名无法解析】之类的错,这是因为项目or插件的Build.cs文件中没有引入对应的模块名称。在根目录->Source->项目名->XXX.Build.cs下补充"RawMesh", "MeshDescription", "StaticMeshDescription",还不行的话再补"RenderCore", "UnrealEd",退出后去根目录generate一下再打开即可。


——另外,如果你的打印结果中包含乱码,可以参考下面的文章进行项目编码格式的修改。
U3D

——相较之下,U3D的网格系统可是太傻白甜了。没有上面这么多种SM、RM、Desc、PM、SK的分类(也可能有但我没用过不知道),也没有多层的嵌套,一个Mesh类点一次就可以全部搞定。
——实际的操作这里就不写了,可以看我之前做的一个毛发效果的文章,其中第二节便是使用的Mesh生成的多层网格。
回复

举报 使用道具

2

主题

4

帖子

8

积分

新手上路

Rank: 1

积分
8
发表于 2023-2-2 13:45:41 | 显示全部楼层
目前创建sm,ue更推荐哪种用法?
回复

举报 使用道具

4

主题

6

帖子

13

积分

新手上路

Rank: 1

积分
13
发表于 2023-2-2 13:46:03 | 显示全部楼层
我听认识的大佬说是description,但目前用哪个都是能跑的。顺便我用的引擎版本是4.26
回复

举报 使用道具

您需要登录后才可以回帖 登录 | 立即注册
快速回复 返回顶部 返回列表