[UE5]CitySample复刻计划(4)-街区生成细化中的UDynamicMesh和USplineComponent使用
Epslin
2025年10月24日 22:51
收录于文集
共12篇

名为“内部挤压”的乌云

上一篇​中,我们完成了在道路基础上街区模型的生成。自然而然地,我们希望能在上面加入更丰富的表现和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的内部挤压,时至今日还没有非常好的解决方案,但我们仍可以为内部挤压进行一些基础性的尝试。

UDynamicMesh使用拾遗

在之前的几何体构建中,我们使用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一般遵循创建-修改的流程:

  1. 使用Append[XXX]传入给定信息创建基础几何体,就像我们之前使用过的AppendSimpleExtrudePolygon和AppendSweepPolygon,这些几何体是后续操作的基础。

  2. 使用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++接到街区的生成部分,代码片段如下:

代码块
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);
	}
//后方省略
复制成功

在生成时有两点需要注意:

  1. 法线校正的顺序:在筛选面的时候我们使用NormalAngle进行筛选,与PCG节点类似。但要注意我们在建模时挤出之后调用了Normal的修复和分离后才生成了正确的法线。因此我们在筛选前需要先修正法线再进行修改。

  2. 材质设置的顺序:正如我们在Blender使用内部挤压时,我们初始选择的面和完成内部挤压时选择的面是同一个面,而周边面为新生成,我们在设置PolyGroup的时候设置的是初始选择面。所以我们再次选择全部顶面设置材质ID,再用PolyGroup匹配初始面进行选区ID覆盖。

直角转弯处理

按照我们的预想,经过挤压每隔一个街区都可以形成一个“包边”的结构,但事情显然不会这么顺利,在Linear类型的拐点处就出现了如下问题:

Linear类型拐点处成面错误

不难看出在拐点位置,道路和街区都发生了一定程度的“扭曲”,这种扭曲来源于点的旋转值。这个点的旋转保持了和左侧路段一致,右侧路段需要进行“扭转衔接”,进而使得在获取街区边界点的时候过渡不自然,内部挤压使得这一问题更加明显。

那么我们该如何解决这个问题呢?既然是点旋转造成的,我们可以考虑调整拐点处的旋转值,取两条路径的平均值以减少扭转,在拐点处添加细分点增加点数使得内部挤压有足够的操作点。

既然这个问题会同时影响多个位置,我们需要考虑如何从更靠近源头的地方解决:在URoadGeneratorSubsystem::ResampleSpline()中我们处理了样条的重采样方式,进而生成Segments。我们可以在这里对Linear类型的点进行特殊处理:

  1. 在Linear前后一定位置各插入一个点,在这两个点处采样样条的旋转。

  2. 使用两个旋转的平均值作为原Linear点的旋转值。

相当于对Linear点进行细分实现旋转的渐变过渡,这也要求前后两个插入点要距离原控制点尽量近,如下图所示:

在Linear前后插入点

从图中我们也可以看出,Linear类型转角比较锐利、前后两个分段长度又比较小,这会造成另一个问题,当分段过小时,会出现多个分段距离过近、被四叉树判定为多叉路口的情况。这里有两种解决方式:

  1. 禁止使用Linear类型过度点,使用两段线段实现上面的效果,我们在构建初始道路模型时已经处理过两条Spline相交的模型,因此可以实现圆弧过渡,从根源上避免这种问题。

  2. 调整分段长度,避免多个Segment重叠,这种方式保留了Linear过度点存在的可能性,但新的分段长度需要小心控制。

为了尽可能保持用户点的自由度,本文选用了第二种方式。结合分段长度、四叉树节点大小,确定附加分段是正常分段的0.4倍时可以避免重叠问题,为了避免后续其他位置出现问题不便于定位,在生成交汇路口出现特征分段时进行断言。修改后的ResampleSpline()函数如下:

代码块
C++
自动换行
复制代码
//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的方式

UEditorAssetSubsystem快捷判断资产存在性

在材质设置方面,CitySample提供了非常丰(庞)富(杂)的材质,但考虑到Git对二进制文件支持比较差,这里使用函数InitialMaterials提供了一种兼容方案。在函数中读取了Generator的两个成员变量MaterialsPath和BackupMaterialsPath,其中MaterialsPath配置为CitySample材质,如果已经将Road/Material迁移到项目中则自动选择该路径;而BackupMaterialsPath对应了Git仓库中提供的基础材质,只要拉取了插件即可使用,兼顾仓库大小控制和基础可视效果实现。

资产有效性判定是上面需求的基础,按常规来看,我们需要定位到对应路径、查找文件、匹配后缀、尝试读取,同时还会遇到Content路径和Plugin路径的分别处理,实践起来还是有些麻烦。这时我们可以使用UEditorAssetSubsystem::DoesAssetExist(),只需要在Content中CopyPackagePath并应用到我们的代码中,不需要对路径进行额外处理。

获取资产加载路径

对应的代码如下:

代码块
C++
自动换行
复制代码
//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分水平,后面会再进一步进行优化。

Spline提取方案设计

在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的提取,该过程主要分为以下几步:

  1. 根据图找到对应的RoadGenerator。

  2. 从RoadGenerator中读取引用的Spline,根据RoadGenerator端点提取Spline中两点间的控制点。

  3. 把每条Spline的控制点信息传给BlockGenerator,由BlockGenerator合成样条线。

第一步和生成街区时一致,这里不再赘述,我们从第二步开始分析。

从RoadGenerator中提取控制点

我们提取样条的时候核心要求是尽量保持街区轮廓,街区轮廓由道路构成,道路由最开始输入的用户设定Spline生成,而Spline的多种点类型就要求我们在提取时保留位置和Tangent信息。我们需要提取的控制点包括道路两个端点、端点之间的用户输入控制点。

在道路创建过程中,交汇路口把整条Spline切成多段,每段都会创建一个RoadActor。在RoadActor中我们有道路构成的点信息和SplineComponent的引用,因此我们可以通过位置从Spline中查到自身的Distance值,后续的内容都可以根据Distance值获得。考虑到我们输出的值需要包含Location和Tangent,可以使用FInterpCurveVector作为返回值容器。

在所有曲线中,我们需要特别关注ClosedLoop类型,它在0点时相交的特性带来了两个问题:

处理环路时的两个问题

  1. 0点跨越问题:在道路分布中起点和终点位于0点的两侧,如上图中三角形标注的RoadStart和RoadEnd,在遍历过程中需要准确处理序号以防越界。

  2. 终点连接Tangent问题:在闭合样条的最后一点的LeaveTangent和第一点的ArriveTangent会被强制设置为0,如果我们直接设置,会形成如下所示的“硬拐点”

Loop连接处Tangent为0

对于第一个问题,可以和城区生成时使用类似的方法,只需要注意我们获取的方向和道路生成的方向是否同向即可。

对于第二个问题,既然我们没有办法改变特性,那么可以使用我们最熟悉的方法——加点,只我们可以再中间再插入一个/多个点来解决这个问题,这里我们首先尝试在两点中点处加入一个点,代码片段如下:

代码块
C++
自动换行
复制代码
//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中编写相关的逻辑。基于图我们可以获得按顺序的片段顺序,每段片段顺序中的点顺序已经排好,只要进行简单拼装,多个片段之间使用直线连接:

代码块
C++
自动换行
复制代码
//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()的作用。

代码块
C++
自动换行
复制代码
//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程序员节快乐!