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.
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.
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:
- Repeatability — the same seed should reproduce the layout when the recipe and scene are unchanged.
- Art direction — artists should provide meshes, weights, density, and scale without recompiling C++.
- Spatial logic — roads, buildings, fields, tombs, gates, and props need different placement rules.
- Terrain awareness — generated content must land on Landscape instead of floating at the input Z.
- 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
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;
};
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);
}
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;
}
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);
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;
}
}
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;
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);
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"
}
The public command is generate_scatter_region. Inputs are validated before the world is touched:
- region type must be
village,farm, orcemetery - 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
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)