From 7ffe3ec97800a449babc5e78b6e49d96904ca87d Mon Sep 17 00:00:00 2001 From: nathan Date: Thu, 14 May 2026 06:41:23 -0700 Subject: [PATCH] Add Ground Zero foliage pass --- AGRARIAN_DEVELOPMENT_ROADMAP.md | 4 +- .../Maps/L_GroundZeroTerrain_Test.umap | 4 +- Docs/Terrain/GroundZeroFoliagePass.md | 40 +++++ Scripts/setup_ground_zero_demo_map.py | 155 ++++++++++++++++++ Scripts/verify_ground_zero_foliage.py | 52 ++++++ Source/AgrarianGame/AgrarianFoliagePatch.cpp | 94 +++++++++++ Source/AgrarianGame/AgrarianFoliagePatch.h | 52 ++++++ 7 files changed, 397 insertions(+), 4 deletions(-) create mode 100644 Docs/Terrain/GroundZeroFoliagePass.md create mode 100644 Scripts/verify_ground_zero_foliage.py create mode 100644 Source/AgrarianGame/AgrarianFoliagePatch.cpp create mode 100644 Source/AgrarianGame/AgrarianFoliagePatch.h diff --git a/AGRARIAN_DEVELOPMENT_ROADMAP.md b/AGRARIAN_DEVELOPMENT_ROADMAP.md index 8c7d215..e62e6cb 100644 --- a/AGRARIAN_DEVELOPMENT_ROADMAP.md +++ b/AGRARIAN_DEVELOPMENT_ROADMAP.md @@ -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. diff --git a/Content/Agrarian/Maps/L_GroundZeroTerrain_Test.umap b/Content/Agrarian/Maps/L_GroundZeroTerrain_Test.umap index 7daeb77..b66ab38 100644 --- a/Content/Agrarian/Maps/L_GroundZeroTerrain_Test.umap +++ b/Content/Agrarian/Maps/L_GroundZeroTerrain_Test.umap @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9a2ab0438daf46f9247bb01dc1adf80a5302e08ed7311e98061d8554242b239f -size 7408412 +oid sha256:5a97ad4279d404f86a44d0e8c36712bbac9db70843d68747f8ad2465889fc9b7 +size 7464126 diff --git a/Docs/Terrain/GroundZeroFoliagePass.md b/Docs/Terrain/GroundZeroFoliagePass.md new file mode 100644 index 0000000..94337ac --- /dev/null +++ b/Docs/Terrain/GroundZeroFoliagePass.md @@ -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. diff --git a/Scripts/setup_ground_zero_demo_map.py b/Scripts/setup_ground_zero_demo_map.py index 8ec659c..731d603 100644 --- a/Scripts/setup_ground_zero_demo_map.py +++ b/Scripts/setup_ground_zero_demo_map.py @@ -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) diff --git a/Scripts/verify_ground_zero_foliage.py b/Scripts/verify_ground_zero_foliage.py new file mode 100644 index 0000000..872fc01 --- /dev/null +++ b/Scripts/verify_ground_zero_foliage.py @@ -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() diff --git a/Source/AgrarianGame/AgrarianFoliagePatch.cpp b/Source/AgrarianGame/AgrarianFoliagePatch.cpp new file mode 100644 index 0000000..93af678 --- /dev/null +++ b/Source/AgrarianGame/AgrarianFoliagePatch.cpp @@ -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(TEXT("SceneRoot")); + RootComponent = SceneRoot; + SceneRoot->SetMobility(EComponentMobility::Static); + + TreeInstances = CreateDefaultSubobject(TEXT("TreeInstances")); + TreeInstances->SetupAttachment(SceneRoot); + ConfigureFoliageComponent(TreeInstances, TEXT("BlockAll")); + + ShrubInstances = CreateDefaultSubobject(TEXT("ShrubInstances")); + ShrubInstances->SetupAttachment(SceneRoot); + ConfigureFoliageComponent(ShrubInstances, TEXT("NoCollision")); + + GrassInstances = CreateDefaultSubobject(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; +} diff --git a/Source/AgrarianGame/AgrarianFoliagePatch.h b/Source/AgrarianGame/AgrarianFoliagePatch.h new file mode 100644 index 0000000..bd6374d --- /dev/null +++ b/Source/AgrarianGame/AgrarianFoliagePatch.h @@ -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 SceneRoot; + + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Agrarian|Foliage") + TObjectPtr TreeInstances; + + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Agrarian|Foliage") + TObjectPtr ShrubInstances; + + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Agrarian|Foliage") + TObjectPtr 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; +};