diff --git a/AGRARIAN_DEVELOPMENT_ROADMAP.md b/AGRARIAN_DEVELOPMENT_ROADMAP.md index 922eac4..7e7a9e6 100644 --- a/AGRARIAN_DEVELOPMENT_ROADMAP.md +++ b/AGRARIAN_DEVELOPMENT_ROADMAP.md @@ -824,7 +824,7 @@ Target deliverable: A small group can join a server, spawn into one biome, gathe - [x] Replace box/sphere/cylinder survival objects with readable MVP meshes for campfires, primitive shelter pieces, resource pickups, water sources, wildlife, and gathered items. Added composed native proxy visuals for campfires, primitive shelters, pickups, resource nodes, water sources, and wildlife using cooked Agrarian placeholder mesh assets plus Ground Zero materials, with child visuals set to no collision so gameplay behavior remains unchanged while investor builds read as intentional objects instead of primitive debug shapes. - [x] Replace the placeholder Ground Zero environment presentation with investor-facing biome dressing: believable terrain material, grass, brush, shrubs, bushes, trees, rocks, water visuals, and local coastal-scrub color variation. Upgraded the repeatable Ground Zero setup to require denser investor-facing foliage counts and twenty-three labeled variation actors covering trees, brush, shrubs, dry grass mats, rock slabs, water-bank pieces, reeds, and freshwater surface material variation, then extended the verifier/docs so the map no longer qualifies if the visual dressing falls back to sparse placeholder presentation. - [x] Add a real water-source visual pass with surface material, edge treatment, scale, and placement that reads as collectable freshwater instead of a placeholder plane. Formalized the MVP freshwater presentation around the native `AAgrarianWaterSource` water-surface, stone-bank, and collect-marker proxies, documented the Ground Zero drainage-candidate placement and nearby water-bank/reed dressing, and added an Unreal verifier that checks surface material, edge treatment, scale, placement, and nearby dressing actors in the actual map. -- [ ] Add density and sightline tuning so grasses, shrubs, trees, and resource clusters are visible enough to sell the world without hiding gameplay-critical objects. +- [x] Add density and sightline tuning so grasses, shrubs, trees, and resource clusters are visible enough to sell the world without hiding gameplay-critical objects. Added protected foliage clearances around early survival targets and biome resource nodes, sampled first-look sightline corridors from the player start to wood, fiber, campfire, shelter, wildlife, and freshwater, plus a dedicated Unreal verifier/documentation gate so investor-facing density cannot regress into object-hiding clutter. - [ ] Preserve realism as the target: use assets, materials, lighting, and environmental dressing that can survive toward MVP production rather than cosmetic throwaways where practical. - [ ] Define default, recommended, and cinematic investor rendering presets, with ray tracing available only as an optional high-end/cinematic mode and never required for baseline visual credibility. - [ ] Verify the non-ray-traced compatibility/default path still looks credible on common investor, tester, and remote-session hardware. diff --git a/Content/Agrarian/Maps/L_GroundZeroTerrain_Test.umap b/Content/Agrarian/Maps/L_GroundZeroTerrain_Test.umap index 1e4f9d4..fa1c07c 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:7c94fd13bec41aafacc13fb88d82ce975e6d525868b0f898ee04dcb8fecd78aa -size 7549806 +oid sha256:23e2d2bb544d36bb58139d0a1c1a2eb047e0122a335a24ff2d2f91f4214fd91f +size 7549816 diff --git a/Docs/Terrain/GroundZeroDensitySightlineTuning.md b/Docs/Terrain/GroundZeroDensitySightlineTuning.md new file mode 100644 index 0000000..f8b4444 --- /dev/null +++ b/Docs/Terrain/GroundZeroDensitySightlineTuning.md @@ -0,0 +1,40 @@ +# Ground Zero Density And Sightline Tuning + +The investor-facing Ground Zero dressing should read as a coastal scrub biome +without hiding the first playable survival loop. + +## Protected Gameplay Reads + +The repeatable map setup reserves clear pads around: + +- `AGR_DemoPlayerStart` +- `AGR_DemoWoodResource_01` +- `AGR_DemoFiberResource_01` +- `AGR_DemoCampfire_01` +- `AGR_DemoPrimitiveShelter_01` +- `AGR_DemoRabbitWildlife_01` +- `AGR_GZ_FreshWaterSource_01` +- the placed Ground Zero biome resource nodes + +Tree, shrub, and grass reservations use different radii so tall silhouettes +stay farther away while low grass can still sell density without blocking +interaction targets. + +## First-Look Sightlines + +The setup also reserves sampled corridors from `AGR_DemoPlayerStart` to the +early survival objects and the first freshwater source. These corridors keep +the demo readable when an investor spawns in: wood, fiber, shelter, campfire, +wildlife, and water should all remain visually findable without flattening the +whole biome into an empty test field. + +## Verification + +`Scripts/verify_ground_zero_density_sightlines.py` loads +`/Game/Agrarian/Maps/L_GroundZeroTerrain_Test` and checks: + +- foliage counts remain at the investor-facing density target; +- no tree, shrub, or grass instance violates the critical clearances; +- no tree, shrub, or grass instance violates the sampled start-to-object + sightline corridors; +- the documentation and roadmap keep the tuning gate visible. diff --git a/Scripts/setup_ground_zero_demo_map.py b/Scripts/setup_ground_zero_demo_map.py index 322528d..94b9897 100644 --- a/Scripts/setup_ground_zero_demo_map.py +++ b/Scripts/setup_ground_zero_demo_map.py @@ -629,6 +629,37 @@ FOLIAGE_ZONES = { }, } +SIGHTLINE_TUNING_CONFIG = { + "critical_clearance_cm": { + "trees": 5200.0, + "shrubs": 3400.0, + "grass": 1250.0, + }, + "corridor_clearance_cm": { + "trees": 3600.0, + "shrubs": 2100.0, + "grass": 850.0, + }, + "corridor_sample_spacing_cm": 1600.0, + "critical_actor_labels": { + "AGR_DemoPlayerStart", + "AGR_DemoWoodResource_01", + "AGR_DemoFiberResource_01", + "AGR_DemoCampfire_01", + "AGR_DemoPrimitiveShelter_01", + "AGR_DemoRabbitWildlife_01", + "AGR_GZ_FreshWaterSource_01", + }, + "sightline_pairs": [ + ("AGR_DemoPlayerStart", "AGR_DemoWoodResource_01"), + ("AGR_DemoPlayerStart", "AGR_DemoFiberResource_01"), + ("AGR_DemoPlayerStart", "AGR_DemoCampfire_01"), + ("AGR_DemoPlayerStart", "AGR_DemoPrimitiveShelter_01"), + ("AGR_DemoPlayerStart", "AGR_DemoRabbitWildlife_01"), + ("AGR_DemoPlayerStart", "AGR_GZ_FreshWaterSource_01"), + ], +} + def get_actor_label(actor): try: @@ -796,6 +827,42 @@ def distance_2d(a, b): return math.hypot(a.x - b.x, a.y - b.y) +def reserved_point(point, source_label, clearances): + return { + "point": point, + "source_label": source_label, + "clearances": clearances, + } + + +def reserved_point_clearance(reservation, foliage_family): + return reservation["clearances"].get(foliage_family, 0.0) + + +def add_sightline_corridor_reservations(reservations, label_points): + spacing_cm = SIGHTLINE_TUNING_CONFIG["corridor_sample_spacing_cm"] + corridor_clearances = SIGHTLINE_TUNING_CONFIG["corridor_clearance_cm"] + + for start_label, end_label in SIGHTLINE_TUNING_CONFIG["sightline_pairs"]: + start = label_points.get(start_label) + end = label_points.get(end_label) + if not start or not end: + continue + + length_cm = distance_2d(start, end) + sample_count = max(1, int(length_cm / spacing_cm)) + for index in range(1, sample_count): + alpha = index / sample_count + point = unreal.Vector( + start.x + ((end.x - start.x) * alpha), + start.y + ((end.y - start.y) * alpha), + 0.0, + ) + reservations.append( + reserved_point(point, f"{start_label}->{end_label}", corridor_clearances) + ) + + def validate_safe_spawn_location(height_values, location_xy, resource_points, water_points): failures = [] elevation_m = terrain_elevation_m(height_values, location_xy.x, location_xy.y) @@ -882,7 +949,7 @@ def apply_foliage_materials(foliage_actor, materials): component.set_material(0, material) -def choose_foliage_points(height_values, zone, reserved_points, existing_points): +def choose_foliage_points(height_values, family_name, zone, reservations, existing_points): rng = random.Random(FOLIAGE_RANDOM_SEED + len(existing_points) + int(zone["count"])) chosen = [] attempts = 0 @@ -897,7 +964,10 @@ def choose_foliage_points(height_values, zone, reserved_points, existing_points) 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): + if any( + distance_2d(point, reservation["point"]) < reserved_point_clearance(reservation, family_name) + for reservation in reservations + ): continue if any(distance_2d(point, existing) < zone["avoid_radius_cm"] * 0.55 for existing in existing_points): @@ -912,12 +982,33 @@ def choose_foliage_points(height_values, zone, reserved_points, existing_points) return chosen -def spawn_foliage_actor(height_values, materials): - reserved_points = [ - spec["location_xy"] - for spec in DEMO_ACTORS - if spec["label"] not in {"AGR_DemoSkyLightingController", "AGR_DemoWeatherAudioController", "AGR_DemoNoticeActor"} - ] +def build_foliage_reservations(safe_spawn_location_xy): + label_points = {} + specs = DEMO_ACTORS + BIOME_RESOURCE_ACTORS + WATER_SOURCE_ACTORS + for spec in specs: + label = spec["label"] + location_xy = spec["location_xy"] + if label == SAFE_SPAWN_CONFIG["player_start_label"] and safe_spawn_location_xy is not None: + location_xy = safe_spawn_location_xy + label_points[label] = location_xy + + reservations = [] + critical_clearances = SIGHTLINE_TUNING_CONFIG["critical_clearance_cm"] + for label in SIGHTLINE_TUNING_CONFIG["critical_actor_labels"]: + point = label_points.get(label) + if point: + reservations.append(reserved_point(point, label, critical_clearances)) + + for spec in BIOME_RESOURCE_ACTORS: + reservations.append(reserved_point(spec["location_xy"], spec["label"], critical_clearances)) + + add_sightline_corridor_reservations(reservations, label_points) + unreal.log(f"Prepared {len(reservations)} Ground Zero foliage sightline reservation point(s).") + return reservations + + +def spawn_foliage_actor(height_values, materials, safe_spawn_location_xy): + reservations = build_foliage_reservations(safe_spawn_location_xy) foliage_actor = unreal.AgrarianEditorAutomationLibrary.spawn_actor_in_editor_world( unreal.AgrarianFoliagePatch, @@ -936,19 +1027,19 @@ def spawn_foliage_actor(height_values, materials): existing_points = [] rng = random.Random(FOLIAGE_RANDOM_SEED) - for point in choose_foliage_points(height_values, FOLIAGE_ZONES["trees"], reserved_points, existing_points): + for point in choose_foliage_points(height_values, "trees", FOLIAGE_ZONES["trees"], reservations, 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): + for point in choose_foliage_points(height_values, "shrubs", FOLIAGE_ZONES["shrubs"], reservations, 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): + for point in choose_foliage_points(height_values, "grass", FOLIAGE_ZONES["grass"], reservations, 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) @@ -1091,7 +1182,7 @@ def main(): apply_landscape_material(materials["terrain"]) height_values = load_heightmap() safe_spawn_location_xy = select_safe_spawn_location(height_values) - spawn_foliage_actor(height_values, materials) + spawn_foliage_actor(height_values, materials, safe_spawn_location_xy) for spec in BIOME_RESOURCE_ACTORS: spawn_demo_actor(spec, height_values, materials) for spec in WATER_SOURCE_ACTORS: diff --git a/Scripts/verify_ground_zero_density_sightlines.py b/Scripts/verify_ground_zero_density_sightlines.py new file mode 100644 index 0000000..a9dc54e --- /dev/null +++ b/Scripts/verify_ground_zero_density_sightlines.py @@ -0,0 +1,193 @@ +import math +from pathlib import Path + +import unreal + + +MAP_PATH = "/Game/Agrarian/Maps/L_GroundZeroTerrain_Test" +FOLIAGE_LABEL = "AGR_GroundZeroFoliage_FirstPass" +EXPECTED_FOLIAGE_COUNTS = { + "trees": 64, + "shrubs": 148, + "grass": 260, +} +CRITICAL_CLEARANCE_CM = { + "trees": 5200.0, + "shrubs": 3400.0, + "grass": 1250.0, +} +CORRIDOR_CLEARANCE_CM = { + "trees": 3600.0, + "shrubs": 2100.0, + "grass": 850.0, +} +CORRIDOR_SAMPLE_SPACING_CM = 1600.0 +CRITICAL_ACTOR_LABELS = { + "AGR_DemoPlayerStart", + "AGR_DemoWoodResource_01", + "AGR_DemoFiberResource_01", + "AGR_DemoCampfire_01", + "AGR_DemoPrimitiveShelter_01", + "AGR_DemoRabbitWildlife_01", + "AGR_GZ_FreshWaterSource_01", + "AGR_GZ_Wood_CoastalScrub_01", + "AGR_GZ_Wood_CoastalScrub_02", + "AGR_GZ_Wood_Hillside_03", + "AGR_GZ_Fiber_Grassland_01", + "AGR_GZ_Fiber_Grassland_02", + "AGR_GZ_Fiber_Scrub_03", + "AGR_GZ_Fiber_DrainageCandidate_04", + "AGR_GZ_EdiblePlant_CoastalScrub_01", + "AGR_GZ_EdiblePlant_Grassland_02", + "AGR_GZ_EdiblePlant_DrainageCandidate_03", + "AGR_GZ_Stone_Slope_01", + "AGR_GZ_Stone_Slope_02", + "AGR_GZ_Stone_ExposedTerrain_03", + "AGR_GZ_Stone_ValleyEdge_04", +} +SIGHTLINE_PAIRS = [ + ("AGR_DemoPlayerStart", "AGR_DemoWoodResource_01"), + ("AGR_DemoPlayerStart", "AGR_DemoFiberResource_01"), + ("AGR_DemoPlayerStart", "AGR_DemoCampfire_01"), + ("AGR_DemoPlayerStart", "AGR_DemoPrimitiveShelter_01"), + ("AGR_DemoPlayerStart", "AGR_DemoRabbitWildlife_01"), + ("AGR_DemoPlayerStart", "AGR_GZ_FreshWaterSource_01"), +] + + +def get_actor_label(actor): + try: + return actor.get_actor_label() + except Exception: + return actor.get_name() + + +def distance_2d(a, b): + return math.hypot(a.x - b.x, a.y - b.y) + + +def transform_location(transform_result): + transform = transform_result[0] if isinstance(transform_result, tuple) else transform_result + if hasattr(transform, "translation"): + return transform.translation + try: + return transform.get_editor_property("translation") + except Exception: + return transform.get_location() + + +def instance_locations(component): + locations = [] + for index in range(component.get_instance_count()): + locations.append(transform_location(component.get_instance_transform(index, True))) + return locations + + +def sampled_corridor_points(start, end): + length_cm = distance_2d(start, end) + sample_count = max(1, int(length_cm / CORRIDOR_SAMPLE_SPACING_CM)) + points = [] + for index in range(1, sample_count): + alpha = index / sample_count + points.append( + unreal.Vector( + start.x + ((end.x - start.x) * alpha), + start.y + ((end.y - start.y) * alpha), + 0.0, + ) + ) + return points + + +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() + actors_by_label = {get_actor_label(actor): actor for actor in actors} + failures = [] + + missing_labels = sorted(label for label in CRITICAL_ACTOR_LABELS if label not in actors_by_label) + if missing_labels: + failures.append("missing critical actor label(s): " + ", ".join(missing_labels)) + + foliage = actors_by_label.get(FOLIAGE_LABEL) + if not foliage: + failures.append(f"missing {FOLIAGE_LABEL}") + else: + foliage_components = { + "trees": foliage.get_editor_property("tree_instances"), + "shrubs": foliage.get_editor_property("shrub_instances"), + "grass": foliage.get_editor_property("grass_instances"), + } + foliage_locations = { + family: instance_locations(component) + for family, component in foliage_components.items() + } + + for family, expected_count in EXPECTED_FOLIAGE_COUNTS.items(): + actual_count = len(foliage_locations[family]) + if actual_count != expected_count: + failures.append(f"{family} expected {expected_count}, got {actual_count}") + + critical_points = { + label: actors_by_label[label].get_actor_location() + for label in CRITICAL_ACTOR_LABELS + if label in actors_by_label + } + for family, locations in foliage_locations.items(): + clearance = CRITICAL_CLEARANCE_CM[family] + for label, point in critical_points.items(): + nearest = min((distance_2d(location, point) for location in locations), default=999999.0) + if nearest < clearance: + failures.append( + f"{family} nearest to {label} is {nearest:.0f}cm, below {clearance:.0f}cm clearance" + ) + + for start_label, end_label in SIGHTLINE_PAIRS: + start_actor = actors_by_label.get(start_label) + end_actor = actors_by_label.get(end_label) + if not start_actor or not end_actor: + continue + + corridor_points = sampled_corridor_points( + start_actor.get_actor_location(), + end_actor.get_actor_location(), + ) + for family, locations in foliage_locations.items(): + clearance = CORRIDOR_CLEARANCE_CM[family] + for point in corridor_points: + nearest = min((distance_2d(location, point) for location in locations), default=999999.0) + if nearest < clearance: + failures.append( + f"{family} nearest to {start_label}->{end_label} corridor is " + f"{nearest:.0f}cm, below {clearance:.0f}cm clearance" + ) + break + + project_root = Path(unreal.Paths.convert_relative_path_to_full(unreal.Paths.project_dir())) + docs = project_root / "Docs" / "Terrain" / "GroundZeroDensitySightlineTuning.md" + roadmap = project_root / "AGRARIAN_DEVELOPMENT_ROADMAP.md" + for path, snippets in { + docs: ["Ground Zero Density And Sightline Tuning", "First-Look Sightlines"], + roadmap: ["[x] Add density and sightline tuning"], + }.items(): + text = path.read_text(encoding="utf-8") + for snippet in snippets: + if snippet not in text: + failures.append(f"{path} missing snippet: {snippet}") + + if failures: + raise RuntimeError("Ground Zero density/sightline verification failed: " + "; ".join(failures)) + + unreal.log( + "Ground Zero density/sightline verification complete: " + f"{len(CRITICAL_ACTOR_LABELS)} critical labels, " + f"{len(SIGHTLINE_PAIRS)} sightline corridor(s), " + f"{EXPECTED_FOLIAGE_COUNTS['trees']} trees, " + f"{EXPECTED_FOLIAGE_COUNTS['shrubs']} shrubs, " + f"{EXPECTED_FOLIAGE_COUNTS['grass']} grass clumps." + ) + + +main()