DEV Community

Cover image for We Open-Sourced a Seeded UE5 Village Generator — Here’s the C++ Pipeline
Lewis Liu
Lewis Liu

Posted on

We Open-Sourced a Seeded UE5 Village Generator — Here’s the C++ Pipeline

Randomly scattering meshes is easy. Generating a village that follows the terrain, avoids obvious overlaps, stays art-directable, and can be reproduced from a seed is the harder problem.

We open-sourced the solution as Seele Scatter Regions, an MIT-licensed Unreal Engine 5.5 editor plugin developed by SEELE AI. It generates villages, farms, and cemeteries from project-owned Static Mesh recipes without depending on Unreal Engine's PCG framework.

The repository includes the public C++ source, Blueprint-callable editor tooling, a JSON automation path, packaging validation, and a UE5.5 release. This article walks through how those pieces fit together.

Seele Scatter Regions generating villages, farms, and cemeteries in Unreal Engine 5

This post walks through the current C++ implementation: how recipe assets become topology, how candidate points are filtered into believable layouts, how instances are projected onto Landscape, and how the generator exposes enough telemetry to be automated safely.

A village generated by Seele Scatter Regions on an Unreal Engine Landscape

Current scope: beta 0.1.0, Unreal Engine 5.5, editor-side generation, and C++ projects.

The actual problem: controlled randomness

A useful procedural tool needs more than a random position and yaw. We wanted five properties:

  1. Repeatability — the same seed should reproduce the layout when the recipe and scene are unchanged.
  2. Art direction — artists should provide meshes, weights, density, and scale without recompiling C++.
  3. Spatial logic — roads, buildings, fields, tombs, gates, and props need different placement rules.
  4. Terrain awareness — generated content must land on Landscape instead of floating at the input Z.
  5. Automation-friendly results — callers need structured counts, warnings, bounds, and errors, not just a log line.

The implementation became a pipeline:

JSON / C++ input
        ↓
Recipe Data Asset
        ↓
Seeded boundary + road topology
        ↓
Candidate point generation
        ↓
Density, distance, slope, and collision filters
        ↓
Landscape projection
        ↓
Instanced Static Mesh components
        ↓
Structured generation result
Enter fullscreen mode Exit fullscreen mode

The important design decision is that randomness is only one stage. Most of the believable result comes from constraints applied after candidate generation.

1. Put art direction in recipe assets

The public recipe starts with a small piece of shared mesh data:

USTRUCT(BlueprintType)
struct FScatterRegionMeshRecipeData
{
    GENERATED_BODY()

    UPROPERTY(EditAnywhere, BlueprintReadOnly)
    TSoftObjectPtr<UStaticMesh> Mesh;

    UPROPERTY(EditAnywhere, BlueprintReadOnly)
    FVector ScaleMin = FVector::OneVector;

    UPROPERTY(EditAnywhere, BlueprintReadOnly)
    FVector ScaleMax = FVector::OneVector;

    UPROPERTY(EditAnywhere, BlueprintReadOnly)
    bool bUniformScale = false;

    UPROPERTY(EditAnywhere, BlueprintReadOnly, meta = (ClampMin = "0.0"))
    float Weight = 1.0f;
};
Enter fullscreen mode Exit fullscreen mode

A UScatterRegionRecipeDataAsset then exposes type-specific slots:

  • Village: weighted building slots, local props, fence, gate, road, roadside props, and scatter props
  • Farm: road, dirt tiles, crops, and scarecrows
  • Cemetery: road, tombs, and a memorial/panteon slot

The asset normalizes its slot layout in the constructor, PostLoad, and PostEditChangeProperty. When the region type changes, irrelevant slots disappear and the expected slots are restored.

This gives us a stable contract without hard-coding production art. The open-source repository includes lightweight recipes using Unreal's Basic Shapes, while real projects can duplicate a recipe and assign their own assets.

Soft object references matter here: recipes can describe content without eagerly loading every mesh. At generation time, the compiler resolves the selected recipe, validates its slots, loads meshes synchronously, and converts the author-facing data into a compact internal representation.

2. A seed should control topology, not only decoration

Every random choice flows through one FRandomStream:

FRandomStream Stream(Spec.Seed);

if (Spec.RegionType == TEXT("farm"))
{
    GenerateFarm(Context, Spec, Recipe, Stream);
}
else if (Spec.RegionType == TEXT("cemetery"))
{
    GenerateCemetery(Context, Spec, Recipe, Stream);
}
else
{
    GenerateVillage(Context, Spec, Recipe, Stream);
}
Enter fullscreen mode Exit fullscreen mode

The seed influences boundary irregularity, road bends, branch placement, building selection, density filtering, mesh weights, position jitter, yaw, and scale. This is more useful than applying a seed only to the final prop scatter: the large-scale composition is reproducible too.

For villages, the generator builds a smoothed closed boundary, chooses a gate along the boundary, and grows the road graph inward from that gate. The primary road is made from short quadratic Bézier segments; secondary branches search for valid endpoints inside the polygon.

In simplified form:

const FVector2D Control =
    (Start + End) * 0.5f +
    Normal * Stream.FRandRange(-BendStrengthCm, BendStrengthCm);

for (int32 Index = 1; Index <= SegmentCount; ++Index)
{
    const float T = static_cast<float>(Index) / SegmentCount;
    const FVector2D A = FMath::Lerp(Start, Control, T);
    const FVector2D B = FMath::Lerp(Control, End, T);
    const FVector2D Current = FMath::Lerp(A, B, T);
    Segments.Add(MakeRoadSegment(Previous, Current, bPrimary));
    Previous = Current;
}
Enter fullscreen mode Exit fullscreen mode

The result is deterministic but not rigidly grid-shaped.

There is one important caveat: deterministic generation means same seed + same recipe + same scene state. If the Landscape or mesh bounds change, projection and collision decisions can change as well. The seed makes a build reproducible; it does not freeze the world around it.

3. Treat placement as a point-processing pipeline

The reusable core is a lightweight graph-point structure containing location, yaw, density, bounds radius, Z offset, and a scale multiplier.

Instead of placing a mesh immediately, each region strategy creates candidate points and runs them through small transforms. The village roadside-prop path is a good example:

SidePropPoints = ScatterRegionDensityFilter(
    SidePropPoints, Stream, 1.0f);

SidePropPoints = ScatterRegionDifferenceAgainstRoads(
    SidePropPoints,
    RoadSegments,
    MinRoadDistance,
    MaxRoadDistance);

SidePropPoints = ScatterRegionDifferenceAgainstPlacements(
    SidePropPoints,
    BuildingPlacements,
    BuildingClearance);

SidePropPoints = ScatterRegionSelfPruning(
    SidePropPoints,
    Stream,
    MinimumSpacing,
    true);

ScatterRegionLookAtRoad(SidePropPoints, RoadSegments);

ScatterRegionTransformPoints(
    SidePropPoints,
    Stream,
    80.0f,          // position jitter in cm
    12.0f,          // yaw jitter in degrees
    FVector(0.85f),
    FVector(1.15f),
    true);
Enter fullscreen mode Exit fullscreen mode

Each operation has one job:

  • Density filter accepts points probabilistically from recipe density.
  • Difference against roads enforces a minimum and optional maximum road distance.
  • Difference against placements keeps props away from buildings or reserved landmarks.
  • Self-pruning rejects candidates that are too close to already accepted points.
  • Look-at-road rotates a point toward its nearest road segment.
  • Transform adds controlled position, yaw, and scale variation.

Collision reservation uses mesh bounds when available, so a large building and a small prop do not consume the same amount of space.

This pipeline is the heart of the system. Region generators can create very different candidate sets while reusing the same constraints.

4. Project every accepted instance onto Landscape

Candidate generation happens in local XY space around a root actor. Before an instance is committed, the plugin traces vertically through the editor world:

const FVector Start(
    WorldXY.X,
    WorldXY.Y,
    Context.Center.Z + LocalLocation.Z + 200000.0f);

const FVector End(
    WorldXY.X,
    WorldXY.Y,
    Context.Center.Z + LocalLocation.Z - 200000.0f);

TArray<FHitResult> Hits;
Context.World->LineTraceMultiByChannel(
    Hits,
    Start,
    End,
    ECC_Visibility,
    QueryParams);

for (const FHitResult& Hit : Hits)
{
    if (IsLandscapeTraceHit(Hit))
    {
        OutLocalLocation.Z =
            Hit.ImpactPoint.Z - Context.Center.Z;
        Context.ProjectionHits++;
        return true;
    }
}
Enter fullscreen mode Exit fullscreen mode

The trace ignores the root actor and actors tagged as previously generated regions. Without that, later generations could project onto earlier generated meshes instead of the Landscape.

After projection, the mesh is grounded using its bounds:

Location.Z += GroundedZ(RecipeMesh, Scale);
Location.Z += VerticalOffsetCm;
Enter fullscreen mode Exit fullscreen mode

The generator tracks projection attempts, hits, and misses. A complete miss becomes a warning rather than silently producing a misleading "success."

Slope checks reuse Landscape samples around building or memorial candidates. That lets large semantic objects be rejected on steep terrain before they are committed.

5. Batch output with Instanced Static Mesh components

Spawning one Actor per crop, tomb, or fence segment would create unnecessary editor and rendering overhead. The plugin groups instances by slot + mesh path:

const FString ComponentKey = Slot + TEXT("|") + RecipeMesh.Path;

if (UInstancedStaticMeshComponent** Existing =
        Context.Components.Find(ComponentKey))
{
    return *Existing;
}

UInstancedStaticMeshComponent* Component =
    NewObject<UInstancedStaticMeshComponent>(
        Context.RootActor,
        ComponentName);

Component->SetStaticMesh(RecipeMesh.Mesh);
Component->AttachToComponent(
    Context.RootActor->GetRootComponent(),
    FAttachmentTransformRules::KeepRelativeTransform);
Enter fullscreen mode Exit fullscreen mode

Every accepted transform is added to the matching UInstancedStaticMeshComponent. The generated region remains one tagged root actor with a manageable component set, while slot counts and world bounds are updated as instances are added.

We have deliberately not attached a benchmark number to this design: performance depends on meshes, materials, instance counts, and the target machine. The architectural benefit is still concrete—thousands of repeated objects do not become thousands of Actors.

6. Three content grammars, one infrastructure

The generators share recipes, random streams, point filters, projection, collision reservation, output components, and telemetry. What differs is the content grammar.

Region Topology Main placement rules
Village Irregular boundary, gate-rooted main road, bent branches Road-facing building clusters, local props, perimeter fence/gate, roadside and free scatter
Farm Closed boundary, two-lane access roads, tiled fields Dirt tiles avoid road footprints; crops sit above dirt; scarecrows are density-filtered and spaced
Cemetery Closed boundary and access paths Memorials reserve exclusion zones; tomb grids avoid roads and memorials, then self-prune

This separation is valuable. A "universal" random scatter function would bury these differences in flags. Three small domain strategies over shared primitives are easier to reason about and extend.

7. Make the generator safe to automate

The same generator can be called from C++, a Blueprint-callable Editor Subsystem, or a JSON adapter.

A request looks like this:

{
  "region_type": "village",
  "center": [75000, 37000, 0],
  "size_m": 150,
  "seed": 76101,
  "recipe_asset": "/Game/MyRecipes/DA_Village.DA_Village"
}
Enter fullscreen mode Exit fullscreen mode

The public command is generate_scatter_region. Inputs are validated before the world is touched:

  • region type must be village, farm, or cemetery
  • size is clamped to 20–1000 meters
  • a recipe asset path is required
  • generation is rejected when PIE is active
  • the editor world must be initialized and available

The result reports:

  • success
  • generated actor name
  • instance and component counts
  • per-slot counts
  • projection attempts, hits, and misses
  • generated bounds
  • warnings and structured errors

That observability is not just polish. It lets an automation client distinguish "the command returned" from "a useful region was actually generated."

A practical success check is:

success == true
region_actor is not empty
instance_count > 0
projection_hits > 0 when Landscape projection is expected
errors is empty
Enter fullscreen mode Exit fullscreen mode

What we learned

A few design lessons survived every iteration:

Randomness is an input, not the architecture. The spatial constraints do most of the work.

Determinism must include topology. Reproducing only prop jitter is not enough when roads and clusters define the composition.

Authored data and generation logic should evolve separately. Recipe assets let artists replace meshes and tune density without turning the generator into project-specific code.

Telemetry belongs in the public API. Counts, bounds, warnings, and projection statistics make editor automation debuggable.

Scope should be explicit. Version 0.1.0 generates in the editor, not at packaged-game runtime. The generated actor and instanced components can be saved with the edited level.

Try it or read the source

Seele Scatter Regions is MIT-licensed and open source:

If you are building procedural tools in Unreal Engine, I would be interested to hear how you balance deterministic generation, designer control, and terrain constraints—especially if you have taken a PCG-based or runtime-first approach.

Top comments (0)