VoxelPluginFree 中监听 LOD0 区块生成回调的完整指南

在 UE5 中使用 VoxelPluginFree 构建程序化世界时,一个常见需求是:当最高精度的地形网格(LOD0)生成完毕后,驱动植被、物理、交互等下游系统。但 VoxelPluginFree 的 API 中并没有直接的 "OnChunkGenerated" 回调——本文介绍两种实用的替代方案,并附上可直接运行的 Demo 代码。

结论先行

VoxelPluginFree 没有暴露任何"网格生成完成"的逐块回调。实际开发中使用两层机制代替:

机制触发时机粒度适用场景
[机制一] AVoxelWorld::OnWorldLoadedCreateWorld() 完成后仅一次全局初始化碰撞、构建水体、过渡动画
[机制二] FVoxelEventManager::BindEventInvoker 进入/离开 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 绑定参数速查

参数类型含义
bFireExistingOnesbooltrue = 绑定瞬间对半径内已有区块也触一次回调
ChunkSizeint32事件区块的大小 (体素数),项目常用 64~128
DistanceInChunksint32Invoker 生效半径 (单位: chunk 数)
ActivateFChunkDelegate区块进入时的回调
DeactivateFChunkDelegate区块离开时的回调
EVoxelEventFlagsenumLocalInvokerOnly = 只响应本地玩家

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 级别。掌握这两种机制,足以应对绝大多数生产需求。