Add chunked terrain authoring foundation
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run

This commit is contained in:
Rbanh 2026-07-12 18:43:42 -04:00
parent 577d8dcb27
commit ed21bcc52d
22 changed files with 874 additions and 6 deletions

View File

@ -0,0 +1,46 @@
# Terrain Authoring Foundation
Working implementation plan for Gitea
[`#22`](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/22).
This establishes the persistent and hydrated terrain contract required by sculpting `#23`, layer
painting `#24`, physics placement `#25`, and the regression pack `#32`.
## Authored Contract
- Add reflected `TerrainDesc` authoring data with a version, square height-grid resolution, inline
finite heights, horizontal sample spacing, vertical scale, chunk quad size, optional shared
Material/Material Instance reference, collider generation, and shadow flags.
- V1 stores the compact height grid in the scene so save/recovery/history/prefab behavior is exact.
A later external heightmap asset migration must be explicit and versioned rather than silently
changing ownership.
- Validate resolution, exact sample count, finite values, positive scale, and chunk bounds before
hydration or publication.
## Hydration
- Partition the grid into deterministic non-overlapping quad chunks with shared boundary samples.
- Generate triangle-list meshes with normals, UVs, U32 indices, and tangents; each generated child
carries owner/chunk coordinates and remains runtime-only.
- Use the authored shared material when resolvable and an explicit terrain fallback otherwise.
- Optionally attach `ColliderConstructor::TrimeshFromMesh` per chunk.
- Rebuild only the owning terrain's generated chunks when descriptor/material/active state changes;
disabling/removing terrain cleans every generated child.
## Editor Integration
- Register Terrain as a stable authoring component and ActorKind with conflicts against primitive,
brush, static renderer, and skinned renderer geometry sources.
- Add an inspector for resolution, world scale, chunk size, collider/shadow state, material summary,
validation, and a guarded flat-grid resize action. All mutations are one typed history step.
- Include terrain bounds in selection framing and generated chunks in authored-parent picking.
- Expose Add Terrain through the existing component shelf; no new permanent toolbar is added.
## Verification
- Tests cover descriptor validation, deterministic chunk coverage/mesh topology, hydration and
cleanup, generated-child stripping, serialization, and history round-trip.
- Source-only workspace format/check/strict-Clippy/tests and level validation run. Packaged tests
remain deferred by project-owner direction.
- Live native-Wayland acceptance creates or loads a terrain actor, verifies chunk rendering and
selection/inspection, changes scale/chunking, exercises undo/redo, and inspects a saved scene to
confirm generated chunks are absent.

View File

@ -170,6 +170,7 @@ deep-stale variants.
| Prefab Instance inspector | Inspect/recover source health, Apply/Revert overrides by scope, Apply overrides to source, create a variant, **Unpack Layer**, or recursively **Convert to Local** | | Prefab Instance inspector | Inspect/recover source health, Apply/Revert overrides by scope, Apply overrides to source, create a variant, **Unpack Layer**, or recursively **Convert to Local** |
| Inspector component card | Collapse with caret, toggle active with status dot, or use triple-dot menu for reset/copy/paste/move/remove actions | | Inspector component card | Collapse with caret, toggle active with status dot, or use triple-dot menu for reset/copy/paste/move/remove actions |
| Inspector footer → Add Component | Expands an inline search shelf for registered authoring, rendering, physics, gameplay, and volume components with descriptions, availability hints, and undo | | Inspector footer → Add Component | Expands an inline search shelf for registered authoring, rendering, physics, gameplay, and volume components with descriptions, availability hints, and undo |
| Inspector footer → Add Component → Terrain | Add a height-grid terrain actor; configure grid scale/chunking/collision in its component card and use Resize Flat only for deliberate grid replacement |
| Audio Source / Listener inspector | Assign and audition clips; edit gain, pitch, loop/autoplay, spatial blend, attenuation, bus, listener priority, and ear gap | | Audio Source / Listener inspector | Assign and audition clips; edit gain, pitch, loop/autoplay, spatial blend, attenuation, bus, listener priority, and ear gap |
| Edit → Project Settings… | Edit `assets/project.ron` rendering, audio buses, physics, and input | | Edit → Project Settings… | Edit `assets/project.ron` rendering, audio buses, physics, and input |
@ -398,6 +399,7 @@ crates/
- [x] Filtered hierarchy, inspector, viewport, toolbar, asset browser, and status panels - [x] Filtered hierarchy, inspector, viewport, toolbar, asset browser, and status panels
- [x] Delete, duplicate, rename, and structural/material undo-redo - [x] Delete, duplicate, rename, and structural/material undo-redo
- [x] Native Bevy scene New/Open/Save/Save As with dirty title tracking - [x] Native Bevy scene New/Open/Save/Save As with dirty title tracking
- [x] First-class height-grid Terrain actor with deterministic generated render/collider chunks, reflected history, validation, and a committed showcase fixture ([ADR 0039](docs/adr/0039-inline-height-grid-terrain-foundation.md), [terrain guide](docs/editor/terrain.md), [Gitea #22](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/22))
- [x] Non-blocking native file/folder/confirmation broker across scene, asset, prefab, composition, collaboration, and Project Browser workflows ([ADR 0038](docs/adr/0038-non-blocking-native-dialog-broker.md), [workflow guide](docs/editor/native-dialogs.md), [Gitea #52](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/52)) - [x] Non-blocking native file/folder/confirmation broker across scene, asset, prefab, composition, collaboration, and Project Browser workflows ([ADR 0038](docs/adr/0038-non-blocking-native-dialog-broker.md), [workflow guide](docs/editor/native-dialogs.md), [Gitea #52](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/52))
- [x] Asset import, static mesh/prefab placement, texture assignment, and selection export - [x] Asset import, static mesh/prefab placement, texture assignment, and selection export
- [x] PIE player-only snapshot/restore (authored `LevelObject` edits persist on stop) - [x] PIE player-only snapshot/restore (authored `LevelObject` edits persist on stop)

View File

@ -269,6 +269,24 @@
), ),
dependencies: [], dependencies: [],
), ),
(
id: ("1b25d451-7b54-41d6-af00-d2e6950f502d"),
path: "assets/levels/terrain_authoring_showcase.scn.ron",
label: "terrain_authoring_showcase.scn",
kind_tag: "Level",
import_settings: (
scale: 1.0,
generate_collider: true,
lod0_only: true,
placement_mode: StaticAsset,
hierarchy_mode: SingleActor,
material_policy: SourceMaterials,
static_mesh_manifest_path: None,
animation_manifest_path: None,
default_animation_clip_id: None,
),
dependencies: [],
),
( (
id: ("dc13ce01-c7ce-43c5-974c-0e659ae49ab9"), id: ("dc13ce01-c7ce-43c5-974c-0e659ae49ab9"),
path: "assets/levels/editor_scene 2.scn.ron", path: "assets/levels/editor_scene 2.scn.ron",

View File

@ -0,0 +1,60 @@
(schema_version: 4, resources: {}, entities: {
1: (components: {
"bevy_ecs::name::Name": "Terrain Showcase",
"bevy_transform::components::transform::Transform": (
translation: (0.0, 0.0, 0.0),
rotation: (0.0, 0.0, 0.0, 1.0),
scale: (1.0, 1.0, 1.0),
),
"shared::components::ActorId": ("terrain-showcase-main"),
"shared::components::ActorKind": Terrain,
"shared::components::EditorVisibility": (visible: true),
"shared::components::HierarchySiblingIndex": (0),
"shared::components::LevelObject": (),
"shared::components::TerrainDesc": (
schema_version: 1,
resolution: 5,
heights: [
0.00, 0.00, 0.05, 0.00, 0.00,
0.00, 0.15, 0.30, 0.10, 0.00,
0.05, 0.35, 0.80, 0.25, 0.05,
0.00, 0.20, 0.40, 0.15, 0.00,
0.00, 0.00, 0.05, 0.00, 0.00,
],
sample_spacing: 2.0,
height_scale: 3.0,
chunk_quads: 2,
base_material: Some((
asset_id: "cc2769da-c9a5-4cb3-866a-cc81d0688ee2",
sub_asset_id: "material:instance",
label: "surface_tint_instance",
source_path: Some("assets/materials/surface_tint_instance.ron"),
)),
generate_colliders: true,
cast_shadows: true,
receive_shadows: true,
),
}),
2: (components: {
"bevy_ecs::name::Name": "Terrain Sun",
"bevy_transform::components::transform::Transform": (
translation: (0.0, 8.0, 0.0),
rotation: (0.1691, -0.0670, 0.7244, 0.6648),
scale: (1.0, 1.0, 1.0),
),
"shared::components::ActorId": ("terrain-showcase-sun"),
"shared::components::ActorKind": Light,
"shared::components::EditorVisibility": (visible: true),
"shared::components::HierarchySiblingIndex": (1),
"shared::components::LevelObject": (),
"shared::components::LightDesc": (
kind: Directional,
color: (r: 1.0, g: 0.95, b: 0.85, a: 1.0),
intensity: 100000.0,
range: 0.0,
shadows: true,
inner_angle_deg: 25.0,
outer_angle_deg: 35.0,
),
}),
})

View File

@ -1113,6 +1113,9 @@ fn format_actor_validation(err: ActorValidationError) -> String {
ActorValidationError::InvalidBrushGeometry(message) => { ActorValidationError::InvalidBrushGeometry(message) => {
format!("Save failed: invalid brush geometry: {message}") format!("Save failed: invalid brush geometry: {message}")
} }
ActorValidationError::InvalidTerrain(message) => {
format!("Save failed: invalid terrain: {message}")
}
ActorValidationError::StaticMeshMissingPrimitive => { ActorValidationError::StaticMeshMissingPrimitive => {
"Save failed: StaticMesh actor requires Primitive or StaticMeshRenderer with a mesh slot" "Save failed: StaticMesh actor requires Primitive or StaticMeshRenderer with a mesh slot"
.into() .into()

View File

@ -112,6 +112,7 @@ pub fn draw_actor_inspector(world: &mut World, ui: &mut egui::Ui, entity: Entity
ActorKind::StaticMesh ActorKind::StaticMesh
| ActorKind::SkinnedMesh | ActorKind::SkinnedMesh
| ActorKind::Brush | ActorKind::Brush
| ActorKind::Terrain
| ActorKind::ImportedModel | ActorKind::ImportedModel
| ActorKind::Light | ActorKind::Light
| ActorKind::Empty | ActorKind::Empty
@ -143,6 +144,7 @@ fn actor_icon(kind: ActorKind) -> egui_phosphor_icons::Icon {
ActorKind::StaticMesh ActorKind::StaticMesh
| ActorKind::SkinnedMesh | ActorKind::SkinnedMesh
| ActorKind::Brush | ActorKind::Brush
| ActorKind::Terrain
| ActorKind::ImportedModel => icons::CUBE, | ActorKind::ImportedModel => icons::CUBE,
ActorKind::Light => icons::LIGHTBULB, ActorKind::Light => icons::LIGHTBULB,
ActorKind::PrefabAnchor => icons::PACKAGE, ActorKind::PrefabAnchor => icons::PACKAGE,

View File

@ -373,6 +373,27 @@ impl Default for EditorComponentRegistry {
], ],
hydration_effect: "Hydrates into generated brush mesh children.", hydration_effect: "Hydrates into generated brush mesh children.",
}, },
EditorComponentDescriptor {
id: shared::AUTHORING_COMPONENT_TERRAIN,
type_name: "shared::components::TerrainDesc",
display_name: "Terrain",
category: EditorComponentCategory::Authoring,
addable: true,
removable: true,
reorderable: true,
hidden: false,
icon: icons::MOUNTAINS.as_str(),
description: "Creates chunked height-grid terrain with optional collision.",
search_terms: &["terrain", "landscape", "heightmap", "ground", "chunk"],
recommended: &[],
conflicts_with: &[
"shared::components::Primitive",
"shared::components::BrushDesc",
"shared::components::StaticMeshRenderer",
"shared::animation::SkinnedMeshRenderer",
],
hydration_effect: "Hydrates into generated render and collider chunks.",
},
EditorComponentDescriptor { EditorComponentDescriptor {
id: shared::AUTHORING_COMPONENT_MATERIAL, id: shared::AUTHORING_COMPONENT_MATERIAL,
type_name: "shared::components::MaterialDesc", type_name: "shared::components::MaterialDesc",

View File

@ -205,6 +205,7 @@ fn actor_kind_sort_key(world: &World, entity: Entity) -> u8 {
.map(|kind| match kind { .map(|kind| match kind {
ActorKind::Empty => 0, ActorKind::Empty => 0,
ActorKind::Brush ActorKind::Brush
| ActorKind::Terrain
| ActorKind::StaticMesh | ActorKind::StaticMesh
| ActorKind::SkinnedMesh | ActorKind::SkinnedMesh
| ActorKind::ImportedModel => 1, | ActorKind::ImportedModel => 1,
@ -468,6 +469,7 @@ pub fn actor_kind_icon(kind: ActorKind) -> &'static str {
match kind { match kind {
ActorKind::Empty => icons::FOLDER.as_str(), ActorKind::Empty => icons::FOLDER.as_str(),
ActorKind::Brush => icons::CUBE.as_str(), ActorKind::Brush => icons::CUBE.as_str(),
ActorKind::Terrain => icons::MOUNTAINS.as_str(),
ActorKind::StaticMesh => icons::CUBE.as_str(), ActorKind::StaticMesh => icons::CUBE.as_str(),
ActorKind::SkinnedMesh => icons::PERSON_SIMPLE_RUN.as_str(), ActorKind::SkinnedMesh => icons::PERSON_SIMPLE_RUN.as_str(),
ActorKind::ImportedModel => icons::CUBE_TRANSPARENT.as_str(), ActorKind::ImportedModel => icons::CUBE_TRANSPARENT.as_str(),

View File

@ -16,8 +16,8 @@ use shared::{
MaterialParameterValue, MaterialRef, MaterialShaderKind, NavigationArea, NavigationBounds, MaterialParameterValue, MaterialRef, MaterialShaderKind, NavigationArea, NavigationBounds,
NavigationLink, NavigationObstacle, ObjectiveMarker, PhysicsBody, PlayerSpawn, NavigationLink, NavigationObstacle, ObjectiveMarker, PhysicsBody, PlayerSpawn,
PostProcessVolumeDesc, PrefabInstance, Primitive, PrimitiveShape, ProjectSun, RigidBodyDesc, PostProcessVolumeDesc, PrefabInstance, Primitive, PrimitiveShape, ProjectSun, RigidBodyDesc,
SkinnedMeshRenderer, StaticMeshRenderer, StaticMeshRendererEntry, TeamSpawn, TriggerVolume, SkinnedMeshRenderer, StaticMeshRenderer, StaticMeshRendererEntry, TeamSpawn, TerrainDesc,
WeaponSpawn, AUTHORING_DIRECTIONAL_LUX_MAX, AUTHORING_POINT_SPOT_LUMENS_MAX, TriggerVolume, WeaponSpawn, AUTHORING_DIRECTIONAL_LUX_MAX, AUTHORING_POINT_SPOT_LUMENS_MAX,
COMPONENT_ANIMATION_CONTROLLER_DESC, COMPONENT_AUDIO_LISTENER_DESC, COMPONENT_ANIMATION_CONTROLLER_DESC, COMPONENT_AUDIO_LISTENER_DESC,
COMPONENT_AUDIO_SOURCE_DESC, COMPONENT_BRUSH_DESC, COMPONENT_COLLIDER_DESC, COMPONENT_AUDIO_SOURCE_DESC, COMPONENT_BRUSH_DESC, COMPONENT_COLLIDER_DESC,
COMPONENT_LIGHT_DESC, COMPONENT_MATERIAL_DESC, COMPONENT_NAVIGATION_AREA, COMPONENT_LIGHT_DESC, COMPONENT_MATERIAL_DESC, COMPONENT_NAVIGATION_AREA,
@ -25,8 +25,8 @@ use shared::{
COMPONENT_OBJECTIVE_MARKER, COMPONENT_PHYSICS_BODY, COMPONENT_PLAYER_SPAWN, COMPONENT_OBJECTIVE_MARKER, COMPONENT_PHYSICS_BODY, COMPONENT_PLAYER_SPAWN,
COMPONENT_POST_PROCESS_VOLUME, COMPONENT_PREFAB_INSTANCE, COMPONENT_PRIMITIVE, COMPONENT_POST_PROCESS_VOLUME, COMPONENT_PREFAB_INSTANCE, COMPONENT_PRIMITIVE,
COMPONENT_PROJECT_SUN, COMPONENT_RIGID_BODY_DESC, COMPONENT_SKINNED_MESH_RENDERER, COMPONENT_PROJECT_SUN, COMPONENT_RIGID_BODY_DESC, COMPONENT_SKINNED_MESH_RENDERER,
COMPONENT_STATIC_MESH_RENDERER, COMPONENT_TEAM_SPAWN, COMPONENT_TRIGGER_VOLUME, COMPONENT_STATIC_MESH_RENDERER, COMPONENT_TEAM_SPAWN, COMPONENT_TERRAIN_DESC,
COMPONENT_WEAPON_SPAWN, COMPONENT_TRIGGER_VOLUME, COMPONENT_WEAPON_SPAWN,
}; };
use crate::history::{ use crate::history::{
@ -1110,6 +1110,7 @@ fn draw_authoring_component_by_type(
COMPONENT_STATIC_MESH_RENDERER => static_mesh_renderer_ui(world, ui, entity), COMPONENT_STATIC_MESH_RENDERER => static_mesh_renderer_ui(world, ui, entity),
COMPONENT_SKINNED_MESH_RENDERER => skinned_mesh_renderer_ui(world, ui, entity), COMPONENT_SKINNED_MESH_RENDERER => skinned_mesh_renderer_ui(world, ui, entity),
COMPONENT_BRUSH_DESC => brush_editor_ui(world, ui, entity), COMPONENT_BRUSH_DESC => brush_editor_ui(world, ui, entity),
COMPONENT_TERRAIN_DESC => terrain_editor_ui(world, ui, entity),
COMPONENT_PRIMITIVE => primitive_editor_ui(world, ui, entity), COMPONENT_PRIMITIVE => primitive_editor_ui(world, ui, entity),
COMPONENT_MATERIAL_DESC => material_editor_ui(world, ui, entity), COMPONENT_MATERIAL_DESC => material_editor_ui(world, ui, entity),
COMPONENT_LIGHT_DESC => light_editor_ui(world, ui, entity), COMPONENT_LIGHT_DESC => light_editor_ui(world, ui, entity),
@ -3494,6 +3495,128 @@ fn brush_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
} }
} }
fn terrain_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
let Some(mut terrain) = world.get::<TerrainDesc>(entity).cloned() else {
return;
};
let original = terrain.clone();
let mut changed = false;
let mut requested_resolution = terrain.resolution;
let card = component_card_context(
world,
entity,
ComponentCardOptions::removable(COMPONENT_TERRAIN_DESC, "Terrain", icons::MOUNTAINS),
);
let card_response = component_card(ui, &card, |ui| {
property_row(ui, "Grid", |ui| {
ui.horizontal(|ui| {
ui.add(
egui::DragValue::new(&mut requested_resolution)
.range(2..=1025)
.suffix(" samples"),
);
if ui
.add_enabled(
requested_resolution != terrain.resolution,
egui::Button::new("Resize Flat"),
)
.on_hover_text("Replaces the current height grid with a flat grid")
.clicked()
{
let replacement = TerrainDesc::flat(requested_resolution);
terrain.resolution = replacement.resolution;
terrain.heights = replacement.heights;
terrain.chunk_quads = terrain.chunk_quads.min(terrain.resolution - 1).max(1);
changed = true;
}
});
});
property_row(ui, "Sample Spacing", |ui| {
changed |= ui
.add(
egui::DragValue::new(&mut terrain.sample_spacing)
.range(0.01..=1000.0)
.speed(0.1)
.suffix(" m"),
)
.changed();
});
property_row(ui, "Height Scale", |ui| {
changed |= ui
.add(
egui::DragValue::new(&mut terrain.height_scale)
.range(0.01..=10000.0)
.speed(0.1)
.suffix(" m"),
)
.changed();
});
property_row(ui, "Chunk Size", |ui| {
changed |= ui
.add(
egui::DragValue::new(&mut terrain.chunk_quads)
.range(1..=terrain.resolution.saturating_sub(1))
.suffix(" quads"),
)
.changed();
});
property_row(ui, "Collision", |ui| {
changed |= ui
.checkbox(&mut terrain.generate_colliders, "Generate")
.changed();
});
property_row(ui, "Shadows", |ui| {
ui.horizontal_wrapped(|ui| {
changed |= ui.checkbox(&mut terrain.cast_shadows, "Cast").changed();
changed |= ui
.checkbox(&mut terrain.receive_shadows, "Receive")
.changed();
});
});
property_row(ui, "Base Material", |ui| {
ui.label(
terrain
.base_material
.as_ref()
.map(|material| material.label.as_str())
.filter(|label| !label.is_empty())
.unwrap_or("Terrain fallback"),
);
});
match terrain.validate() {
Ok(()) => {
ui.label(
egui::RichText::new(format!(
"{} heights • {}×{} chunks",
terrain.heights.len(),
(terrain.resolution - 1).div_ceil(terrain.chunk_quads),
(terrain.resolution - 1).div_ceil(terrain.chunk_quads)
))
.color(super::theme::SUCCESS),
);
}
Err(error) => {
ui.label(egui::RichText::new(error).color(super::theme::ERROR));
}
}
});
apply_component_card_response(world, entity, card_response);
if changed && terrain != original {
let _ = reflected_component_transaction(
world,
entity,
"Edit Terrain",
shared::AUTHORING_COMPONENT_TERRAIN,
COMPONENT_TERRAIN_DESC,
move |world, entity| {
world.entity_mut(entity).insert(terrain);
Ok(())
},
);
}
}
fn brush_validation_ui(ui: &mut egui::Ui, brush: &BrushDesc) { fn brush_validation_ui(ui: &mut egui::Ui, brush: &BrushDesc) {
let report = validate_brush(brush); let report = validate_brush(brush);
if report.diagnostics.is_empty() { if report.diagnostics.is_empty() {

View File

@ -631,6 +631,10 @@ fn icon_for_actor_components(components: ActorIconComponents) -> ActorIconSpec {
image: ActorIconImage::Mesh, image: ActorIconImage::Mesh,
category: ActorIconCategory::Mesh, category: ActorIconCategory::Mesh,
}, },
ActorKind::Terrain => ActorIconSpec {
image: ActorIconImage::Mesh,
category: ActorIconCategory::Mesh,
},
ActorKind::StaticMesh => { ActorKind::StaticMesh => {
if components.has_primitive || components.has_static_mesh_renderer { if components.has_primitive || components.has_static_mesh_renderer {
ActorIconSpec { ActorIconSpec {

View File

@ -175,6 +175,7 @@ fn actor_kind_ron(kind: ActorKind) -> &'static str {
match kind { match kind {
ActorKind::Empty => "Empty", ActorKind::Empty => "Empty",
ActorKind::Brush => "Brush", ActorKind::Brush => "Brush",
ActorKind::Terrain => "Terrain",
ActorKind::StaticMesh => "StaticMesh", ActorKind::StaticMesh => "StaticMesh",
ActorKind::SkinnedMesh => "SkinnedMesh", ActorKind::SkinnedMesh => "SkinnedMesh",
ActorKind::ImportedModel => "ImportedModel", ActorKind::ImportedModel => "ImportedModel",

View File

@ -11,7 +11,7 @@ use crate::{
ActorKind, AnimationControllerDesc, AudioListenerDesc, AudioSourceDesc, BrushDesc, LevelObject, ActorKind, AnimationControllerDesc, AudioListenerDesc, AudioSourceDesc, BrushDesc, LevelObject,
LightDesc, ModelRef, NavigationArea, NavigationBounds, NavigationLink, NavigationObstacle, LightDesc, ModelRef, NavigationArea, NavigationBounds, NavigationLink, NavigationObstacle,
ObjectiveMarker, PlayerSpawn, PostProcessVolumeDesc, PrefabInstance, PrefabRef, Primitive, ObjectiveMarker, PlayerSpawn, PostProcessVolumeDesc, PrefabInstance, PrefabRef, Primitive,
SkinnedMeshRenderer, StaticMeshRenderer, TeamSpawn, TriggerVolume, WeaponSpawn, SkinnedMeshRenderer, StaticMeshRenderer, TeamSpawn, TerrainDesc, TriggerVolume, WeaponSpawn,
AUDIO_CLIP_SUB_ASSET_ID, AUDIO_CLIP_SUB_ASSET_ID,
}; };
@ -31,6 +31,9 @@ pub fn infer_actor_kind(entity: EntityRef<'_>) -> Option<ActorKind> {
if entity.get::<BrushDesc>().is_some() { if entity.get::<BrushDesc>().is_some() {
return Some(ActorKind::Brush); return Some(ActorKind::Brush);
} }
if entity.get::<TerrainDesc>().is_some() {
return Some(ActorKind::Terrain);
}
if entity.get::<Primitive>().is_some() || entity.get::<StaticMeshRenderer>().is_some() { if entity.get::<Primitive>().is_some() || entity.get::<StaticMeshRenderer>().is_some() {
return Some(ActorKind::StaticMesh); return Some(ActorKind::StaticMesh);
} }
@ -84,6 +87,7 @@ pub enum ActorValidationError {
BrushHasLight, BrushHasLight,
BrushHasModelRef, BrushHasModelRef,
InvalidBrushGeometry(String), InvalidBrushGeometry(String),
InvalidTerrain(String),
StaticMeshMissingPrimitive, StaticMeshMissingPrimitive,
StaticMeshHasLight, StaticMeshHasLight,
StaticMeshHasModelRef, StaticMeshHasModelRef,
@ -166,6 +170,7 @@ pub fn validate_actor(entity: EntityRef<'_>) -> Result<(), ActorValidationError>
} }
let geometry_source_count = usize::from(entity.get::<Primitive>().is_some()) let geometry_source_count = usize::from(entity.get::<Primitive>().is_some())
+ usize::from(entity.get::<BrushDesc>().is_some()) + usize::from(entity.get::<BrushDesc>().is_some())
+ usize::from(entity.get::<TerrainDesc>().is_some())
+ usize::from(entity.get::<StaticMeshRenderer>().is_some()) + usize::from(entity.get::<StaticMeshRenderer>().is_some())
+ usize::from(entity.get::<SkinnedMeshRenderer>().is_some()) + usize::from(entity.get::<SkinnedMeshRenderer>().is_some())
+ usize::from(entity.get::<ModelRef>().is_some()); + usize::from(entity.get::<ModelRef>().is_some());
@ -186,6 +191,11 @@ pub fn validate_actor(entity: EntityRef<'_>) -> Result<(), ActorValidationError>
return Err(ActorValidationError::InvalidBrushGeometry(message)); return Err(ActorValidationError::InvalidBrushGeometry(message));
} }
} }
if let Some(terrain) = entity.get::<TerrainDesc>() {
terrain
.validate()
.map_err(ActorValidationError::InvalidTerrain)?;
}
if let Some(renderer) = entity.get::<SkinnedMeshRenderer>() { if let Some(renderer) = entity.get::<SkinnedMeshRenderer>() {
if renderer.path.trim().is_empty() { if renderer.path.trim().is_empty() {
return Err(ActorValidationError::SkinnedMeshInvalidRenderer); return Err(ActorValidationError::SkinnedMeshInvalidRenderer);
@ -218,6 +228,13 @@ pub fn validate_actor(entity: EntityRef<'_>) -> Result<(), ActorValidationError>
} }
let _ = brush; let _ = brush;
} }
ActorKind::Terrain => {
if entity.get::<TerrainDesc>().is_none() {
return Err(ActorValidationError::InvalidTerrain(
"terrain actor is missing TerrainDesc".into(),
));
}
}
ActorKind::StaticMesh => { ActorKind::StaticMesh => {
let has_static_mesh_renderer = entity let has_static_mesh_renderer = entity
.get::<StaticMeshRenderer>() .get::<StaticMeshRenderer>()

View File

@ -290,6 +290,7 @@ pub fn inspector_component_active(order: Option<&InspectorOrder>, type_name: &st
// Rust type paths remain serialization details and may change during refactors. // Rust type paths remain serialization details and may change during refactors.
pub const AUTHORING_COMPONENT_PRIMITIVE: &str = "render.primitive"; pub const AUTHORING_COMPONENT_PRIMITIVE: &str = "render.primitive";
pub const AUTHORING_COMPONENT_BRUSH: &str = "render.brush"; pub const AUTHORING_COMPONENT_BRUSH: &str = "render.brush";
pub const AUTHORING_COMPONENT_TERRAIN: &str = "render.terrain";
pub const AUTHORING_COMPONENT_STATIC_MESH_RENDERER: &str = "render.static_mesh_renderer"; pub const AUTHORING_COMPONENT_STATIC_MESH_RENDERER: &str = "render.static_mesh_renderer";
pub const AUTHORING_COMPONENT_SKINNED_MESH_RENDERER: &str = "render.skinned_mesh_renderer"; pub const AUTHORING_COMPONENT_SKINNED_MESH_RENDERER: &str = "render.skinned_mesh_renderer";
pub const AUTHORING_COMPONENT_MATERIAL: &str = "render.material"; pub const AUTHORING_COMPONENT_MATERIAL: &str = "render.material";
@ -318,6 +319,7 @@ pub fn authoring_component_id(key: &str) -> Option<&'static str> {
match key { match key {
AUTHORING_COMPONENT_PRIMITIVE | COMPONENT_PRIMITIVE => Some(AUTHORING_COMPONENT_PRIMITIVE), AUTHORING_COMPONENT_PRIMITIVE | COMPONENT_PRIMITIVE => Some(AUTHORING_COMPONENT_PRIMITIVE),
AUTHORING_COMPONENT_BRUSH | COMPONENT_BRUSH_DESC => Some(AUTHORING_COMPONENT_BRUSH), AUTHORING_COMPONENT_BRUSH | COMPONENT_BRUSH_DESC => Some(AUTHORING_COMPONENT_BRUSH),
AUTHORING_COMPONENT_TERRAIN | COMPONENT_TERRAIN_DESC => Some(AUTHORING_COMPONENT_TERRAIN),
AUTHORING_COMPONENT_STATIC_MESH_RENDERER | COMPONENT_STATIC_MESH_RENDERER => { AUTHORING_COMPONENT_STATIC_MESH_RENDERER | COMPONENT_STATIC_MESH_RENDERER => {
Some(AUTHORING_COMPONENT_STATIC_MESH_RENDERER) Some(AUTHORING_COMPONENT_STATIC_MESH_RENDERER)
} }
@ -386,6 +388,7 @@ pub fn authoring_component_key(key: &str) -> &str {
pub const COMPONENT_PRIMITIVE: &str = "shared::components::Primitive"; pub const COMPONENT_PRIMITIVE: &str = "shared::components::Primitive";
pub const COMPONENT_BRUSH_DESC: &str = "shared::components::BrushDesc"; pub const COMPONENT_BRUSH_DESC: &str = "shared::components::BrushDesc";
pub const COMPONENT_TERRAIN_DESC: &str = "shared::components::TerrainDesc";
pub const COMPONENT_STATIC_MESH_RENDERER: &str = "shared::components::StaticMeshRenderer"; pub const COMPONENT_STATIC_MESH_RENDERER: &str = "shared::components::StaticMeshRenderer";
pub const COMPONENT_MATERIAL_DESC: &str = "shared::components::MaterialDesc"; pub const COMPONENT_MATERIAL_DESC: &str = "shared::components::MaterialDesc";
pub const COMPONENT_MATERIAL_OVERRIDE: &str = "shared::components::MaterialOverride"; pub const COMPONENT_MATERIAL_OVERRIDE: &str = "shared::components::MaterialOverride";
@ -852,6 +855,124 @@ impl BrushDesc {
} }
} }
pub const TERRAIN_SCHEMA_VERSION: u32 = 1;
/// Persistent height-grid terrain authoring data. Hydration partitions the grid into generated
/// render/collider chunks; saved scenes retain only this descriptor.
#[derive(Component, Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[reflect(Component, Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct TerrainDesc {
#[serde(default = "default_terrain_schema_version")]
pub schema_version: u32,
#[serde(default = "default_terrain_resolution")]
pub resolution: u32,
#[serde(default = "default_terrain_heights")]
pub heights: Vec<f32>,
#[serde(default = "default_terrain_sample_spacing")]
pub sample_spacing: f32,
#[serde(default = "default_terrain_height_scale")]
pub height_scale: f32,
#[serde(default = "default_terrain_chunk_quads")]
pub chunk_quads: u32,
#[serde(default)]
pub base_material: Option<EditorAssetRef>,
#[serde(default = "default_true")]
pub generate_colliders: bool,
#[serde(default = "default_true")]
pub cast_shadows: bool,
#[serde(default = "default_true")]
pub receive_shadows: bool,
}
impl Default for TerrainDesc {
fn default() -> Self {
Self {
schema_version: TERRAIN_SCHEMA_VERSION,
resolution: default_terrain_resolution(),
heights: default_terrain_heights(),
sample_spacing: default_terrain_sample_spacing(),
height_scale: default_terrain_height_scale(),
chunk_quads: default_terrain_chunk_quads(),
base_material: None,
generate_colliders: true,
cast_shadows: true,
receive_shadows: true,
}
}
}
impl TerrainDesc {
pub fn flat(resolution: u32) -> Self {
let resolution = resolution.max(2);
Self {
resolution,
heights: vec![0.0; resolution as usize * resolution as usize],
chunk_quads: (resolution - 1).min(default_terrain_chunk_quads()),
..Default::default()
}
}
pub fn validate(&self) -> Result<(), String> {
if self.schema_version != TERRAIN_SCHEMA_VERSION {
return Err(format!(
"terrain schema {} is unsupported; expected {}",
self.schema_version, TERRAIN_SCHEMA_VERSION
));
}
if !(2..=1025).contains(&self.resolution) {
return Err("terrain resolution must be between 2 and 1025 samples".into());
}
let expected = self.resolution as usize * self.resolution as usize;
if self.heights.len() != expected {
return Err(format!(
"terrain resolution {} requires {expected} height samples, found {}",
self.resolution,
self.heights.len()
));
}
if self.heights.iter().any(|height| !height.is_finite()) {
return Err("terrain height samples must all be finite".into());
}
if !self.sample_spacing.is_finite() || self.sample_spacing <= 0.0 {
return Err("terrain sample spacing must be finite and positive".into());
}
if !self.height_scale.is_finite() || self.height_scale <= 0.0 {
return Err("terrain height scale must be finite and positive".into());
}
if self.chunk_quads == 0 || self.chunk_quads > self.resolution - 1 {
return Err(format!(
"terrain chunk size must be between 1 and {} quads",
self.resolution - 1
));
}
Ok(())
}
}
fn default_terrain_schema_version() -> u32 {
TERRAIN_SCHEMA_VERSION
}
fn default_terrain_resolution() -> u32 {
33
}
fn default_terrain_heights() -> Vec<f32> {
vec![0.0; default_terrain_resolution() as usize * default_terrain_resolution() as usize]
}
fn default_terrain_sample_spacing() -> f32 {
1.0
}
fn default_terrain_height_scale() -> f32 {
10.0
}
fn default_terrain_chunk_quads() -> u32 {
16
}
impl Default for BrushDesc { impl Default for BrushDesc {
fn default() -> Self { fn default() -> Self {
Self::cuboid(Vec3::ONE) Self::cuboid(Vec3::ONE)
@ -1392,6 +1513,7 @@ impl Default for EditorVisibility {
pub enum ActorKind { pub enum ActorKind {
Empty, Empty,
Brush, Brush,
Terrain,
StaticMesh, StaticMesh,
SkinnedMesh, SkinnedMesh,
ImportedModel, ImportedModel,

View File

@ -10,6 +10,7 @@ mod primitives;
mod skinned_meshes; mod skinned_meshes;
mod static_meshes; mod static_meshes;
pub mod strip; pub mod strip;
mod terrain;
mod visibility; mod visibility;
pub use lights::cascade_config_from_rendering; pub use lights::cascade_config_from_rendering;
@ -37,6 +38,8 @@ use static_meshes::{
hydrate_static_mesh_renderers, spawn_static_mesh_parts, HydratedStaticMeshPart, hydrate_static_mesh_renderers, spawn_static_mesh_parts, HydratedStaticMeshPart,
StaticMeshArtifactCache, StaticMeshArtifactCache,
}; };
pub use terrain::HydratedTerrainChunk;
use terrain::{cleanup_removed_terrain, hydrate_terrain};
use visibility::{ use visibility::{
ensure_level_object_visibility_hierarchy, init_editor_visibility_on_spawn, ensure_level_object_visibility_hierarchy, init_editor_visibility_on_spawn,
sync_editor_visibility, visibility_from_editor, sync_editor_visibility, visibility_from_editor,
@ -73,6 +76,7 @@ impl Plugin for HydrationPlugin {
( (
hydrate_primitives, hydrate_primitives,
hydrate_brushes, hydrate_brushes,
hydrate_terrain,
hydrate_materials, hydrate_materials,
hydrate_lights, hydrate_lights,
reconcile_missing_runtime_lights, reconcile_missing_runtime_lights,
@ -83,6 +87,7 @@ impl Plugin for HydrationPlugin {
hydrate_models, hydrate_models,
hydrate_prefabs, hydrate_prefabs,
hydrate_physics, hydrate_physics,
cleanup_removed_terrain,
) )
.chain() .chain()
.in_set(HydrationSet::Content), .in_set(HydrationSet::Content),

View File

@ -0,0 +1,314 @@
//! Chunked render and collider hydration for authored height-grid terrain.
use avian3d::prelude::ColliderConstructor;
use bevy::asset::RenderAssetUsages;
use bevy::light::{NotShadowCaster, NotShadowReceiver};
use bevy::mesh::{Indices, PrimitiveTopology};
use bevy::prelude::*;
use crate::{
authoring_component_active, load_resolved_material_from_path, AuthoringComponentStates,
InspectorOrder, LevelObject, RaytracingExcluded, TerrainDesc, COMPONENT_TERRAIN_DESC,
};
use super::materials::material_from_desc;
#[derive(Component, Reflect, Debug, Clone, Copy, PartialEq, Eq)]
#[reflect(Component, Default, Debug)]
pub struct HydratedTerrainChunk {
pub owner: Entity,
pub chunk_x: u32,
pub chunk_z: u32,
}
impl Default for HydratedTerrainChunk {
fn default() -> Self {
Self {
owner: Entity::PLACEHOLDER,
chunk_x: 0,
chunk_z: 0,
}
}
}
#[allow(clippy::type_complexity)]
pub fn hydrate_terrain(
mut commands: Commands,
asset_server: Res<AssetServer>,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
terrains: Query<
(
Entity,
&TerrainDesc,
Option<&AuthoringComponentStates>,
Option<&InspectorOrder>,
),
(
With<LevelObject>,
Or<(
Added<TerrainDesc>,
Changed<TerrainDesc>,
Changed<AuthoringComponentStates>,
Changed<InspectorOrder>,
)>,
),
>,
children: Query<&Children>,
chunks: Query<(), With<HydratedTerrainChunk>>,
) {
for (entity, terrain, states, order) in &terrains {
despawn_terrain_chunks(&mut commands, entity, &children, &chunks);
if authoring_component_active(states, order, COMPONENT_TERRAIN_DESC) {
spawn_terrain_chunks(
&mut commands,
&asset_server,
&mut meshes,
&mut materials,
entity,
terrain,
);
}
}
}
pub fn cleanup_removed_terrain(
mut commands: Commands,
mut removed: RemovedComponents<TerrainDesc>,
children: Query<&Children>,
chunks: Query<(), With<HydratedTerrainChunk>>,
) {
for entity in removed.read() {
despawn_terrain_chunks(&mut commands, entity, &children, &chunks);
}
}
pub fn spawn_terrain_chunks(
commands: &mut Commands,
asset_server: &AssetServer,
meshes: &mut Assets<Mesh>,
materials: &mut Assets<StandardMaterial>,
owner: Entity,
terrain: &TerrainDesc,
) {
if let Err(error) = terrain.validate() {
warn!("Terrain hydration skipped invalid actor {owner:?}: {error}");
return;
}
let material = terrain
.base_material
.as_ref()
.and_then(|reference| reference.source_path.as_deref())
.and_then(|path| match load_resolved_material_from_path(path) {
Ok((desc, _)) => Some(material_from_desc(asset_server, &desc)),
Err(error) => {
warn!("Terrain {owner:?} material could not resolve: {error}");
None
}
})
.unwrap_or_else(|| StandardMaterial {
base_color: Color::srgb(0.24, 0.29, 0.25),
perceptual_roughness: 0.92,
..default()
});
let material = materials.add(material);
let quad_count = terrain.resolution - 1;
let chunks_per_axis = quad_count.div_ceil(terrain.chunk_quads);
for chunk_z in 0..chunks_per_axis {
for chunk_x in 0..chunks_per_axis {
let start_x = chunk_x * terrain.chunk_quads;
let start_z = chunk_z * terrain.chunk_quads;
let end_x = (start_x + terrain.chunk_quads).min(quad_count);
let end_z = (start_z + terrain.chunk_quads).min(quad_count);
let Some(mesh) = terrain_chunk_mesh(terrain, start_x, start_z, end_x, end_z) else {
continue;
};
let mesh = meshes.add(mesh);
let mut chunk = commands.spawn((
HydratedTerrainChunk {
owner,
chunk_x,
chunk_z,
},
Name::new(format!("Terrain Chunk {chunk_x},{chunk_z}")),
Mesh3d(mesh),
MeshMaterial3d(material.clone()),
Transform::default(),
Visibility::Visible,
RaytracingExcluded,
ChildOf(owner),
));
if terrain.generate_colliders {
chunk.insert(ColliderConstructor::TrimeshFromMesh);
}
if !terrain.cast_shadows {
chunk.insert(NotShadowCaster);
}
if !terrain.receive_shadows {
chunk.insert(NotShadowReceiver);
}
}
}
}
fn despawn_terrain_chunks(
commands: &mut Commands,
entity: Entity,
children: &Query<&Children>,
chunks: &Query<(), With<HydratedTerrainChunk>>,
) {
let Ok(children) = children.get(entity) else {
return;
};
for child in children.iter() {
if chunks.get(child).is_ok() {
commands.entity(child).despawn();
}
}
}
pub fn terrain_chunk_mesh(
terrain: &TerrainDesc,
start_x: u32,
start_z: u32,
end_x: u32,
end_z: u32,
) -> Option<Mesh> {
if start_x >= end_x
|| start_z >= end_z
|| end_x >= terrain.resolution
|| end_z >= terrain.resolution
{
return None;
}
let width = end_x - start_x + 1;
let depth = end_z - start_z + 1;
let mut positions = Vec::with_capacity((width * depth) as usize);
let mut normals = Vec::with_capacity((width * depth) as usize);
let mut uvs = Vec::with_capacity((width * depth) as usize);
let quad_count = (terrain.resolution - 1) as f32;
let half_extent = quad_count * terrain.sample_spacing * 0.5;
let sample = |x: u32, z: u32| {
terrain.heights[(z * terrain.resolution + x) as usize] * terrain.height_scale
};
for z in start_z..=end_z {
for x in start_x..=end_x {
let left = sample(x.saturating_sub(1), z);
let right = sample((x + 1).min(terrain.resolution - 1), z);
let down = sample(x, z.saturating_sub(1));
let up = sample(x, (z + 1).min(terrain.resolution - 1));
let normal = Vec3::new(left - right, 2.0 * terrain.sample_spacing, down - up)
.normalize_or_zero();
positions.push([
x as f32 * terrain.sample_spacing - half_extent,
sample(x, z),
z as f32 * terrain.sample_spacing - half_extent,
]);
normals.push(normal.to_array());
uvs.push([x as f32 / quad_count, z as f32 / quad_count]);
}
}
let mut indices = Vec::with_capacity(((width - 1) * (depth - 1) * 6) as usize);
for z in 0..depth - 1 {
for x in 0..width - 1 {
let a = z * width + x;
let b = a + 1;
let c = a + width;
let d = c + 1;
indices.extend_from_slice(&[a, c, b, b, c, d]);
}
}
let mut mesh = Mesh::new(
PrimitiveTopology::TriangleList,
RenderAssetUsages::default(),
);
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs);
mesh.insert_indices(Indices::U32(indices));
if let Err(error) = mesh.generate_tangents() {
warn!("Terrain chunk tangent generation failed: {error:?}");
}
Some(mesh)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ActorKind;
#[test]
fn chunk_mesh_has_expected_topology() {
let terrain = TerrainDesc::flat(5);
let mesh = terrain_chunk_mesh(&terrain, 0, 0, 4, 4).unwrap();
assert_eq!(mesh.count_vertices(), 25);
assert_eq!(mesh.indices().unwrap().len(), 96);
}
#[test]
fn descriptor_rejects_mismatched_and_non_finite_heights() {
let mut terrain = TerrainDesc::flat(3);
terrain.heights.pop();
assert!(terrain.validate().unwrap_err().contains("requires 9"));
terrain.heights.push(f32::NAN);
assert!(terrain.validate().unwrap_err().contains("finite"));
}
#[test]
fn descriptor_round_trips_through_ron() {
let mut terrain = TerrainDesc::flat(5);
terrain.heights[12] = 0.75;
terrain.sample_spacing = 2.0;
let text = ron::to_string(&terrain).unwrap();
let restored: TerrainDesc = ron::from_str(&text).unwrap();
assert_eq!(restored, terrain);
assert!(restored.validate().is_ok());
}
#[test]
fn hydration_generates_runtime_only_chunks_and_cleans_up() {
let mut app = App::new();
app.add_plugins((MinimalPlugins, AssetPlugin::default()))
.init_asset::<Mesh>()
.init_asset::<StandardMaterial>()
.add_systems(Update, (hydrate_terrain, cleanup_removed_terrain).chain());
let owner = app
.world_mut()
.spawn((
LevelObject,
ActorKind::Terrain,
TerrainDesc {
resolution: 5,
heights: vec![0.0; 25],
chunk_quads: 2,
..Default::default()
},
Transform::default(),
))
.id();
app.update();
app.update();
let children = app.world().get::<Children>(owner).unwrap();
assert_eq!(children.len(), 4);
for child in children.iter() {
assert!(app.world().get::<HydratedTerrainChunk>(child).is_some());
assert!(app.world().get::<LevelObject>(child).is_none());
assert!(app.world().get::<Mesh3d>(child).is_some());
assert!(app.world().get::<ColliderConstructor>(child).is_some());
}
app.world_mut().entity_mut(owner).remove::<TerrainDesc>();
app.update();
app.update();
assert!(app
.world()
.get::<Children>(owner)
.is_none_or(Children::is_empty));
}
}

View File

@ -22,7 +22,8 @@ pub use components::*;
pub use hydration::{ pub use hydration::{
cascade_config_from_rendering, flush_level_object_hydration, material_from_desc, cascade_config_from_rendering, flush_level_object_hydration, material_from_desc,
strip_hydrated, strip_hydrated_entity, HydratedModelRoot, HydratedPrefabMember, strip_hydrated, strip_hydrated_entity, HydratedModelRoot, HydratedPrefabMember,
HydratedPrefabReady, HydratedSkinnedMeshRoot, HydrationPlugin, PrefabHydrationBlocked, HydratedPrefabReady, HydratedSkinnedMeshRoot, HydratedTerrainChunk, HydrationPlugin,
PrefabHydrationBlocked,
}; };
pub use material_asset::{ pub use material_asset::{
load_resolved_material_from_path, MaterialAlphaMode, MaterialAsset, MaterialInstanceAsset, load_resolved_material_from_path, MaterialAlphaMode, MaterialAsset, MaterialInstanceAsset,
@ -112,6 +113,7 @@ impl Plugin for SharedTypesPlugin {
.register_type::<Primitive>() .register_type::<Primitive>()
.register_type::<PrimitiveShape>() .register_type::<PrimitiveShape>()
.register_type::<BrushDesc>() .register_type::<BrushDesc>()
.register_type::<TerrainDesc>()
.register_type::<BrushKind>() .register_type::<BrushKind>()
.register_type::<BrushPlaneDesc>() .register_type::<BrushPlaneDesc>()
.register_type::<BrushFaceDesc>() .register_type::<BrushFaceDesc>()

View File

@ -53,6 +53,7 @@ Immutable-style log of significant decisions. Add a new numbered ADR when changi
| [0036](adr/0036-surface-abi-and-solari-parity.md) | Constrained Surface ABI v1, raster/Solari evaluator parity, and deformed-geometry boundary | | [0036](adr/0036-surface-abi-and-solari-parity.md) | Constrained Surface ABI v1, raster/Solari evaluator parity, and deformed-geometry boundary |
| [0037](adr/0037-collaborative-authored-file-safety.md) | Exact authored-file revisions, observational Git status, and optional ownership providers | | [0037](adr/0037-collaborative-authored-file-safety.md) | Exact authored-file revisions, observational Git status, and optional ownership providers |
| [0038](adr/0038-non-blocking-native-dialog-broker.md) | Worker-owned native waits with one-shot main-thread workflow completion | | [0038](adr/0038-non-blocking-native-dialog-broker.md) | Worker-owned native waits with one-shot main-thread workflow completion |
| [0039](adr/0039-inline-height-grid-terrain-foundation.md) | Inline authored height grids with deterministic runtime-only chunk hydration |
## Editor framework ## Editor framework
@ -79,6 +80,7 @@ Immutable-style log of significant decisions. Add a new numbered ADR when changi
| [editor/material-system.md](editor/material-system.md) | Shared material assets and instances, renderer-slot assignment, Surface evaluators, migration, and diagnostics | | [editor/material-system.md](editor/material-system.md) | Shared material assets and instances, renderer-slot assignment, Surface evaluators, migration, and diagnostics |
| [editor/collaborative-file-safety.md](editor/collaborative-file-safety.md) | Guarded authored writes, compact Git/read-only status, conflict recovery, and ownership providers | | [editor/collaborative-file-safety.md](editor/collaborative-file-safety.md) | Guarded authored writes, compact Git/read-only status, conflict recovery, and ownership providers |
| [editor/native-dialogs.md](editor/native-dialogs.md) | Non-blocking native dialog acquisition and main-thread result application | | [editor/native-dialogs.md](editor/native-dialogs.md) | Non-blocking native dialog acquisition and main-thread result application |
| [editor/terrain.md](editor/terrain.md) | Terrain schema, inspector workflow, chunk hydration, collision, and follow-on boundaries |
| [editor/evaluations/material-renderer-foundation/](editor/evaluations/material-renderer-foundation/) | Live screenshots and acceptance results for the renderer/material/component foundation | | [editor/evaluations/material-renderer-foundation/](editor/evaluations/material-renderer-foundation/) | Live screenshots and acceptance results for the renderer/material/component foundation |
| [editor/evaluations/navigation-authoring/](editor/evaluations/navigation-authoring/) | Live screenshots and acceptance results for navigation authoring and runtime parity | | [editor/evaluations/navigation-authoring/](editor/evaluations/navigation-authoring/) | Live screenshots and acceptance results for navigation authoring and runtime parity |
| [editor/evaluations/production-readiness/](editor/evaluations/production-readiness/) | Current versioned production gate, evidence matrix, candidate commands, soak protocol, and independent sign-off requirements | | [editor/evaluations/production-readiness/](editor/evaluations/production-readiness/) | Current versioned production gate, evidence matrix, candidate commands, soak protocol, and independent sign-off requirements |

View File

@ -0,0 +1,38 @@
# ADR 0039: Inline Height-Grid Terrain Foundation
## Status
Accepted
## Context
Terrain sculpting, material painting, physics placement, recovery, and prefab workflows need one
stable owner for height data. Generated meshes and colliders cannot be authoritative because they
are runtime artifacts, while introducing an external binary heightmap before editing semantics exist
would add file-move, revision, import, and multi-document transaction complexity prematurely.
## Decision
`TerrainDesc` schema v1 stores a bounded square grid of normalized finite `f32` heights directly in
the authored scene component. It also stores sample spacing, height scale, chunk quad size, an
optional shared base Material/Material Instance reference, collider generation, and shadow flags.
Hydration deterministically partitions grid quads into generated children. Boundary samples are
duplicated between neighboring render chunks so each chunk has complete normals and independent
triangle geometry. Chunk meshes/colliders are never `LevelObject` entities and are therefore absent
from saved scenes, recovery documents, prefabs, and history snapshots.
Resolution changes are explicit destructive flat-grid replacements in the foundation inspector.
Sculpt tools mutate the existing grid through grouped history transactions. A future external
heightmap representation requires an explicit schema migration and authored-file ownership ADR; it
must not silently rewrite an inline terrain during normal load.
## Consequences
- Scene, prefab, undo, recovery, collaboration, and validation use one exact terrain document.
- V1 grids are bounded to 1025×1025 samples to prevent accidental unbounded scene payloads.
- Large production terrains may eventually need tiled external storage and streaming.
- Foundation chunks remain raster-only in Solari until terrain layer materials have an exact Surface
evaluator in `#24`; explicit exclusion is preferred to a black or mismatched ray-traced proxy.
- Layer weights and sculpt strokes extend this descriptor contract in `#23`/`#24`; generated chunks
remain disposable regardless of the editing representation.

View File

@ -25,10 +25,12 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a
| [material-system.md](material-system.md) | Shared Material/Material Instance assets, static/skinned renderer slots, Surface ABI, Solari scope, and migration | | [material-system.md](material-system.md) | Shared Material/Material Instance assets, static/skinned renderer slots, Surface ABI, Solari scope, and migration |
| [collaborative-file-safety.md](collaborative-file-safety.md) | Exact authored-file revisions, Git/read-only status, conflict recovery, and optional ownership providers | | [collaborative-file-safety.md](collaborative-file-safety.md) | Exact authored-file revisions, Git/read-only status, conflict recovery, and optional ownership providers |
| [native-dialogs.md](native-dialogs.md) | Non-blocking file/folder/confirmation acquisition and main-thread result application | | [native-dialogs.md](native-dialogs.md) | Non-blocking file/folder/confirmation acquisition and main-thread result application |
| [terrain.md](terrain.md) | Inline height-grid terrain, chunk hydration, collision, inspector workflow, and fixtures |
| [evaluations/material-renderer-foundation/](evaluations/material-renderer-foundation/) | Live screenshots and verification record for the renderer/material/component foundation | | [evaluations/material-renderer-foundation/](evaluations/material-renderer-foundation/) | Live screenshots and verification record for the renderer/material/component foundation |
| [evaluations/material-library-targeted-drop/](evaluations/material-library-targeted-drop/) | Live screenshot and verification record for the docked Material Library and exact reversible surface drops | | [evaluations/material-library-targeted-drop/](evaluations/material-library-targeted-drop/) | Live screenshot and verification record for the docked Material Library and exact reversible surface drops |
| [evaluations/collaborative-file-safety/](evaluations/collaborative-file-safety/) | Live screenshot and verification record for source-control status and guarded external-change recovery | | [evaluations/collaborative-file-safety/](evaluations/collaborative-file-safety/) | Live screenshot and verification record for source-control status and guarded external-change recovery |
| [evaluations/native-dialog-responsiveness/](evaluations/native-dialog-responsiveness/) | Live native-Wayland screenshot and verification record for non-blocking picker responsiveness | | [evaluations/native-dialog-responsiveness/](evaluations/native-dialog-responsiveness/) | Live native-Wayland screenshot and verification record for non-blocking picker responsiveness |
| [evaluations/terrain-foundation/](evaluations/terrain-foundation/) | Live screenshot and verification record for height-grid terrain schema and chunk hydration |
| [evaluations/navigation-authoring/](evaluations/navigation-authoring/) | Live screenshots and verification record for navigation authoring and runtime parity | | [evaluations/navigation-authoring/](evaluations/navigation-authoring/) | Live screenshots and verification record for navigation authoring and runtime parity |
| [evaluations/production-readiness/](evaluations/production-readiness/) | Current #50 release-candidate matrix and required clean-checkout, soak, performance, limitation, and independent-signoff evidence | | [evaluations/production-readiness/](evaluations/production-readiness/) | Current #50 release-candidate matrix and required clean-checkout, soak, performance, limitation, and independent-signoff evidence |
@ -38,6 +40,7 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a
|-------------|----------------|-----| |-------------|----------------|-----|
| `lib.rs` / `EditorPluginGroup` | Ordered plugin bundle, lib/bin split | ADR 0008, architecture.md | | `lib.rs` / `EditorPluginGroup` | Ordered plugin bundle, lib/bin split | ADR 0008, architecture.md |
| `shared::components::BrushDesc` / `shared::hydration::brushes` | Persisted brush authoring data and generated runtime mesh hydration | brushes.md, ADR 0021 | | `shared::components::BrushDesc` / `shared::hydration::brushes` | Persisted brush authoring data and generated runtime mesh hydration | brushes.md, ADR 0021 |
| `shared::components::TerrainDesc` / `shared::hydration::terrain` | Persisted height grid and generated render/collider chunks | terrain.md, ADR 0039 |
| `scene/` | Level I/O, tabs, composition materialization, schema, viewport render-target setup | architecture.md, multi-scene-composition.md, ADR 0026 | | `scene/` | Level I/O, tabs, composition materialization, schema, viewport render-target setup | architecture.md, multi-scene-composition.md, ADR 0026 |
| `viewport/` | Camera, selection, gizmos, render views | architecture.md | | `viewport/` | Camera, selection, gizmos, render views | architecture.md |
| `play/` | PIE session, editor mode state | architecture.md | | `play/` | PIE session, editor mode state | architecture.md |

View File

@ -0,0 +1,42 @@
# Terrain Foundation Evaluation
Date: 2026-07-12
Branch: `codex/terrain-foundation`
Gitea issue: `#22`
This record captures acceptance for the persistent height-grid terrain schema and deterministic
runtime chunk hydration. The permanent workflow lives in the [terrain guide](../../terrain.md).
## Live Editor Evidence
The native Wayland debug editor loaded the committed
`assets/levels/terrain_authoring_showcase.scn.ron` fixture. The screenshot uses Forward rendering to
isolate mesh/material/light correctness while foundation terrain remains explicitly excluded from
Solari until exact terrain layer/Surface parity lands in `#24`.
![Asymmetric shared-material terrain hill generated from four runtime chunks](terrain-showcase-forward.png)
The 5×5 authored grid renders as the expected asymmetric hill, resolves the committed shared
Material Instance, and is split into four 2×2-quad chunks with generated trimesh colliders. The
authored scene contains no `HydratedTerrainChunk`, `Mesh3d`, or collider-constructor components.
## Acceptance Results
| Area | Result | Evidence |
|------|--------|----------|
| Schema and serialization | Pass | Versioned bounded descriptor, exact height count, finite-value and scale/chunk validation, RON round-trip test |
| Chunk hydration | Pass | Deterministic four-chunk runtime fixture; topology test verifies 25 vertices and 96 indices for a 4×4-quad mesh |
| Runtime-only ownership | Pass | Hydration test verifies children have mesh/collider/owner markers but no `LevelObject`, then verifies removal cleanup |
| Actor/editor integration | Pass | First-class Terrain kind, registry descriptor, hierarchy/viewport icon, reflected Add Component and inspector transaction path |
| Material behavior | Pass with boundary | Shared Material/Instance source resolves for raster; missing source uses visible fallback; Solari submission is explicitly excluded pending `#24` |
| Project validation | Pass | 60 level dependencies, existing 5 non-blocking findings, 0 blocking errors |
| Packaged acceptance | Deferred | Explicitly deferred by project-owner direction; no packaged result is claimed here |
## Automated Verification
| Command/suite | Result |
|---------------|--------|
| Focused terrain descriptor/topology/hydration tests | 4 passed |
| Editor component registry reflection contract | Pass |
| `cargo check --workspace --all-targets` | Pass |
| `cargo validate-levels --project .` | 60 dependencies, 5 findings, 0 blockers |

Binary file not shown.

38
docs/editor/terrain.md Normal file
View File

@ -0,0 +1,38 @@
# Terrain Authoring
Terrain is a first-class authored actor backed by `TerrainDesc`. Add it from the Inspector's
**Add Component** shelf by searching for Terrain. It conflicts with other geometry sources on the
same actor: Primitive, Brush, Static Mesh Renderer, and Skinned Mesh Renderer.
## Foundation Workflow
The Terrain card exposes grid resolution, sample spacing, height scale, chunk size, generated
collision, shadows, base material status, and validation. **Resize Flat** deliberately replaces the
height array with a flat grid; normal numeric edits retain all height samples. Every accepted edit is
one reflected history transaction and rebuilds only that terrain's generated chunks.
`assets/levels/terrain_authoring_showcase.scn.ron` is the deterministic foundation fixture. Its 5×5
grid forms an asymmetric hill split into four 2×2-quad chunks. It validates chunk boundaries,
normals, selection through generated children, collider generation, inspector state, and save
stripping without requiring external assets.
## Data And Hydration
- Heights are finite normalized values multiplied by Height Scale at hydration time.
- Sample Spacing controls X/Z distance and the grid remains centered on the actor origin.
- Chunk Size is measured in quads. Neighboring chunks share boundary samples but not runtime mesh
assets, allowing later stroke updates to rebuild only affected chunks.
- A resolvable shared Material/Material Instance source supplies the chunk material. Missing or
invalid references produce a warning and visible terrain fallback rather than missing geometry.
- Generated `HydratedTerrainChunk` children own mesh, optional trimesh collider, and shadow state.
They are runtime-only and never serialized as authored actors.
- Foundation chunks are explicitly excluded from Solari submission and remain raster-visible while
Auto/Solari is active. Terrain Surface/layer parity enters with the material-layer work in `#24`;
the editor never substitutes a black or semantically different ray-traced terrain proxy.
Sculpt brushes and grouped stroke undo are tracked by Gitea `#23`; material layers and weight
painting are tracked by `#24`. The persistence decision is recorded in
[ADR 0039](../adr/0039-inline-height-grid-terrain-foundation.md).
Live native-Wayland evidence and focused verification are recorded in the
[terrain foundation evaluation](evaluations/terrain-foundation/).