Add Ground Zero foliage pass

This commit is contained in:
2026-05-14 06:41:23 -07:00
parent 7a05e324a3
commit 7ffe3ec978
7 changed files with 397 additions and 4 deletions
+2 -2
View File
@@ -424,7 +424,7 @@ Target deliverable: A small group can join a server, spawn into one biome, gathe
- [x] Verify terrain scale is 1 km x 1 km in Unreal.
- [x] Verify terrain tile origin and centered Unreal bounds for the Ground Zero test map.
- [x] Verify neighboring tile edge coordinates against the registry before multi-tile stitching.
- [ ] Add foliage pass.
- [x] Add foliage pass.
- [~] Add resource nodes.
- [ ] Add biome-appropriate natural resources based on Ground Zero.
- [ ] Add water source.
@@ -1402,4 +1402,4 @@ Next version .01 priorities:
Immediate next item:
- [ ] Add foliage pass.
- [ ] Add biome-appropriate natural resources based on Ground Zero.
Binary file not shown.
+40
View File
@@ -0,0 +1,40 @@
# Ground Zero First-Pass Foliage
The Ground Zero demo map now has a first-pass foliage layer driven by terrain
height and simple biome placement rules.
## Implementation
- Native actor: `AAgrarianFoliagePatch`
- Map setup script: `Scripts/setup_ground_zero_demo_map.py`
- Map actor label: `AGR_GroundZeroFoliage_FirstPass`
- Mesh source: Unreal level prototyping meshes for now.
The actor uses hierarchical instanced static mesh components for three visible
foliage groups:
- Trees: taller cylinder instances placed mostly on higher/hill terrain.
- Shrubs: low blocky scrub instances across valley and hillside terrain.
- Grass: small clump instances across the broader walkable tile.
## Placement Rules
The first pass is intentionally deterministic. It uses the Ground Zero Unreal
heightmap and a fixed random seed so repeated map setup runs produce the same
foliage distribution.
The placement avoids the demo player start, shelter, campfire, resource nodes,
and wildlife spawn area so the initial investor path remains readable.
## Counts
- Trees: `42`
- Shrubs: `96`
- Grass clumps: `180`
## Follow-Up
This is a visual and performance-safe placeholder pass, not final ecology. The
next biome/resource passes should replace prototype meshes with real coastal
California scrub, grassland, woodland, and resource-specific assets, then tie
spawn density to land-cover and hydrography data.
+155
View File
@@ -1,3 +1,5 @@
import math
import random
import struct
from pathlib import Path
@@ -13,6 +15,14 @@ XY_SCALE_CM = 100000.0 / (LANDSCAPE_SIZE - 1)
Z_SCALE_CM = 100.0
LANDSCAPE_MIN_XY = -50000.0
FOLIAGE_LABEL = "AGR_GroundZeroFoliage_FirstPass"
FOLIAGE_RANDOM_SEED = 4160544
FOLIAGE_MESHES = {
"tree": "/Game/LevelPrototyping/Meshes/SM_Cylinder",
"shrub": "/Game/LevelPrototyping/Meshes/SM_Cube",
"grass": "/Game/LevelPrototyping/Meshes/SM_Cylinder",
}
DEMO_ACTORS = [
{
@@ -88,6 +98,34 @@ DEMO_ACTORS = [
]
FOLIAGE_ZONES = {
"trees": {
"count": 42,
"x_range": (-25000.0, 42000.0),
"y_range": (-12000.0, 42000.0),
"min_elevation_m": 18.0,
"avoid_radius_cm": 3600.0,
"scale_range": (0.75, 1.35),
},
"shrubs": {
"count": 96,
"x_range": (-42000.0, 45000.0),
"y_range": (-38000.0, 45000.0),
"min_elevation_m": 7.0,
"avoid_radius_cm": 2600.0,
"scale_range": (0.45, 1.05),
},
"grass": {
"count": 180,
"x_range": (-46000.0, 46000.0),
"y_range": (-46000.0, 46000.0),
"min_elevation_m": 4.0,
"avoid_radius_cm": 1800.0,
"scale_range": (0.35, 0.9),
},
}
def get_actor_label(actor):
try:
return actor.get_actor_label()
@@ -109,6 +147,13 @@ def load_blueprint_class(path):
return generated_class
def load_required_asset(path):
asset = unreal.EditorAssetLibrary.load_asset(path)
if not asset:
raise RuntimeError(f"Required asset not found: {path}")
return asset
def load_heightmap():
raw = HEIGHTMAP_PATH.read_bytes()
expected_bytes = LANDSCAPE_SIZE * LANDSCAPE_SIZE * 2
@@ -125,12 +170,120 @@ def terrain_z_cm(height_values, x_cm, y_cm):
return elevation_m * 100.0
def terrain_elevation_m(height_values, x_cm, y_cm):
return terrain_z_cm(height_values, x_cm, y_cm) / 100.0
def remove_existing_demo_actors(labels):
for actor in unreal.EditorLevelLibrary.get_all_level_actors():
if get_actor_label(actor) in labels:
unreal.EditorLevelLibrary.destroy_actor(actor)
def distance_2d(a, b):
return math.hypot(a.x - b.x, a.y - b.y)
def make_foliage_transform(height_values, x, y, yaw, z_offset, scale_xy, scale_z):
return unreal.Transform(
location=unreal.Vector(x, y, terrain_z_cm(height_values, x, y) + z_offset),
rotation=unreal.Rotator(0.0, yaw, 0.0),
scale=unreal.Vector(scale_xy, scale_xy, scale_z),
)
def configure_foliage_meshes(foliage_actor):
component_meshes = {
"tree_instances": load_required_asset(FOLIAGE_MESHES["tree"]),
"shrub_instances": load_required_asset(FOLIAGE_MESHES["shrub"]),
"grass_instances": load_required_asset(FOLIAGE_MESHES["grass"]),
}
for property_name, mesh in component_meshes.items():
component = foliage_actor.get_editor_property(property_name)
component.set_editor_property("static_mesh", mesh)
def choose_foliage_points(height_values, zone, reserved_points, existing_points):
rng = random.Random(FOLIAGE_RANDOM_SEED + len(existing_points) + int(zone["count"]))
chosen = []
attempts = 0
max_attempts = zone["count"] * 80
while len(chosen) < zone["count"] and attempts < max_attempts:
attempts += 1
x = rng.uniform(zone["x_range"][0], zone["x_range"][1])
y = rng.uniform(zone["y_range"][0], zone["y_range"][1])
point = unreal.Vector(x, y, 0.0)
if terrain_elevation_m(height_values, x, y) < zone["min_elevation_m"]:
continue
if any(distance_2d(point, reserved) < zone["avoid_radius_cm"] for reserved in reserved_points):
continue
if any(distance_2d(point, existing) < zone["avoid_radius_cm"] * 0.55 for existing in existing_points):
continue
chosen.append(point)
existing_points.append(point)
if len(chosen) != zone["count"]:
unreal.log_warning(f"Placed {len(chosen)} of {zone['count']} requested foliage instances for zone.")
return chosen
def spawn_foliage_actor(height_values):
reserved_points = [
spec["location_xy"]
for spec in DEMO_ACTORS
if spec["label"] not in {"AGR_DemoSun", "AGR_DemoSkyLight", "AGR_DemoFog", "AGR_DemoNoticeActor"}
]
foliage_actor = unreal.AgrarianEditorAutomationLibrary.spawn_actor_in_editor_world(
unreal.AgrarianFoliagePatch,
unreal.Vector(0.0, 0.0, 0.0),
unreal.Rotator(0.0, 0.0, 0.0),
FOLIAGE_LABEL,
)
if not foliage_actor:
raise RuntimeError("Could not spawn first-pass foliage actor.")
set_actor_label(foliage_actor, FOLIAGE_LABEL)
configure_foliage_meshes(foliage_actor)
foliage_actor.clear_foliage()
existing_points = []
rng = random.Random(FOLIAGE_RANDOM_SEED)
for point in choose_foliage_points(height_values, FOLIAGE_ZONES["trees"], reserved_points, existing_points):
scale = rng.uniform(*FOLIAGE_ZONES["trees"]["scale_range"])
foliage_actor.add_tree_instance(
make_foliage_transform(height_values, point.x, point.y, rng.uniform(0.0, 360.0), 5.0, scale, scale * 5.5)
)
for point in choose_foliage_points(height_values, FOLIAGE_ZONES["shrubs"], reserved_points, existing_points):
scale = rng.uniform(*FOLIAGE_ZONES["shrubs"]["scale_range"])
foliage_actor.add_shrub_instance(
make_foliage_transform(height_values, point.x, point.y, rng.uniform(0.0, 360.0), 4.0, scale * 2.2, scale * 0.85)
)
for point in choose_foliage_points(height_values, FOLIAGE_ZONES["grass"], reserved_points, existing_points):
scale = rng.uniform(*FOLIAGE_ZONES["grass"]["scale_range"])
foliage_actor.add_grass_instance(
make_foliage_transform(height_values, point.x, point.y, rng.uniform(0.0, 360.0), 2.0, scale * 0.28, scale * 1.6)
)
unreal.log(
"Placed first-pass Ground Zero foliage: "
f"{foliage_actor.get_tree_instance_count()} trees, "
f"{foliage_actor.get_shrub_instance_count()} shrubs, "
f"{foliage_actor.get_grass_instance_count()} grass clumps."
)
return foliage_actor
def spawn_demo_actor(spec, height_values):
location_xy = spec["location_xy"]
z = spec.get("fixed_z")
@@ -160,9 +313,11 @@ def main():
raise RuntimeError(f"Could not load map: {MAP_PATH}")
labels = {spec["label"] for spec in DEMO_ACTORS}
labels.add(FOLIAGE_LABEL)
remove_existing_demo_actors(labels)
height_values = load_heightmap()
spawn_foliage_actor(height_values)
for spec in DEMO_ACTORS:
spawn_demo_actor(spec, height_values)
+52
View File
@@ -0,0 +1,52 @@
import unreal
MAP_PATH = "/Game/Agrarian/Maps/L_GroundZeroTerrain_Test"
FOLIAGE_LABEL = "AGR_GroundZeroFoliage_FirstPass"
EXPECTED_COUNTS = {
"trees": 42,
"shrubs": 96,
"grass": 180,
}
def get_actor_label(actor):
try:
return actor.get_actor_label()
except Exception:
return actor.get_name()
def main():
if not unreal.EditorLevelLibrary.load_level(MAP_PATH):
raise RuntimeError(f"Could not load map: {MAP_PATH}")
actors = unreal.EditorLevelLibrary.get_all_level_actors()
foliage_actors = [actor for actor in actors if get_actor_label(actor) == FOLIAGE_LABEL]
if len(foliage_actors) != 1:
raise RuntimeError(f"Expected exactly one {FOLIAGE_LABEL}, found {len(foliage_actors)}")
foliage = foliage_actors[0]
actual_counts = {
"trees": foliage.get_tree_instance_count(),
"shrubs": foliage.get_shrub_instance_count(),
"grass": foliage.get_grass_instance_count(),
}
failures = [
f"{kind} expected {expected}, got {actual_counts[kind]}"
for kind, expected in EXPECTED_COUNTS.items()
if actual_counts[kind] != expected
]
if failures:
raise RuntimeError("Ground Zero foliage verification failed: " + "; ".join(failures))
unreal.log(
"Ground Zero foliage verification complete: "
f"{actual_counts['trees']} trees, "
f"{actual_counts['shrubs']} shrubs, "
f"{actual_counts['grass']} grass clumps."
)
main()
@@ -0,0 +1,94 @@
// Copyright Pacificao. All Rights Reserved.
#include "AgrarianFoliagePatch.h"
#include "Components/HierarchicalInstancedStaticMeshComponent.h"
#include "Components/SceneComponent.h"
namespace
{
void ConfigureFoliageComponent(UHierarchicalInstancedStaticMeshComponent* Component, const FName CollisionProfileName)
{
if (!Component)
{
return;
}
Component->SetMobility(EComponentMobility::Static);
Component->SetCollisionProfileName(CollisionProfileName);
Component->SetGenerateOverlapEvents(false);
Component->bCastDynamicShadow = true;
Component->bCastStaticShadow = true;
Component->InstanceStartCullDistance = 120000;
Component->InstanceEndCullDistance = 180000;
}
}
AAgrarianFoliagePatch::AAgrarianFoliagePatch()
{
PrimaryActorTick.bCanEverTick = false;
SceneRoot = CreateDefaultSubobject<USceneComponent>(TEXT("SceneRoot"));
RootComponent = SceneRoot;
SceneRoot->SetMobility(EComponentMobility::Static);
TreeInstances = CreateDefaultSubobject<UHierarchicalInstancedStaticMeshComponent>(TEXT("TreeInstances"));
TreeInstances->SetupAttachment(SceneRoot);
ConfigureFoliageComponent(TreeInstances, TEXT("BlockAll"));
ShrubInstances = CreateDefaultSubobject<UHierarchicalInstancedStaticMeshComponent>(TEXT("ShrubInstances"));
ShrubInstances->SetupAttachment(SceneRoot);
ConfigureFoliageComponent(ShrubInstances, TEXT("NoCollision"));
GrassInstances = CreateDefaultSubobject<UHierarchicalInstancedStaticMeshComponent>(TEXT("GrassInstances"));
GrassInstances->SetupAttachment(SceneRoot);
ConfigureFoliageComponent(GrassInstances, TEXT("NoCollision"));
}
void AAgrarianFoliagePatch::ClearFoliage()
{
if (TreeInstances)
{
TreeInstances->ClearInstances();
}
if (ShrubInstances)
{
ShrubInstances->ClearInstances();
}
if (GrassInstances)
{
GrassInstances->ClearInstances();
}
}
int32 AAgrarianFoliagePatch::AddTreeInstance(const FTransform& InstanceTransform)
{
return TreeInstances ? TreeInstances->AddInstance(InstanceTransform, true) : INDEX_NONE;
}
int32 AAgrarianFoliagePatch::AddShrubInstance(const FTransform& InstanceTransform)
{
return ShrubInstances ? ShrubInstances->AddInstance(InstanceTransform, true) : INDEX_NONE;
}
int32 AAgrarianFoliagePatch::AddGrassInstance(const FTransform& InstanceTransform)
{
return GrassInstances ? GrassInstances->AddInstance(InstanceTransform, true) : INDEX_NONE;
}
int32 AAgrarianFoliagePatch::GetTreeInstanceCount() const
{
return TreeInstances ? TreeInstances->GetInstanceCount() : 0;
}
int32 AAgrarianFoliagePatch::GetShrubInstanceCount() const
{
return ShrubInstances ? ShrubInstances->GetInstanceCount() : 0;
}
int32 AAgrarianFoliagePatch::GetGrassInstanceCount() const
{
return GrassInstances ? GrassInstances->GetInstanceCount() : 0;
}
@@ -0,0 +1,52 @@
// Copyright Pacificao. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "AgrarianFoliagePatch.generated.h"
class UHierarchicalInstancedStaticMeshComponent;
class USceneComponent;
UCLASS(Blueprintable)
class AGRARIANGAME_API AAgrarianFoliagePatch : public AActor
{
GENERATED_BODY()
public:
AAgrarianFoliagePatch();
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Agrarian|Foliage")
TObjectPtr<USceneComponent> SceneRoot;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Agrarian|Foliage")
TObjectPtr<UHierarchicalInstancedStaticMeshComponent> TreeInstances;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Agrarian|Foliage")
TObjectPtr<UHierarchicalInstancedStaticMeshComponent> ShrubInstances;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Agrarian|Foliage")
TObjectPtr<UHierarchicalInstancedStaticMeshComponent> GrassInstances;
UFUNCTION(BlueprintCallable, Category = "Agrarian|Foliage")
void ClearFoliage();
UFUNCTION(BlueprintCallable, Category = "Agrarian|Foliage")
int32 AddTreeInstance(const FTransform& InstanceTransform);
UFUNCTION(BlueprintCallable, Category = "Agrarian|Foliage")
int32 AddShrubInstance(const FTransform& InstanceTransform);
UFUNCTION(BlueprintCallable, Category = "Agrarian|Foliage")
int32 AddGrassInstance(const FTransform& InstanceTransform);
UFUNCTION(BlueprintPure, Category = "Agrarian|Foliage")
int32 GetTreeInstanceCount() const;
UFUNCTION(BlueprintPure, Category = "Agrarian|Foliage")
int32 GetShrubInstanceCount() const;
UFUNCTION(BlueprintPure, Category = "Agrarian|Foliage")
int32 GetGrassInstanceCount() const;
};