
在上一篇中,我们完成了在道路基础上街区模型的生成。自然而然地,我们希望能在上面加入更丰富的表现和PCG的接入,更详细来说就是这样的需求:

街区细化需求
当时我非常想当然的觉得,这就是一个简单的内部挤压。如果用Blender表现一下就是这个样子:首先我们已经有被道路围合而成的街区Mesh(下图中左侧面,带有圆弧倒角),然后我们面内部挤压(InsetFace)就可以生成如右图的铺装路面,听上去是不是非常简单?

Blender中的内部挤压
此时我大有“物理学的大厦已经基本建成”之感。但经过一番尝试,果不其然“乌云”出现了。这个乌云概括起来就是“如何实现自定义的内部挤压”。
本文对应的[项目GitHub](https://github.com/jiadevr/PCGDemo)使用5.6.1源码编译版本创建,需要C++编译环境。
如果我们打开CitySample,我们可以发现铺装路面的内部转角是Linear构成的无过渡直边,也就是说相比上图,他更类似于下图:

CitySample中的直边过渡
如果我们再调研一些其他方案
[Spline Sidewalk Blueprint | Fab](https://www.fab.com/listings/ed97266e-3767-4095-a29e-05ff4010860c),好的,它干脆就是直接从样条向外铺出来的。

商城Spline Sidewalk Blueprint插件效果图-来自Fab
那么核心的问题就在于如何实现DynamicMesh和Spline的内部挤压,时至今日还没有非常好的解决方案,但我们仍可以为内部挤压进行一些基础性的尝试。
在之前的几何体构建中,我们使用UDyanmicMesh作为几何信息容器,在编辑器下创建了对应的道路、交汇路口和街区。这些构建非常简单,只需要Extrude/Sweep即可,如果如果我们需要一些进一步的功能,就需要稍微多了解一些UDynamicMesh了。
UDynmaicMesh是在4.26版本
[UE4.26运行时的Mesh生成与编辑 - 知乎](https://zhuanlan.zhihu.com/p/345724236)发布的几何体处理类,由UE的首席几何学家Ryan Shmidt和其团队开发。B站内也有不少大佬进行了使用的讲解聆枫-DynamicMesh使用教程、UE官方直播-安宁Ken-UE中的运行时Mesh,甚至Lyra中的L_Expanse_Blockout整个关卡都是如此构建的。
从上面这些参考中我们可以知道DyanmicMesh的操作相比ProceduralMesh更加灵活,对于低频变化物体的性能更优。而在使用中,UDynamicMesh一般遵循创建-修改的流程:
使用Append[XXX]传入给定信息创建基础几何体,就像我们之前使用过的AppendSimpleExtrudePolygon和AppendSweepPolygon,这些几何体是后续操作的基础。
使用Apply[XXX]对基础几何体进行修改,比如布尔、挤出、内部挤压,类似C4D中的修改器概念。
“好,听起来很简单,我们还有很多参考,一定可以把问题解决的!”,但实际操作时就会发现一个问题,几乎所有的示例都在讲如何使用“布尔”操作实现他们的需求。这就不得不提到布尔操作的特殊性:布尔节点是两个Mesh进行处理,可以直接分配其中一个Mesh的材质ID,实现材质选区的划分,布尔完成后选区保留。如下图所示:

Lyra中B_Tool_AdvancedWindow直接对剪除对象设置MaterialID
但我们使用其他Apply节点修改对象为同一个几何体,需要自己配置材质选区,好在DynamicMesh提供了非常完备的蓝图节点,我们可以结合文档
[虚幻引擎中的几何体脚本参考 | 虚幻引擎 5.6 文档 | Epic Developer Community](https://dev.epicgames.com/documentation/zh-cn/unreal-engine/geometry-scripting-reference-in-unreal-engine)
和蓝图进行快速尝试:

在立方体顶面内部挤压并分别设置材质ID
上面的节点中,我们通过面法线方向对不同面进行了筛选,通过配置PolyGroup区分原选择面和新生成面,以此对基础立方体的顶面设置了内部挤压并为挤压结果面、顶部其他面、立方体非顶面设置了不同的材质ID。尝试成功后我们可以点击看到节点对应的静态函数,然后将其翻译成C++接到街区的生成部分,代码片段如下:
//BlockMeshGenerator.cpp-bool UBlockMeshGenerator::GenerateMesh()
//前方省略
UGeometryScriptLibrary_MeshPrimitiveFunctions::AppendSimpleExtrudePolygon(
MeshPtr, GeometryScriptOptions, ExtrudeMeshTrans, ExtrudeShape, 30.0f);
UGeometryScriptLibrary_MeshNormalsFunctions::AutoRepairNormals(MeshPtr);
FGeometryScriptSplitNormalsOptions SplitOptions;
FGeometryScriptCalculateNormalsOptions CalculateOptions;
UGeometryScriptLibrary_MeshNormalsFunctions::ComputeSplitNormals(MeshPtr, SplitOptions,
CalculateOptions);
FGeometryScriptMeshSelection UpFaceSelection;
UGeometryScriptLibrary_MeshSelectionFunctions::SelectMeshElementsByNormalAngle(MeshPtr, UpFaceSelection);
FGeometryScriptMeshInsetOutsetFacesOptions InsetOptions;
InsetOptions.Distance = 400.0f;
InsetOptions.Softness = 100.0f;
InsetOptions.AreaMode = EGeometryScriptPolyOperationArea::EntireSelection;
FGeometryScriptMeshEditPolygroupOptions SplitPolyGroupOptions;
//设置的Group对象是挤压的那个面,也就是原选择面,而不是内部挤压新生成的周边面
SplitPolyGroupOptions.GroupMode = EGeometryScriptMeshEditPolygroupMode::AutoGenerateNew;
SplitPolyGroupOptions.ConstantGroup = 1;
InsetOptions.GroupOptions = SplitPolyGroupOptions;
UGeometryScriptLibrary_MeshModelingFunctions::ApplyMeshInsetOutsetFaces(MeshPtr, InsetOptions, UpFaceSelection);
UGeometryScriptLibrary_MeshMaterialFunctions::EnableMaterialIDs(MeshPtr);
//先给所有顶面都设置
FGeometryScriptMeshSelection NewUpFaceSelection;
UGeometryScriptLibrary_MeshSelectionFunctions::SelectMeshElementsByNormalAngle(MeshPtr, NewUpFaceSelection);
UGeometryScriptLibrary_MeshMaterialFunctions::SetMaterialIDForMeshSelection(MeshPtr, NewUpFaceSelection, 1);
//把内部挤压的单独拿出来设置
UGeometryScriptLibrary_MeshMaterialFunctions::SetMaterialIDForMeshSelection(MeshPtr, UpFaceSelection, 0);
//其他不可见的面
FGeometryScriptMeshSelection OtherFaceSelection;
UGeometryScriptLibrary_MeshSelectionFunctions::SelectMeshElementsByNormalAngle(
MeshPtr, OtherFaceSelection, FVector::UpVector, 1, EGeometryScriptMeshSelectionType::Triangles, true);
UGeometryScriptLibrary_MeshMaterialFunctions::SetMaterialIDForMeshSelection(MeshPtr, OtherFaceSelection, 2);
if (Materials.IsEmpty())
{
InitialMaterials();
}
if (!Materials.IsEmpty() && MeshComponent.IsValid())
{
MeshComponent->ConfigureMaterialSet(Materials);
}
//后方省略 在生成时有两点需要注意:
法线校正的顺序:在筛选面的时候我们使用NormalAngle进行筛选,与PCG节点类似。但要注意我们在建模时挤出之后调用了Normal的修复和分离后才生成了正确的法线。因此我们在筛选前需要先修正法线再进行修改。
材质设置的顺序:正如我们在Blender使用内部挤压时,我们初始选择的面和完成内部挤压时选择的面是同一个面,而周边面为新生成,我们在设置PolyGroup的时候设置的是初始选择面。所以我们再次选择全部顶面设置材质ID,再用PolyGroup匹配初始面进行选区ID覆盖。
按照我们的预想,经过挤压每隔一个街区都可以形成一个“包边”的结构,但事情显然不会这么顺利,在Linear类型的拐点处就出现了如下问题:

Linear类型拐点处成面错误
不难看出在拐点位置,道路和街区都发生了一定程度的“扭曲”,这种扭曲来源于点的旋转值。这个点的旋转保持了和左侧路段一致,右侧路段需要进行“扭转衔接”,进而使得在获取街区边界点的时候过渡不自然,内部挤压使得这一问题更加明显。
那么我们该如何解决这个问题呢?既然是点旋转造成的,我们可以考虑调整拐点处的旋转值,取两条路径的平均值以减少扭转,在拐点处添加细分点增加点数使得内部挤压有足够的操作点。
既然这个问题会同时影响多个位置,我们需要考虑如何从更靠近源头的地方解决:在URoadGeneratorSubsystem::ResampleSpline()中我们处理了样条的重采样方式,进而生成Segments。我们可以在这里对Linear类型的点进行特殊处理:
在Linear前后一定位置各插入一个点,在这两个点处采样样条的旋转。
使用两个旋转的平均值作为原Linear点的旋转值。
相当于对Linear点进行细分实现旋转的渐变过渡,这也要求前后两个插入点要距离原控制点尽量近,如下图所示:

在Linear前后插入点
从图中我们也可以看出,Linear类型转角比较锐利、前后两个分段长度又比较小,这会造成另一个问题,当分段过小时,会出现多个分段距离过近、被四叉树判定为多叉路口的情况。这里有两种解决方式:
禁止使用Linear类型过度点,使用两段线段实现上面的效果,我们在构建初始道路模型时已经处理过两条Spline相交的模型,因此可以实现圆弧过渡,从根源上避免这种问题。
调整分段长度,避免多个Segment重叠,这种方式保留了Linear过度点存在的可能性,但新的分段长度需要小心控制。
为了尽可能保持用户点的自由度,本文选用了第二种方式。结合分段长度、四叉树节点大小,确定附加分段是正常分段的0.4倍时可以避免重叠问题,为了避免后续其他位置出现问题不便于定位,在生成交汇路口出现特征分段时进行断言。修改后的ResampleSpline()函数如下:
//RoadGeneratorSubsystem.cpp
TArray<FTransform> URoadGeneratorSubsystem::ResampleSpline(const USplineComponent* TargetSpline)
{
TArray<FTransform> Results;
if (nullptr == TargetSpline || TargetSpline->GetNumberOfSplinePoints() <= 1)
{
return Results;
}
const float SegmentMaxDisThreshold = 10 * PolyLineSampleDistance;
TArray<FVector> PolyLineEndPointLoc;
TArray<double> PolyLineLengths;
//曲线,该函数返回闭合样条返回段,Distance数组是到每一个端点处的长度(类似前缀和),ControlPoint位置一定会有一个采样点
TargetSpline->ConvertSplineToPolyLineWithDistances(ESplineCoordinateSpace::World, PolyLineSampleDistance,
PolyLineEndPointLoc, PolyLineLengths);
//先处理Linear
TArray<int32> LinearControlPointIndexes;
for (int32 i = 0; i < PolyLineLengths.Num(); ++i)
{
float InputKey = TargetSpline->GetInputKeyValueAtDistanceAlongSpline(PolyLineLengths[i]);
if (IsIntegerInFloatFormat(InputKey))
{
int32 ControlPointIndex = static_cast<int32>(InputKey);
if (TargetSpline->GetSplinePointType(ControlPointIndex) == ESplinePointType::Linear && i > 0 &&
i < PolyLineLengths.Num() - 1)
{
LinearControlPointIndexes.Add(i);
}
}
}
//所有细分点的初始内容
Results.Reserve(PolyLineEndPointLoc.Num());
for (int i = 0; i < PolyLineLengths.Num(); ++i)
{
Results.Emplace(
TargetSpline->GetTransformAtDistanceAlongSpline(PolyLineLengths[i], ESplineCoordinateSpace::World,
true));
}
TMap<int32, TArray<FTransform>> InterplatePointsOnControlPoints;
TMap<int32, TArray<double>> InterplateLengthOnSpline;
if (!LinearControlPointIndexes.IsEmpty())
{
for (int32& ControlPointIndex : LinearControlPointIndexes)
{
//值过小会因为被四叉树判定为相交,生成交汇路口时报错
const float AdditionalSampleDistance = 4 * PolyLineSampleDistance;
//判断和前边点的距离
float FrontNeighbourDis = PolyLineLengths[ControlPointIndex] - PolyLineLengths[ControlPointIndex - 1];
FTransform LastTransform = TargetSpline->GetTransformAtDistanceAlongSpline(
PolyLineLengths[ControlPointIndex - 1], ESplineCoordinateSpace::World,
true);
if (FrontNeighbourDis > AdditionalSampleDistance)
{
LastTransform = TargetSpline->GetTransformAtDistanceAlongSpline(
PolyLineLengths[ControlPointIndex] - AdditionalSampleDistance,
ESplineCoordinateSpace::World,
true);
InterplatePointsOnControlPoints.Emplace(ControlPointIndex - 1).Add(LastTransform);
InterplateLengthOnSpline.Emplace(ControlPointIndex - 1).Emplace(
PolyLineLengths[ControlPointIndex] - AdditionalSampleDistance);
}
float NextNeighbourDis = PolyLineLengths[ControlPointIndex + 1] - PolyLineLengths[ControlPointIndex];
FTransform NextTransform = TargetSpline->GetTransformAtDistanceAlongSpline(
PolyLineLengths[ControlPointIndex + 1], ESplineCoordinateSpace::World,
true);
if (NextNeighbourDis > AdditionalSampleDistance)
{
NextTransform = TargetSpline->GetTransformAtDistanceAlongSpline(
PolyLineLengths[ControlPointIndex] + AdditionalSampleDistance,
ESplineCoordinateSpace::World,
true);
InterplatePointsOnControlPoints.Add(ControlPointIndex).Add(NextTransform);
InterplateLengthOnSpline.Add(ControlPointIndex).Emplace(
PolyLineLengths[ControlPointIndex] + AdditionalSampleDistance);
}
//FQuat不要使用(A+B)/2计算值不对
FQuat AverageRotator = FQuat::Slerp(LastTransform.GetRotation(), NextTransform.GetRotation(), 0.5);
Results[ControlPointIndex].SetRotation(AverageRotator);
FVector RotDir = AverageRotator.Rotator().Vector();
}
InsertElementsAtIndex(Results, InterplatePointsOnControlPoints);
InsertElementsAtIndex(PolyLineLengths, InterplateLengthOnSpline);
}
TMap<int32, TArray<FTransform>> SegmentsToSubdivide;
//检测上面函数返回的分段长度是否符合设定要求,如果不符合则记录位置进一步处理
for (int i = 1; i < Results.Num(); ++i)
{
if (PolyLineLengths[i] - PolyLineLengths[i - 1] > SegmentMaxDisThreshold)
{
//以该点为起点的位置需要插入元素
SegmentsToSubdivide.Emplace(i - 1);
}
}
if (SegmentsToSubdivide.IsEmpty())
{
return Results;
}
//如果需要处理,在记录的位置增加细分
for (TPair<int32, TArray<FTransform>>& TargetSegment : SegmentsToSubdivide)
{
const int32 SegmentIndex = TargetSegment.Key;
const float OriginalSegmentLength = PolyLineLengths[SegmentIndex + 1] - PolyLineLengths[SegmentIndex];
int32 TargetSubdivisionNum = FMath::CeilToInt32(OriginalSegmentLength / SegmentMaxDisThreshold);
double TargetSubdivisionLength = OriginalSegmentLength / TargetSubdivisionNum;
TArray<FTransform> SubdivisionPoints;
for (int32 j = 1; j < TargetSubdivisionNum; j++)
{
float DisToSubdivisionPoint = static_cast<float>(j * TargetSubdivisionLength + PolyLineLengths[
SegmentIndex]);
TargetSegment.Value.Add(
TargetSpline->GetTransformAtDistanceAlongSpline(DisToSubdivisionPoint, ESplineCoordinateSpace::World));
}
}
//FTransform为非POD对象,不能直接内存拷贝
InsertElementsAtIndex(Results, SegmentsToSubdivide);
return Results;
} 这里还有一个要点是旋转平均值的求取需要使用FQuat的SLerp方法,不能直接对FQuat使用相加除2的方式。
在材质设置方面,CitySample提供了非常丰(庞)富(杂)的材质,但考虑到Git对二进制文件支持比较差,这里使用函数InitialMaterials提供了一种兼容方案。在函数中读取了Generator的两个成员变量MaterialsPath和BackupMaterialsPath,其中MaterialsPath配置为CitySample材质,如果已经将Road/Material迁移到项目中则自动选择该路径;而BackupMaterialsPath对应了Git仓库中提供的基础材质,只要拉取了插件即可使用,兼顾仓库大小控制和基础可视效果实现。
资产有效性判定是上面需求的基础,按常规来看,我们需要定位到对应路径、查找文件、匹配后缀、尝试读取,同时还会遇到Content路径和Plugin路径的分别处理,实践起来还是有些麻烦。这时我们可以使用UEditorAssetSubsystem::DoesAssetExist(),只需要在Content中CopyPackagePath并应用到我们的代码中,不需要对路径进行额外处理。

获取资产加载路径
对应的代码如下:
//BlockMeshGenerator.h
const TArray<FString> MaterialsPath{
"/Game/Road/Material/MI/MI_FreewayConcrete_PropsWS02", "/Game/Road/Material/MI/MI_Sidewalk_Plaza_E_WS"
};
const TArray<FString> BackupMaterialsPath{
"/JIAPCGAidTool/CityGeneratorContent/MI_BlockCenter", "/JIAPCGAidTool/CityGeneratorContent/MI_Sidewalk"
};
//BlockMeshGenerator.cpp
void UBlockMeshGenerator::InitialMaterials()
{
Materials.Empty();
UEditorAssetSubsystem* AssetSubsystem = GEditor->GetEditorSubsystem<UEditorAssetSubsystem>();
if (!AssetSubsystem)
{
return;
}
for (int i = 0; i < MaterialsPath.Num(); ++i)
{
const FString* MaterialPath = &MaterialsPath[i];
if (!AssetSubsystem->DoesAssetExist(*MaterialPath))
{
if (AssetSubsystem->DoesAssetExist(*BackupMaterialsPath[i]))
{
MaterialPath = &BackupMaterialsPath[i];
}
else
{
MaterialPath = nullptr;
}
}
if (nullptr == MaterialPath)
{
Materials.Emplace(nullptr);
continue;
}
UObject* TargetMatAsset = AssetSubsystem->LoadAsset(*MaterialPath);
if (TargetMatAsset)
{
Materials.Add(Cast<UMaterialInterface>(TargetMatAsset));
}
}
Materials.Emplace(nullptr);
} 完成上面的设置之后我们就可以获得初步的内部挤压面了,如下图所示:

街区内部挤压并赋予材质
当然这种内部挤压面其实和我们需要的还有差距,大概也只是跑通了基本路径的60分水平,后面会再进一步进行优化。
在IPCC中提供了多种建筑体块生成方式,其中比较有参考意义的是EdgeBased的系列算法。首先在街区内生成一条参考线,在参考线上尝试摆放建筑,直到排满或者无法再装下,类似于01背包问题的二维版。
为了使用这个算法我们需要提取街区轮廓的Spline,然后收缩一定距离。和上面的几何体构建可以说是殊途同归了,虽然目前还是没有适配度很高的收缩算法,但我们依然可以先把第一步完成:提取道路的边界轮廓。
这里有两种选择,如何还记得上一篇街区构造的内容不难想到:我们在构建街区的时候已经使用图提取过道路、交汇路口的边界点,可以由这些点获取街区的边界;同时我们还持有各条道路的Spline引用,我们可以从USplineComponent中截取分段。
既然已经尝试过点操作了,这里不妨试(踩)试(坑)USplineComponent截取分段的方法。
这里先介绍一下USplineComponent的基础知识。在前面生成交汇路口的时候,我们提到USplineComponent比较重,没必要为交汇路口创建,这是因为Spline内部包括描述位置和Tangent的FInterpCurveVector、描述旋转的FInterpCurveQuat,描述缩放的FInterpCurveVector和用于映射的FInterpCurveFloat。一般来说我们仅需要描述位置和Tangent的FInterpCurveVector就可以构成曲线。描述旋转的FInterpCurveQuat一般用于额外调整分离的Rotation,比如在Sequencer中的相机角度等。
在FInterpCurveVector中Tangent值表示的是控制点切线的方向,在UE中存储为一个局部空间矢量,可以理解成在编辑Spline时的控制柄矢量,和控制点位置相加形成了控制柄位置,如下所示:

世界空间下的控制点和Tangent控制柄,可以看出和LeaveTangent匹配
铺垫完Spline的基础知识之后,我们再说一下为什么这里选择创建Spline:不同于交汇路口,这里Spline的创建主要目的是尝试为后面PCG采样提供更多信息,Spline可以直接被PCGComponent采样到,而使用FInterpCurveVector需要我们进行额外转换(当然也不排除后面再改方案)。
说回到街区Spline的提取,该过程主要分为以下几步:
根据图找到对应的RoadGenerator。
从RoadGenerator中读取引用的Spline,根据RoadGenerator端点提取Spline中两点间的控制点。
把每条Spline的控制点信息传给BlockGenerator,由BlockGenerator合成样条线。
第一步和生成街区时一致,这里不再赘述,我们从第二步开始分析。
我们提取样条的时候核心要求是尽量保持街区轮廓,街区轮廓由道路构成,道路由最开始输入的用户设定Spline生成,而Spline的多种点类型就要求我们在提取时保留位置和Tangent信息。我们需要提取的控制点包括道路两个端点、端点之间的用户输入控制点。
在道路创建过程中,交汇路口把整条Spline切成多段,每段都会创建一个RoadActor。在RoadActor中我们有道路构成的点信息和SplineComponent的引用,因此我们可以通过位置从Spline中查到自身的Distance值,后续的内容都可以根据Distance值获得。考虑到我们输出的值需要包含Location和Tangent,可以使用FInterpCurveVector作为返回值容器。
在所有曲线中,我们需要特别关注ClosedLoop类型,它在0点时相交的特性带来了两个问题:

处理环路时的两个问题
0点跨越问题:在道路分布中起点和终点位于0点的两侧,如上图中三角形标注的RoadStart和RoadEnd,在遍历过程中需要准确处理序号以防越界。
终点连接Tangent问题:在闭合样条的最后一点的LeaveTangent和第一点的ArriveTangent会被强制设置为0,如果我们直接设置,会形成如下所示的“硬拐点”

Loop连接处Tangent为0
对于第一个问题,可以和城区生成时使用类似的方法,只需要注意我们获取的方向和道路生成的方向是否同向即可。
对于第二个问题,既然我们没有办法改变特性,那么可以使用我们最熟悉的方法——加点,只我们可以再中间再插入一个/多个点来解决这个问题,这里我们首先尝试在两点中点处加入一个点,代码片段如下:
//RoadMeshGenerator.cpp
FInterpCurveVector URoadMeshGenerator::GetSplineControlPointsInRoadRange(bool bForwardOrderDir,ECoordOffsetType OffsetType,float CustomOffsetOnLeft)
{
FInterpCurveVector Result;
if (!ReferenceSpline.IsValid())
{
return Result;
}
USplineComponent* OwnerSpline = ReferenceSpline.Pin().Get();
AActor* OwnerActor = GetOwner();
if (OwnerActor == nullptr) { return Result; }
const float DirectionScalar = bForwardOrderDir ? -1.0f : 1.0f;
FVector RoadStartLocation = SweepPointsTrans[0].GetLocation();
RoadStartLocation = UKismetMathLibrary::TransformLocation(OwnerActor->GetTransform(), RoadStartLocation);
//下面这个函数有问题,输入数字大的时候使用Local可能会输出(0,0,0)
float StartAsDist = OwnerSpline->GetDistanceAlongSplineAtLocation(RoadStartLocation, ESplineCoordinateSpace::World);
float StartAsInputKey = OwnerSpline->GetInputKeyValueAtDistanceAlongSpline(StartAsDist);
//处理偏移问题
GetWSPointFromRoadCenterWithOffset(RoadStartLocation, StartAsDist, OwnerSpline, bForwardOrderDir, OffsetType, CustomOffsetOnLeft);
FInterpCurvePoint<FVector> StartPoint(bForwardOrderDir ? 0.0f : 1.0f, RoadStartLocation, FVector::ZeroVector,FVector::ZeroVector, CIM_Constant);
FVector RoadEndLocation = SweepPointsTrans.Last().GetLocation();
RoadEndLocation = UKismetMathLibrary::TransformLocation(OwnerActor->GetTransform(), RoadEndLocation);
//下面这个函数有问题,输入数字大的时候使用Local可能会输出(0,0,0)
float EndAsDist = OwnerSpline->GetDistanceAlongSplineAtLocation(RoadEndLocation, ESplineCoordinateSpace::World);
float EndAsInputKey = OwnerSpline->GetInputKeyValueAtDistanceAlongSpline(EndAsDist);
//处理偏移
GetWSPointFromRoadCenterWithOffset(RoadEndLocation, EndAsDist, OwnerSpline, bForwardOrderDir, OffsetType, CustomOffsetOnLeft);
FInterpCurvePoint<FVector> EndPoint(bForwardOrderDir ? 1.0f : 0.0f, RoadEndLocation, FVector::ZeroVector, FVector::ZeroVector,CIM_Constant);
//起点到终点跨过的ControlPoints个数,需要特别考虑Loop类型End<Start
int32 ControlPointNumInRange = FMath::FloorToInt(EndAsInputKey) - FMath::FloorToInt(StartAsInputKey);
int32 NumControlPoints = OwnerSpline->GetNumberOfSplinePoints();
bool bInLoopArea = false;
//Loop类型Start从较大值绕回到Start
if (ControlPointNumInRange < 0)
{
ensure(OwnerSpline->IsClosedLoop());
ControlPointNumInRange = NumControlPoints - FMath::CeilToInt(StartAsInputKey) +
FMath::CeilToInt(EndAsInputKey);
bInLoopArea = true;
}
Result.Points.Reserve(2 + ControlPointNumInRange);
const FInterpCurvePoint<FVector>& FirstElem = bForwardOrderDir ? StartPoint : EndPoint;
Result.Points.Add(FirstElem);
const FInterpCurvePoint<FVector>& LastElem = bForwardOrderDir ? EndPoint : StartPoint;
//是结尾到开头连接的部分
if (ControlPointNumInRange > 0)
{
for (int32 i = 1; i <= ControlPointNumInRange; i++)
{
int32 ResultIndex = bForwardOrderDir? (FMath::FloorToInt(StartAsInputKey) + i) % (NumControlPoints): (FMath::CeilToInt(EndAsInputKey) - i + NumControlPoints) % (NumControlPoints);
float InputKeyAsFloat = static_cast<float>(ResultIndex);
FVector ControlPointLocWS = OwnerSpline->GetLocationAtSplineInputKey(InputKeyAsFloat,
ESplineCoordinateSpace::World);
//只要调整了Tangent控制柄就会变成CustomTangent,对应产生Rotation
FVector ControlPointArriveTangentWS = (bForwardOrderDir ? 1.0 : -1.0) * OwnerSpline->GetArriveTangentAtSplinePoint(InputKeyAsFloat, ESplineCoordinateSpace::World);
FVector ControlPointLeaveTangentWS = (bForwardOrderDir ? 1.0 : -1.0) * OwnerSpline->GetLeaveTangentAtSplinePoint(InputKeyAsFloat, ESplineCoordinateSpace::World);
ESplinePointType::Type PointType = OwnerSpline->GetSplinePointType(ResultIndex);
//对于获取边缘的情况
GetWSPointFromRoadCenterWithOffset(ControlPointLocWS, ResultIndex, OwnerSpline, bForwardOrderDir,OffsetType, CustomOffsetOnLeft);
float TempTime = i / (1.0f + ControlPointNumInRange);
//对于Curve类型两个Tangent值相同
FInterpCurvePoint<FVector> MidPoint(TempTime, ControlPointLocWS, ControlPointArriveTangentWS,ControlPointLeaveTangentWS,ConvertSplinePointTypeToInterpCurveMode(PointType));
if (i == 1 || i == ControlPointNumInRange)
{
//两者均已经转化为世界坐标
double DistanceToNeighbour = FVector::DistSquared(ControlPointLocWS,i == 1 ? FirstElem.OutVal : LastElem.OutVal);
if (DistanceToNeighbour <= 10000)
{
continue;
}
}
if (bInLoopArea && ResultIndex == OwnerSpline->GetNumberOfSplinePoints() - 1)
{
//对最后一个点到第一个点在后边插入一个点,避免CloseLoop时强制设置Tangent使得Spline走形
float AppendPointDis = OwnerSpline->GetSplineLength() - (OwnerSpline->GetSplineLength() - OwnerSpline->GetDistanceAlongSplineAtSplinePoint(OwnerSpline->GetNumberOfSplinePoints() - 1)) * 0.5f;
FVector AppendPointLocWS = OwnerSpline->GetLocationAtDistanceAlongSpline(AppendPointDis, ESplineCoordinateSpace::World);
DrawDebugSphere(GetWorld(), AppendPointLocWS, 100.0f, 10, FColor::Red, true, -1, 0, 5.0f);
UE_LOG(LogTemp, Display, TEXT("Add Location At %s Distance %f"), *AppendPointLocWS.ToString(),AppendPointDis);
GetWSPointFromRoadCenterWithOffset(AppendPointLocWS, AppendPointDis, OwnerSpline, bForwardOrderDir,OffsetType, CustomOffsetOnLeft);
FVector AppendPointTangentWS = (bForwardOrderDir ? 1.0 : -1.0) * OwnerSpline->GetTangentAtDistanceAlongSpline(AppendPointDis, ESplineCoordinateSpace::World);
TempTime += 0.5f / (1.0f + ControlPointNumInRange);
FInterpCurvePoint<FVector> AppendPoint(TempTime, AppendPointLocWS, AppendPointTangentWS, AppendPointTangentWS, CIM_CurveAuto);
MidPoint.LeaveTangent = MidPoint.ArriveTangent;
if (bForwardOrderDir)
{
Result.Points.Add(MidPoint);
}
Result.Points.Add(AppendPoint);
if (bForwardOrderDir)
{
continue;
}
}
Result.Points.Add(MidPoint);
}
}
//已经是世界空间了
Result.Points.Emplace(LastElem);
return Result;
} 需要注意的是在使用Spline位置信息提取Distance值时,尽量使用ESplineCoordinateSpace::World,出于不明原因,较大的坐标参数使用ESplineCoordinateSpace::Local时会错误返回(0,0,0),影响后续整体逻辑。
获得单段FInterpCurveVector之后,我们需要把这些Spline片段拼起来。拼凑的内容和街区生成强相关,所以我们在BlockGenerator中编写相关的逻辑。基于图我们可以获得按顺序的片段顺序,每段片段顺序中的点顺序已经排好,只要进行简单拼装,多个片段之间使用直线连接:
//BlockMeshGenerator.cpp
void UBlockMeshGenerator::SetInnerSplinePoints(
const TArray<FInterpCurveVector>& InOrderedControlPoints)
{
ControlPointsOfAmongRoads = InOrderedControlPoints;
//DrawDebugLines
if (bDrawVisualDebug)
{
FColor LineInIndividualGroup = FColor::MakeRandomSeededColor(GetGlobalIndex());
FColor LineBetweenGroups = FColor::MakeRandomColor();
FVector VerticalOffset = FVector::UpVector * 50.0;
TArray<const FInterpCurvePoint<FVector>*> ConnectedPoints;
ConnectedPoints.Reserve(ControlPointsOfAmongRoads.Num() * 2);
for (const FInterpCurve<FVector>& ControlPointsOfSingleRoad : ControlPointsOfAmongRoads)
{
ConnectedPoints.Emplace(&ControlPointsOfSingleRoad.Points[0]);
for (int i = 1; i < ControlPointsOfSingleRoad.Points.Num(); ++i)
{
FInterpCurvePoint<FVector> LastPoint = ControlPointsOfSingleRoad.Points[i - 1];
FInterpCurvePoint<FVector> CurrentPoint = ControlPointsOfSingleRoad.Points[i];
DrawDebugLine(GetWorld(), LastPoint.OutVal + VerticalOffset, CurrentPoint.OutVal + VerticalOffset,LineInIndividualGroup, true, -1, 0, 50.0f);
}
ConnectedPoints.Emplace(&ControlPointsOfSingleRoad.Points.Last());
}
for (int32 i = 1; i < ConnectedPoints.Num(); i += 2)
{
int32 ConnectTo = (i + 1) % ConnectedPoints.Num();
DrawDebugLine(GetWorld(), ConnectedPoints[i]->OutVal + VerticalOffset, ConnectedPoints[ConnectTo]->OutVal + VerticalOffset, LineBetweenGroups, true,-1, 0, 50.0f);
}
}
}
void UBlockMeshGenerator::GenerateInnerRefSpline()
{
AActor* Owner = MeshComponent->GetOwner();
if (nullptr == Owner)
{
return;
}
UActorComponent* SplineCompTemp = UEditorComponentUtilities::AddComponentInEditor(
Owner, USplineComponent::StaticClass());
if (nullptr == SplineCompTemp) { return; }
RefSpline = Cast<USplineComponent>(SplineCompTemp);
RefSpline->ClearSplinePoints();
TArray<const FInterpCurvePoint<FVector>*> ControlPoints;
for (int32 i = 0; i < ControlPointsOfAmongRoads.Num(); ++i)
{
AdjustTangentValueInline(ControlPointsOfAmongRoads[i]);
//样条线逆时针排序,
for (int j = 0; j < ControlPointsOfAmongRoads[i].Points.Num(); ++j)
{
ControlPoints.Emplace(&ControlPointsOfAmongRoads[i].Points[j]);
}
//理论上不存在
if (1 == ControlPointsOfAmongRoads[i].Points.Num())
{
continue;
}
}
for (int i = 0; i < ControlPoints.Num(); ++i)
{
FVector LocationInLS = UKismetMathLibrary::InverseTransformLocation(
Owner->GetTransform(), ControlPoints[i]->OutVal);
FVector ArriveTangentInLS = UKismetMathLibrary::InverseTransformDirection(
Owner->GetTransform(), ControlPoints[i]->ArriveTangent);
FVector LeaveTangentInLS = UKismetMathLibrary::InverseTransformDirection(
Owner->GetTransform(), ControlPoints[i]->LeaveTangent);
ESplinePointType::Type PointType = ConvertInterpCurveModeToSplinePointType(ControlPoints[i]->InterpMode);
if (PointType == ESplinePointType::Curve || PointType == ESplinePointType::CurveClamped)
{
PointType = ESplinePointType::CurveCustomTangent;
}
FSplinePoint SplinePoint(i, LocationInLS, ArriveTangentInLS, LeaveTangentInLS, FRotator(0), FVector(1), PointType);
RefSpline->AddPoint(SplinePoint, false);
}
RefSpline->SetClosedLoop(true, false);
RefSpline->UpdateSpline();
} 形成的SplineComponent如下图所示:

提取形成的新Spline(紫色)
那么事情是不是又如此简单呢?因为我们是直接提取了所在样条的Tangent值,所以每个点处的Tangent也会继承原样条参数,这就会使得一些Tangent值很大的控制点对我们新形成的样条点造成一些影响:

Tangent值过大(橙色)造成的连接段(红色)扭转
既然我们知道Tangent值是控制点处的切向量,可视化表现为手柄长度,那么我们可以通过计算控制点和相邻点之间的距离来调整Tangent值,这就是上面代码中AdjustTangentValueInline()的作用。
//BlockMeshGenerator.cpp
void UBlockMeshGenerator::AdjustTangentValueInline(FInterpCurve<FVector>& PointGroup)
{
if (PointGroup.Points.Num() <= 2)
{
return;
}
//首尾不涉及Tangent问题
for (int32 i = 1; i < PointGroup.Points.Num() - 1; ++i)
{
const FVector& LocWS = PointGroup.Points[i].OutVal;
DrawDebugSphere(GetWorld(), LocWS, 100.0f, 10, FColor::Red, true, -1, 0, 10.0f);
const FVector& PreLocWS = PointGroup.Points[i - 1].OutVal;
const FVector& NextLocWS = PointGroup.Points[i + 1].OutVal;
const double DisSquaredToNeighbour = FMath::Min(FVector::DistSquared(LocWS, PreLocWS),
FVector::DistSquared(LocWS, NextLocWS));
//使用Curve类型时ArriveTangent和LeaveTangent可以不同,根据SplineControlPoint管的是后一段的思路使用LeaveTangent
FVector& Tangent = PointGroup.Points[i].LeaveTangent;
if (Tangent.SquaredLength() > DisSquaredToNeighbour && (PointGroup.Points[i].InterpMode == CIM_CurveUser ||PointGroup.Points[i].InterpMode == CIM_CurveAuto || PointGroup.Points[i].InterpMode ==CIM_CurveAutoClamped))
{
PointGroup.Points[i].InterpMode = CIM_CurveUser;
float OriginalDistance = Tangent.Size();
Tangent = Tangent.GetSafeNormal() * 0.8 * FMath::Sqrt(DisSquaredToNeighbour);
PointGroup.Points[i].ArriveTangent = Tangent;
UE_LOG(LogTemp, Display,
TEXT("Index [%d] At [%s] Tangent Value Was Clamped From %f To %f,newTangentValue %s"), i,
*LocWS.ToString(), OriginalDistance, Tangent.Size(), *Tangent.ToString());
}
}
} 这面需要注意在设置Tangent的时候使用LeaveTangent作为最终Tangent的赋值,因为每个控制点控制的是它所在点后一段样条。调整之后效果如图:


街区轮廓样条提取效果
经过一番努力,我们在街区几何体生成的基础上实现了街区地面铺装的划分和Spline的提取的60分版本。但在实现的过程我们不免感受到一些“违和感”。正如文章开头提到的,几何体划分和建筑群轮廓参考线的核心问题是“如何实现自定义的内部挤压”,而样条的内部挤压(或收缩)其实并不是一件容易的事情。
正如上面我们对样条的处理中,样条Tangent取值的灵活性导致其变化性极大,在收缩过程中会产生很多的CornerCase。反而我们在构建街区时使用的分段“以直追曲”处理起来更容易些。同时使用弧线对我们生成无过渡直边也有影响,后续很可能还是需要再次拆分为分段,这也是前文“按下不表”的原因,在下一篇文章中我们会使用街道构成的点集进行更多尝试。
虽然没有实现目的,但我仍然认为在走这条技术路径中对UDynamicMeshComponent和USplineComponent的一些使用注意事项可能对有相似需求的朋友有些启发作用,希望可以帮到大家。
祝大家10.24程序员节快乐!