VoxelPluginFree 中监听 LOD0 区块生成回调的完整指南
在 UE5 中使用 VoxelPluginFree 构建程序化世界时,一个常见需求是:当最高精度的地形网格(LOD0)生成完毕后,驱动植被、物理、交互等下游系统。但 VoxelPluginFree 的 API 中并没有直接的 "OnChunkGenerated" 回调——本文介绍两种实用的替代方案,并附上可直接运行的 Demo 代码。
结论先行
VoxelPluginFree 没有暴露任何"网格生成完成"的逐块回调。实际开发中使用两层机制代替:
| 机制 | 触发时机 | 粒度 | 适用场景 |
|---|---|---|---|
[机制一] AVoxelWorld::OnWorldLoaded | CreateWorld() 完成后仅一次 | 全局 | 初始化碰撞、构建水体、过渡动画 |
[机制二] FVoxelEventManager::BindEvent | Invoker 进入/离开 chunk 范围 | 逐块 (如 128 体素) | 植被生成、区域事件、按需加载 |
1. 机制一:OnWorldLoaded(世界级回调)
1.1 原理
OnWorldLoaded 是 AVoxelWorld 上的动态多播委托。当 CreateWorld() 完成初始体素数据生成后广播一次。
1.2 最小 Demo
// ==== YourVoxelWorldManager.h ====
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "VoxelWorld.h"
#include "YourVoxelWorldManager.generated.h"
UCLASS()
class YOURPROJECT_API AYourVoxelWorldManager : public AActor
{
GENERATED_BODY()
public:
void CreateAndConfigureVoxelWorld();
private:
UPROPERTY()
TObjectPtr<AVoxelWorld> VoxelWorld;
UFUNCTION()
void OnVoxelWorldLoaded();
};
// ==== YourVoxelWorldManager.cpp ====
#include "YourVoxelWorldManager.h"
#include "VoxelWorld.h"
void AYourVoxelWorldManager::CreateAndConfigureVoxelWorld()
{
UWorld* World = GetWorld();
if (!World) return;
// 1. 生成 VoxelWorld Actor
FActorSpawnParameters SpawnParams;
SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
VoxelWorld = World->SpawnActor<AVoxelWorld>(
FVector::ZeroVector, FRotator::ZeroRotator, SpawnParams);
if (!VoxelWorld) return;
// 2. 基础配置
VoxelWorld->SetWorldSize(2048);
VoxelWorld->bEnableCollisions = true;
VoxelWorld->bUseCameraIfNoInvokersFound = true; // 重要: 自动用摄像机做 Invoker
// 3. ★ 绑定 OnWorldLoaded 回调
VoxelWorld->OnWorldLoaded.AddUniqueDynamic(this, &AYourVoxelWorldManager::OnVoxelWorldLoaded);
// 4. 触发生成
VoxelWorld->CreateWorld();
}
void AYourVoxelWorldManager::OnVoxelWorldLoaded()
{
UE_LOG(LogTemp, Log, TEXT("VoxelWorld 初始数据生成完毕!"));
// 在这里做世界生成完成后的初始化:
// - 设置碰撞通道
// - 构建水体
// - 触发过渡动画
// - 通知其他子系统
}
1.3 关键点
- 委托是一次性的——只在
CreateWorld()后的首次数据构建完成时触发 - 不能用来监听后续 LOD 切换、区块重生成等情况
- 适合做全局一次性初始化
2. 机制二:FVoxelEventManager::BindEvent(逐块回调)
2.1 原理
FVoxelEventManager 是 VoxelPluginFree 内置的区块事件系统。它基于 Invoker 组件的位置,当 Invoker 进入/离开一个 chunk 大小的区域时触发回调。
注意:此回调响应的是"Invoker 进入区块",不是"Voxel 网格生成完成"。但结合 bFireExistingOnes = true,可以拿到当前半径内所有已处于活动状态的区块。
2.2 完整 Demo
// ==== ChunkBasedFoliageSpawner.h ====
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "VoxelEvents/VoxelEventManager.h"
#include "ChunkBasedFoliageSpawner.generated.h"
class AVoxelWorld;
UCLASS(ClassGroup = (Custom), meta = (BlueprintSpawnableComponent))
class YOURPROJECT_API UChunkBasedFoliageSpawner : public UActorComponent
{
GENERATED_BODY()
public:
// ---- 可配置参数 ----
UPROPERTY(EditAnywhere, Category = "Chunk Events")
int32 ChunkSizeVoxels = 64; // 单个 chunk 大小 (体素)
UPROPERTY(EditAnywhere, Category = "Chunk Events")
int32 LoadRadiusChunks = 6; // 加载半径 (多少个 chunk)
UPROPERTY(EditAnywhere, Category = "Chunk Events")
int32 UnloadRadiusChunks = 8; // 卸载半径 (需 > 加载半径,避免抖动)
UPROPERTY(EditAnywhere, Category = "Chunk Events")
float ProcessInterval = 0.5f; // 处理间隔 (秒)
// ---- 初始化 ----
void Initialize(AVoxelWorld* InVoxelWorld);
protected:
virtual void BeginPlay() override;
virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;
private:
// 事件回调
void OnChunkEnter(FVoxelIntBox Bounds);
void OnChunkExit(FVoxelIntBox Bounds);
// 延迟处理 (不在回调中直接做重计算)
void ProcessPendingChunks();
// Invoker 设置
void SetupEventInvoker();
UPROPERTY()
TObjectPtr<AVoxelWorld> CachedVoxelWorld;
FVoxelEventHandle LoadEventHandle;
FVoxelEventHandle UnloadEventHandle;
TArray<FVoxelIntBox> PendingChunks; // 待处理的区块
bool bWorldReady = false;
FTimerHandle ProcessTimer;
};
// ==== ChunkBasedFoliageSpawner.cpp ====
#include "ChunkBasedFoliageSpawner.h"
#include "VoxelWorld.h"
#include "VoxelInvokerComponent.h"
#include "VoxelEvents/VoxelEventManager.h"
#include "TimerManager.h"
void UChunkBasedFoliageSpawner::Initialize(AVoxelWorld* InVoxelWorld)
{
CachedVoxelWorld = InVoxelWorld;
}
void UChunkBasedFoliageSpawner::BeginPlay()
{
Super::BeginPlay();
if (!CachedVoxelWorld)
{
// 尝试从 Owner 获取
CachedVoxelWorld = Cast<AVoxelWorld>(GetOwner());
}
if (!CachedVoxelWorld) return;
// ★ 延迟 3 秒确保 VoxelWorld 的 EventManager 已就绪
FTimerHandle InitTimer;
GetWorld()->GetTimerManager().SetTimer(InitTimer, [this]()
{
if (!CachedVoxelWorld) return;
// ====== 1. 创建事件用 Invoker ======
SetupEventInvoker();
// ====== 2. 绑定进入/离开事件 ======
FVoxelEventManager& EventMgr = CachedVoxelWorld->GetEventManager();
LoadEventHandle = EventMgr.BindEvent(
true, // bFireExistingOnes: 对半径内已有区块也触发一次
ChunkSizeVoxels, // 区块体素数
LoadRadiusChunks, // 半径 (chunk 数)
FChunkDelegate::CreateUObject(this, &UChunkBasedFoliageSpawner::OnChunkEnter),
FChunkDelegate(), // 离开回调 (不需要)
EVoxelEventFlags::LocalInvokerOnly
);
UnloadEventHandle = EventMgr.BindEvent(
false, // 不对已有区块触发
ChunkSizeVoxels,
UnloadRadiusChunks,
FChunkDelegate(), // 进入回调 (不需要)
FChunkDelegate::CreateUObject(this, &UChunkBasedFoliageSpawner::OnChunkExit),
EVoxelEventFlags::LocalInvokerOnly
);
bWorldReady = true;
// ====== 3. 启动定期处理循环 ======
GetWorld()->GetTimerManager().SetTimer(ProcessTimer, this,
&UChunkBasedFoliageSpawner::ProcessPendingChunks,
ProcessInterval, true);
UE_LOG(LogTemp, Log, TEXT("Chunk events bound: LoadRadius=%d chunks, UnloadRadius=%d chunks"),
LoadRadiusChunks, UnloadRadiusChunks);
}, 3.0f, false);
}
void UChunkBasedFoliageSpawner::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
// ★ 必须解绑事件,防止悬垂引用
if (CachedVoxelWorld)
{
FVoxelEventManager& EventMgr = CachedVoxelWorld->GetEventManager();
if (LoadEventHandle.IsValid()) EventMgr.UnbindEvent(LoadEventHandle);
if (UnloadEventHandle.IsValid()) EventMgr.UnbindEvent(UnloadEventHandle);
}
PendingChunks.Empty();
Super::EndPlay(EndPlayReason);
}
void UChunkBasedFoliageSpawner::SetupEventInvoker()
{
// 找到玩家 Pawn
APlayerController* PC = GetWorld()->GetFirstPlayerController();
if (!PC || !PC->GetPawn()) return;
APawn* PlayerPawn = PC->GetPawn();
// ★ 创建一个纯事件用的 Invoker(不影响 LOD 和碰撞)
UVoxelSimpleInvokerComponent* Invoker = NewObject<UVoxelSimpleInvokerComponent>(PlayerPawn);
Invoker->bUseForLOD = false; // 不参与 LOD 决策
Invoker->bUseForCollisions = false; // 不驱动碰撞
Invoker->bUseForEvents = true; // ★ 仅用于事件系统
Invoker->SetupAttachment(PlayerPawn->GetRootComponent());
Invoker->RegisterComponent();
UE_LOG(LogTemp, Log, TEXT("Event-only invoker attached to %s"), *PlayerPawn->GetName());
}
// ====== 事件回调 ======
void UChunkBasedFoliageSpawner::OnChunkEnter(FVoxelIntBox Bounds)
{
if (!bWorldReady) return;
// ★ 不在回调中做重计算,只收集到待处理队列
PendingChunks.Add(Bounds);
UE_LOG(LogTemp, Verbose, TEXT("Chunk entered: [%d,%d,%d] ~ [%d,%d,%d]"),
Bounds.Min.X, Bounds.Min.Y, Bounds.Min.Z,
Bounds.Max.X, Bounds.Max.Y, Bounds.Max.Z);
}
void UChunkBasedFoliageSpawner::OnChunkExit(FVoxelIntBox Bounds)
{
// 清理该区块的数据
UE_LOG(LogTemp, Verbose, TEXT("Chunk exited: [%d,%d,%d] ~ [%d,%d,%d]"),
Bounds.Min.X, Bounds.Min.Y, Bounds.Min.Z,
Bounds.Max.X, Bounds.Max.Y, Bounds.Max.Z);
}
void UChunkBasedFoliageSpawner::ProcessPendingChunks()
{
if (!bWorldReady || PendingChunks.Num() == 0) return;
// 每帧处理 N 个区块,避免卡顿
const int32 MaxPerFrame = 8;
int32 Processed = 0;
while (PendingChunks.Num() > 0 && Processed < MaxPerFrame)
{
FVoxelIntBox Bounds = PendingChunks.Pop();
// ====== 在这里放置你的业务逻辑 ======
// 例如:
// 1. 从 CachedVoxelWorld->GetDataSharedPtr() 获取 SDF 数据
// 2. 采样地表位置
// 3. 放置植被 / 生成物体 / 触发事件
FVector ChunkWorldCenter = CachedVoxelWorld->LocalToGlobalFloat(
FVector(Bounds.Min + Bounds.Size() / 2));
// 演示: 在 chunk 中心生成一个 debug 球
DrawDebugSphere(GetWorld(), ChunkWorldCenter,
Bounds.Size().X * CachedVoxelWorld->VoxelSize * 0.3f,
8, FColor::Green, false, 1.0f);
Processed++;
}
}
2.3 绑定参数速查
| 参数 | 类型 | 含义 |
|---|---|---|
bFireExistingOnes | bool | true = 绑定瞬间对半径内已有区块也触一次回调 |
ChunkSize | int32 | 事件区块的大小 (体素数),项目常用 64~128 |
DistanceInChunks | int32 | Invoker 生效半径 (单位: chunk 数) |
Activate | FChunkDelegate | 区块进入时的回调 |
Deactivate | FChunkDelegate | 区块离开时的回调 |
EVoxelEventFlags | enum | LocalInvokerOnly = 只响应本地玩家 |
2.4 运行时数据流
玩家移动 (Tick)
│
└─ Invoker 组件自动更新体素位置
│
└─ FVoxelEventManager 以 EventsTickRate (默认 15Hz) 检查
├─ 新进入的 chunk: OnChunkEnter(Bounds)
└─ 已离开的 chunk: OnChunkExit(Bounds)
3. 两种机制配合使用
实际项目中两种机制常用的典型工作流:
CreateWorld()
│
├─ [后台生成体素数据 + 渲染八叉树]
│
├─ ★ OnWorldLoaded 触发
│ ├─ 设置碰撞
│ ├─ 构建海洋 / 水体
│ └─ 启动过渡动画
│
└─ [3s 延迟后] BindEvent
└─ ★ 玩家周围区块逐个触发 OnChunkEnter
└─ 异步生成植被 / 放置物体
对应代码结构:
// 在 CreateWorld() 后:
VoxelWorld->CreateWorld();
// 一步: 全局初始化
VoxelWorld->OnWorldLoaded.AddUniqueDynamic(this, &AMyManager::OnWorldLoaded);
// 二步: 逐块细节 (3s 延迟后)
FTimerHandle Timer;
GetWorld()->GetTimerManager().SetTimer(Timer, [this]()
{
FVoxelEventManager& Mgr = VoxelWorld->GetEventManager();
Mgr.BindEvent(true, 64, 8,
FChunkDelegate::CreateUObject(this, &AMyManager::OnChunkEnter),
FChunkDelegate(),
EVoxelEventFlags::LocalInvokerOnly);
}, 3.0f, false);
4. 常见问题
Q: 为什么没有直接的“LOD0 网格生成完成”回调?
VoxelPluginFree 的 LOD 系统是动态的——LOD 级别根据 Invoker 距离不断切换,"完成"这个概念本身就没有明确边界。官方提供的 FVoxelEventManager 才是正确的扩展点。
Q: 如何确保 OnChunkEnter 触发时对应区块已经是 LOD0?
OnChunkEnter 触发 = Invoker 进入该 chunk 范围。如果你有 bUseForLOD = true 的 Invoker,VoxelPluginFree 会自动将该范围内的区块提升到 LOD0。
如果需要以 LOD0 精度采样体素数据,使用:
FVoxelConstDataAccelerator Accel = CachedVoxelWorld->GetDataSharedPtr();
float Value = Accel.GetValue(FIntVector(X, Y, Z), 0); // LOD = 0 强制最高精度
Q: Unload 半径为什么要大于 Load 半径?
避免玩家在分界线附近来回移动时频繁触发加载/卸载:
LoadRadius = 6; // 玩家周围 6 个 chunk 开始生成
UnloadRadius = 8; // 离开到 8 个 chunk 外才回收
Q: 为什么事件绑定要延迟 3 秒?
CreateWorld() 返回时 EventManager 内部可能尚未完全初始化。延迟确保绑定安全。
5. 总结
┌────────────────────────────────────────────────────────┐
│ VoxelPluginFree 区块生成监听方案 │
├──────────────────┬─────────────────────────────────────┤
│ 需求 │ 方案 │
├──────────────────┼─────────────────────────────────────┤
│ 世界初始化完毕 │ AVoxelWorld::OnWorldLoaded │
│ (一次性) │ │
├──────────────────┼─────────────────────────────────────┤
│ 玩家进入某区域 │ FVoxelEventManager::BindEvent │
│ (逐块触发) │ → OnChunkEnter │
├──────────────────┼─────────────────────────────────────┤
│ 玩家离开某区域 │ FVoxelEventManager::BindEvent │
│ (逐块触发) │ → OnChunkExit │
├──────────────────┼─────────────────────────────────────┤
│ 遍历所有已渲染网格│ GetRenderer().ApplyToAllMeshes() │
└──────────────────┴─────────────────────────────────────┘
VoxelPluginFree 的设计是让开发者通过 Invoker 驱动的 FVoxelEventManager 感知世界状态,而非关心某一具体区块的 LOD 级别。掌握这两种机制,足以应对绝大多数生产需求。