目标
在之前的博客《试用UE4新的地形编辑功能:地形编辑层与Landmass中的地形蓝图笔刷》中,我简单地尝试了地形蓝图笔刷,但当时对其内部机制并不了解。本篇将尝试制作两个最简单的蓝图笔刷,借此学习其内部机制。
此外,“Landmass” 本身是一个插件的名字,严格意义上并不等同于“地形蓝图笔刷”,然而很多资料似乎并没有明确区分二者,令我产生了些困扰,因此我希望先梳理清楚相关的概念。
Building Worlds with Landmass | Unreal Engine中作者展示了地形蓝图笔刷的效果,也简单介绍了如何制作地形蓝图笔刷,是本篇的主要参考资料。不过,为了真正搞清楚如何一步步自己创建蓝图笔刷,我还花了不少时间研究Landmass自带的两个地形蓝图笔刷的内部节点。
梳理相关概念
地形编辑层、地形蓝图笔刷、Landmass插件 都是 4.24 新添加的功能,可见Unreal Engine 4.24 Release Notes
地形编辑层(Landscape Edit Layers)
地形编辑层(Landscape Edit Layers)的概念类似于“图层”,使得编辑操作可以放在多层上而不是“破坏性”地在同一层上编辑。 功能启用后,可以看到堆叠的层:
一个地形编辑层对应的C++对象是FLandscapeLayer (完整定义见附录),定义它的代码是 \Engine\Source\Runtime\Landscape\Classes\Landscape.h。因此这个功能是在引擎本身中的,不需要启用Landmass插件。
(我在《简单尝试UE的地形编辑层(Landscape Edit Layers)功能》也做了些测试。)
地形蓝图笔刷(Landscape Blueprint Brushes)
Landscape Blueprint Brushes enable you to create and manipulate arbitrary terrain regions using shapes defined entirely in Blueprint. You can add multiple overlapping brushes and the system composites them together to display the final result. Landscape Blueprint Brushes consist of a 2D spline shape and a collection of properties enabling you to specify Materials, meshes, falloff, and more. You can also apply effects such as blurring, noise, and curves. Optionally, you can inject heightmap and layer weight data into a brush by overriding its Render event. 借助地形蓝图笔刷,用户可以使用完全在蓝图中定义的形状来创建和操纵任意地形区域。用户可以添加多个层叠的笔刷,系统将把它们组合在一起来显示最终效果。 地形蓝图笔刷由一个2D样条形状和一系列属性组成,让用户能够对材质、网格、衰减等进行指定。用户还能应用模糊、噪点和曲线之类的效果。此外,还可以覆写笔刷的渲染事件,为其插入高度图和层权重数据。
“地形蓝图笔刷” 是依赖于 “地形编辑层” 功能的。 在启用 “地形编辑层” 后,就可以看到在地形的 Sculpt 分页下有 “Blueprint” 的选项:
在 TOOL SETTINGS 中可以选择一个地形蓝图笔刷的种类,随后点击地形即可创建。
地形蓝图笔刷所对应的C++类是ALandscapeBlueprintBrushBase 。定义它的代码是 Engine\Source\Runtime\Landscape\Public\LandscapeBlueprintBrushBase.h。
每一个地形蓝图笔刷实例都会属于一个地形编辑层(FLandscapeLayer 有 Brushes 成员,而每个Brush会引用一个ALandscapeBlueprintBrushBase 对象,详见附录)。当前所选层的所有地形蓝图笔刷将会按顺序排列显示:(越下则计算越靠后)
“地形蓝图笔刷” 也是引擎本身的概念,不需要启用Landmass插件就可以看到。只不过,在不启用Landmass情况下,默认是没有可供选择的笔刷种类的:
此时你只能继承LandscapeBlueprintBrush 来从零创建一个地形蓝图笔刷:
Landmass插件
Landmass 是一个 Built-In 插件: 其包含了 C++部分 和 Content部分(即uasset部分)。
对于C++部分,内容很少,只是:
- 定义了
ALandmassActor (继承自AActor )。 - 扩展了一个
ULandmassBlueprintFunctionLibrary::GetCursorWorldRay 函数,内容很简单。 TerrainCarvingSettings.h 、FalloffSettings.h 、BrushEffectsList.h 中定义了一些结构体,但它们只是充当 UPROPERTY 容器的作用,自身没有逻辑。
重点是Content部分有较多的内容。比如现在比较关注的几个继承自ALandscapeBlueprintBrushBase 的地形蓝图笔刷:
LandmassActor
ALandmassActor 是定义在Landmass插件中的C++类,继承自AActor 。 它本身内容不多,但它是重要的LandmassBrushManager 的基类。
LandmassBrushManager
它继承自LandmassActor ,是一个蓝图类,在Landmass插件中. 它提供了一些制作地形蓝图笔刷必备的内容,比如高度图和权重图的RT:
制作第一个地形蓝图笔刷(功能最简化)
这里主要参考了Building Worlds with Landmass | Unreal Engine 中的 14:52 ~ 19:22。我会从零实现一个类似CustomBrush_MaterialOnly 的蓝图笔刷,但是内容已经简化到了最简程度。其基本思路也在教程中有示意图,核心是使用材质来渲染地形纹理:
0. 创建蓝图
继承自ALandscapeBlueprintBrushBase 我将其命名为 MyTestLandBP。 将Affect Heightmap 勾选,代表此蓝图会影响高度。
然后,创建三个变量:
- BrushManager指向当前所使用的 LandmassBrushManager。(需要将其设为Public,即右侧睁眼,因为我这里实验发现私有情况下会出现丢失它的问题)
- BrushMaterial 指向准备使用的材质。
- DynamicMaterial 代表用来实际执行渲染的动态材质。
1. 初始化BrushManager
初始化阶段,需要创建一个,或者更新已存在的。
这方面的逻辑,可以拷贝CustomBrush_MaterialOnly 的宏Spawn or Update Manager 。我将这个宏拷贝到我的 MyTestLandBP中,并添加了些注释:
然后,在 EventGraph中,添加Initialize 这个事件,并将其连入Spawn or Update Manager 。现在,触发Initialize 后将会调用Spawn or Update Manager 。
2. 准备材质
所使用的材质如下:
其大体逻辑很简单,就是为高度图随便叠加了一个噪声纹理(这里我选择了/Engine/MaterialTemplates/Textures/T_Noise01),然后其高度值缩放了3000。 不过需要注意的是对高度图纹理执行的Unpack 以及其反操作Pack 。这是因为高度图的值是16位的,占用了RG两个通道。
然后,将这个材质作为BrushMaterial的默认值。
3. 添加宏:创建动态材质
由于材质中的 HeightRT ,即高度图的RenderTarget是需要动态赋值的,因此需要创建一个动态材质。再次,可以拷贝CustomBrush_MaterialOnly 的宏Create MID if Needed ,它的逻辑就是如果没有的话则重新创建一个:
5. 覆写Render函数
基类ALandscapeBlueprintBrushBase 的Render 函数定义如下:
UFUNCTION(BlueprintNativeEvent)
UTextureRenderTarget2D* Render(bool InIsHeightmap, UTextureRenderTarget2D* InCombinedResult, const FName& InWeightmapLayerName);
它如何被调用并不是本篇学习的内容。这里只需要明确,使用材质渲染RT的逻辑需要放在这里就可以了。
因此,在蓝图中覆写Render函数:
它有一个RT作为参数,还返回一个RT。但是这两个RT并不是一个RT。参数的RT将作为材质的参数传入,而返回的RT是BrushManager上的RT。连接的蓝图如下:
编译蓝图后可以做测试了。依照一样的流程,选择MyTestLandBP并点击地面,可以看到地形被修改,其图案就是叠加的噪声图案:
现在,最简化的流程已经做完。材质方面之后可以添加一些参数进行控制。
制作第二个地形蓝图笔刷(使用距离场指定范围)
第二个笔刷将在之前的基础上,使用Spline指定一个范围,生成距离场来进行后续的计算。原视频在 19:22~26:1 做了介绍,不过要想知道详细的原理主要还是需要研究CustomBrush_Landmass 这个Landmass插件自带的蓝图笔刷。
这里制作的笔刷同样做了最大程度的简化。其原理正如视频中所给出的,和之前类似,但是多了个 “New land data generated” 而所谓的 “New land data generated” 在这里就是使用Spline所定义的距离场(使用 Jump Flood 算法):
0. 复制第一个蓝图
由于第一个蓝图包含了最基础的功能,因此这里的第二个蓝图笔刷将拷贝它,以它为起点。 我将其命名为MyTestLandBP_2
然后,添加一个Spline组件: 点数需要三个或三个以上。另外,可以将初始点数距离调大点,比如几千,方便预览,毕竟地形网格默认单位是100。
1. 绘制表示范围的三角形
接下来,需要将Spline转换为一个范围,这是通过构建若干个绕着[0]号点的三角形完成的: 比如在四个点的情况下,就会构建 0,1,2 与 0,2,3 的三角形,再多一个点就会是 0,3,4。以此类推。
因此,要添加一些变量:
CanvasUVTris ,它是一个数组,用来存储三角形。ShapeMID ,用来将三角形画到RT上的动态材质。
绘制的具体逻辑,可以拷贝CustomBrush_Landmass 的DrawCanvasShape 函数。我拷贝它之后做了一些简化,整理了下节点,然后加上注释以后: 基本上它分为两部分:
- 首先循环画出所有三角形到
CanvasUVTris 中。 - 然后将
CanvasUVTris 中的三角形画到BrushMananger的一张RT中。使用的材质是ShapeMID 这个动态材质(其母材质是Landmass中的一个材质,但其内容极其简单,基本上就是画出红色,自己也可以轻易制作一个)。
然后,将DrawCanvasShape 连到Render 函数中靠前的位置:
随后,就可以做测试了: 添加一个此蓝图笔刷就可以在场景中的BrushMananger中看到Depth And Shape RT A 这张RT:
拖动Spline控制点,就可以看到动态的变化:
2. 生成 Jump Flood
这方面,Landmass已经有现成的机制了,所以可以直接调用。 在Render 函数中添加: 现在,拖动Spline的控制点时,可以在BrushMananger中看到Jump Flood RT A 这张RT的变化:
3. 创建用于存储距离场的RT
添加一个RT变量用于存储距离场: 创建RT的宏将拷贝自CustomBrush_Landmass 的Create RT if Null or Changed 函数。不需要变动内容,我整理节点后添加了注释以后如下: 而这个宏将在初始化阶段调用:
4. 绘制距离场
添加一个动态材质变量用于绘制距离场: 它的母材质是/Landmass/Landscape/BlueprintBrushes/Materials/CacheDistanceField_RG8 ,之前渲染的RT将作为参数传入。
函数Cache Distance Field 拷贝自CustomBrush_Landmass ,但是添加了两部分内容:“创建动态材质(如果没有的话)”和 “设置材质参数”,原先这两部分被放在了别处,我为了方便一起放在这里: 随后,将在Render 函数中调用它: 现在,可以看到距离场了,它将随着Spline的控制点的变化而变化:
5. 材质
为了测试,这里的材质依旧很简单,只是为高度叠加了距离场: 需要注意的是,距离场的读取是通过/Landmass/Landscape/BlueprintBrushes/MF/ReadCachedDistanceField_RG8 这个材质函数完成的。如果打开它可以看到他有一个CachedDistanceFieldHeight 的参数,需要传入在上一步生成的距离场RT。
6. 完整Render函数
现在,补上传入距离场RT参数的操作之后,完整的Render函数如下: 效果:
可以看到,虽然有瑕疵,但是整个流程是跑通了。
总结
地形编辑层是引擎本身的功能,和Landmass无关。
地形蓝图笔刷依赖于地形编辑层功能。它的核心是定义了一个Render 函数:
UFUNCTION(BlueprintNativeEvent)
UTextureRenderTarget2D* Render(bool InIsHeightmap, UTextureRenderTarget2D* InCombinedResult, const FName& InWeightmapLayerName);
它的子类蓝图可以覆写它来定义自己的逻辑。 虽然地形蓝图笔刷并不依赖于 Landmass,但是默认情况下,不启用Landmass是没有现成的地形蓝图笔刷可用的。
Landmass是一个插件,内容主要是在Content有各种蓝图、材质、材质函数等。 虽然准确来说创建一个地形蓝图笔刷不一定需要Landmass。但是Landmass中有一些帮助创建地形蓝图笔刷的内容,比如本文创建蓝图笔刷就用到了:
- LandmassBrushManager,它有地形高度图和权重图的RT
- 绘制距离场所用的 Jump Flood 函数
- 一些材质
因此也难怪很多资料,比如本篇的参考视频中,没有将“Landmass蓝图笔刷”和“地形蓝图笔刷”区分开,因为他介绍的蓝图笔刷,包括本文做的蓝图笔刷,确实是 “依赖于Landmass的一些机制的地形蓝图笔刷”。
本文暂时是将Landmass的一些机制当作黑盒了。不过,后续如果想要将蓝图笔刷集成于更大的系统,或者添加一些类似距离场的其他数据,可能需要对Landmass进行更深入的理解。
附录:一些相关C++对象定义
USTRUCT()
struct FLandscapeLayer
{
GENERATED_USTRUCT_BODY()
FLandscapeLayer()
: Guid(FGuid::NewGuid())
, Name(NAME_None)
, bVisible(true)
, bLocked(false)
, HeightmapAlpha(1.0f)
, WeightmapAlpha(1.0f)
, BlendMode(LSBM_AdditiveBlend)
{}
FLandscapeLayer(const FLandscapeLayer& OtherLayer) = default;
UPROPERTY(meta = (IgnoreForMemberInitializationTest))
FGuid Guid;
UPROPERTY()
FName Name;
UPROPERTY(Transient)
bool bVisible;
UPROPERTY()
bool bLocked;
UPROPERTY()
float HeightmapAlpha;
UPROPERTY()
float WeightmapAlpha;
UPROPERTY()
TEnumAsByte<enum ELandscapeBlendMode> BlendMode;
UPROPERTY()
TArray<FLandscapeLayerBrush> Brushes;
UPROPERTY()
TMap<TObjectPtr<ULandscapeLayerInfoObject>, bool> WeightmapLayerAllocationBlend;
};
USTRUCT()
struct FLandscapeLayerBrush
{
GENERATED_USTRUCT_BODY()
FLandscapeLayerBrush()
#if WITH_EDITORONLY_DATA
: FLandscapeLayerBrush(nullptr)
#endif
{}
FLandscapeLayerBrush(ALandscapeBlueprintBrushBase* InBlueprintBrush)
#if WITH_EDITORONLY_DATA
: BlueprintBrush(InBlueprintBrush)
, LandscapeSize(MAX_int32, MAX_int32)
, LandscapeRenderTargetSize(MAX_int32, MAX_int32)
#endif
{}
#if WITH_EDITOR
UTextureRenderTarget2D* Render(bool InIsHeightmap, const FIntRect& InLandscapeSize, UTextureRenderTarget2D* InLandscapeRenderTarget, const FName& InWeightmapLayerName = NAME_None);
ALandscapeBlueprintBrushBase* GetBrush() const;
bool IsAffectingHeightmap() const;
bool IsAffectingWeightmapLayer(const FName& InWeightmapLayerName) const;
void SetOwner(ALandscape* InOwner);
#endif
private:
#if WITH_EDITOR
bool Initialize(const FIntRect& InLandscapeExtent, UTextureRenderTarget2D* InLandscapeRenderTarget);
#endif
#if WITH_EDITORONLY_DATA
UPROPERTY()
TObjectPtr<ALandscapeBlueprintBrushBase> BlueprintBrush;
FTransform LandscapeTransform;
FIntPoint LandscapeSize;
FIntPoint LandscapeRenderTargetSize;
#endif
};
UCLASS(Blueprintable, hidecategories = (Replication, Input, LOD, Actor, Cooking, Rendering))
class ALandmassActor : public AActor
{
GENERATED_UCLASS_BODY()
public:
UFUNCTION(BlueprintNativeEvent, CallInEditor, BlueprintCallable, Category = "Tick")
void CustomTick(float DeltaSeconds);
virtual bool IsEditorOnly() const override { return true; }
virtual bool ShouldTickIfViewportsOnly() const override;
virtual void Tick(float DeltaSeconds) override;
UFUNCTION(BlueprintCallable, category = "Default")
void SetEditorTickEnabled(bool bEnabled) { EditorTickIsEnabled = bEnabled; }
UPROPERTY()
bool EditorTickIsEnabled = false;
UFUNCTION(BlueprintNativeEvent, CallInEditor, BlueprintCallable, Category = "Selection")
void ActorSelectionChanged(bool bSelected);
private:
bool bWasSelected = false;
FDelegateHandle OnActorSelectionChangedHandle;
void HandleActorSelectionChanged(const TArray<UObject*>& NewSelection, bool bForceRefresh);
};
UCLASS(Abstract, NotBlueprintable)
class LANDSCAPE_API ALandscapeBlueprintBrushBase : public AActor
{
GENERATED_UCLASS_BODY()
protected:
#if WITH_EDITORONLY_DATA
UPROPERTY(Transient)
TObjectPtr<class ALandscape> OwningLandscape;
UPROPERTY(Category = "Settings", EditAnywhere, BlueprintReadWrite)
bool AffectHeightmap;
UPROPERTY(Category = "Settings", EditAnywhere, BlueprintReadWrite)
bool AffectWeightmap;
UPROPERTY(Category = "Settings", EditAnywhere, BlueprintReadWrite)
TArray<FName> AffectedWeightmapLayers;
UPROPERTY(Transient)
bool bIsVisible;
uint32 LastRequestLayersContentUpdateFrameNumber;
#endif
public:
virtual UTextureRenderTarget2D* Render_Native(bool InIsHeightmap, UTextureRenderTarget2D* InCombinedResult, const FName& InWeightmapLayerName) {return nullptr;}
virtual void Initialize_Native(const FTransform& InLandscapeTransform, const FIntPoint& InLandscapeSize, const FIntPoint& InLandscapeRenderTargetSize) {}
UFUNCTION(BlueprintNativeEvent)
UTextureRenderTarget2D* Render(bool InIsHeightmap, UTextureRenderTarget2D* InCombinedResult, const FName& InWeightmapLayerName);
UFUNCTION(BlueprintNativeEvent)
void Initialize(const FTransform& InLandscapeTransform, const FIntPoint& InLandscapeSize, const FIntPoint& InLandscapeRenderTargetSize);
UFUNCTION(BlueprintCallable, Category = "Landscape")
void RequestLandscapeUpdate();
UFUNCTION(BlueprintImplementableEvent)
void GetBlueprintRenderDependencies(TArray<UObject*>& OutStreamableAssets);
#if WITH_EDITOR
virtual void CheckForErrors() override;
virtual void GetRenderDependencies(TSet<UObject*>& OutDependencies);
virtual void SetOwningLandscape(class ALandscape* InOwningLandscape);
class ALandscape* GetOwningLandscape() const;
bool IsAffectingHeightmap() const { return AffectHeightmap; }
bool IsAffectingWeightmap() const { return AffectWeightmap; }
bool IsAffectingWeightmapLayer(const FName& InLayerName) const;
bool IsVisible() const { return bIsVisible; }
bool IsLayerUpdatePending() const;
void SetIsVisible(bool bInIsVisible);
void SetAffectsHeightmap(bool bInAffectsHeightmap);
void SetAffectsWeightmap(bool bInAffectsWeightmap);
virtual bool ShouldTickIfViewportsOnly() const override;
virtual void Tick(float DeltaSeconds) override;
virtual void PostEditMove(bool bFinished) override;
virtual void PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent) override;
virtual void Destroyed() override;
virtual void PushDeferredLayersContentUpdate();
virtual EActorGridPlacement GetDefaultGridPlacement() const override { return EActorGridPlacement::AlwaysLoaded; }
#endif
};
|