Tune Ground Zero foliage sightlines

This commit is contained in:
2026-05-19 11:07:46 -07:00
parent d0c1e22d98
commit b2e315a510
5 changed files with 339 additions and 15 deletions
+1 -1
View File
@@ -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.
Binary file not shown.
@@ -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.
+103 -12
View File
@@ -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:
@@ -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()