Add chunked terrain authoring foundation
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
This commit is contained in:
parent
577d8dcb27
commit
ed21bcc52d
@ -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.
|
||||
@ -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** |
|
||||
| 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 → 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 |
|
||||
| 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] Delete, duplicate, rename, and structural/material undo-redo
|
||||
- [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] Asset import, static mesh/prefab placement, texture assignment, and selection export
|
||||
- [x] PIE player-only snapshot/restore (authored `LevelObject` edits persist on stop)
|
||||
|
||||
@ -269,6 +269,24 @@
|
||||
),
|
||||
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"),
|
||||
path: "assets/levels/editor_scene 2.scn.ron",
|
||||
|
||||
60
assets/levels/terrain_authoring_showcase.scn.ron
Normal file
60
assets/levels/terrain_authoring_showcase.scn.ron
Normal 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,
|
||||
),
|
||||
}),
|
||||
})
|
||||
@ -1113,6 +1113,9 @@ fn format_actor_validation(err: ActorValidationError) -> String {
|
||||
ActorValidationError::InvalidBrushGeometry(message) => {
|
||||
format!("Save failed: invalid brush geometry: {message}")
|
||||
}
|
||||
ActorValidationError::InvalidTerrain(message) => {
|
||||
format!("Save failed: invalid terrain: {message}")
|
||||
}
|
||||
ActorValidationError::StaticMeshMissingPrimitive => {
|
||||
"Save failed: StaticMesh actor requires Primitive or StaticMeshRenderer with a mesh slot"
|
||||
.into()
|
||||
|
||||
@ -112,6 +112,7 @@ pub fn draw_actor_inspector(world: &mut World, ui: &mut egui::Ui, entity: Entity
|
||||
ActorKind::StaticMesh
|
||||
| ActorKind::SkinnedMesh
|
||||
| ActorKind::Brush
|
||||
| ActorKind::Terrain
|
||||
| ActorKind::ImportedModel
|
||||
| ActorKind::Light
|
||||
| ActorKind::Empty
|
||||
@ -143,6 +144,7 @@ fn actor_icon(kind: ActorKind) -> egui_phosphor_icons::Icon {
|
||||
ActorKind::StaticMesh
|
||||
| ActorKind::SkinnedMesh
|
||||
| ActorKind::Brush
|
||||
| ActorKind::Terrain
|
||||
| ActorKind::ImportedModel => icons::CUBE,
|
||||
ActorKind::Light => icons::LIGHTBULB,
|
||||
ActorKind::PrefabAnchor => icons::PACKAGE,
|
||||
|
||||
@ -373,6 +373,27 @@ impl Default for EditorComponentRegistry {
|
||||
],
|
||||
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 {
|
||||
id: shared::AUTHORING_COMPONENT_MATERIAL,
|
||||
type_name: "shared::components::MaterialDesc",
|
||||
|
||||
@ -205,6 +205,7 @@ fn actor_kind_sort_key(world: &World, entity: Entity) -> u8 {
|
||||
.map(|kind| match kind {
|
||||
ActorKind::Empty => 0,
|
||||
ActorKind::Brush
|
||||
| ActorKind::Terrain
|
||||
| ActorKind::StaticMesh
|
||||
| ActorKind::SkinnedMesh
|
||||
| ActorKind::ImportedModel => 1,
|
||||
@ -468,6 +469,7 @@ pub fn actor_kind_icon(kind: ActorKind) -> &'static str {
|
||||
match kind {
|
||||
ActorKind::Empty => icons::FOLDER.as_str(),
|
||||
ActorKind::Brush => icons::CUBE.as_str(),
|
||||
ActorKind::Terrain => icons::MOUNTAINS.as_str(),
|
||||
ActorKind::StaticMesh => icons::CUBE.as_str(),
|
||||
ActorKind::SkinnedMesh => icons::PERSON_SIMPLE_RUN.as_str(),
|
||||
ActorKind::ImportedModel => icons::CUBE_TRANSPARENT.as_str(),
|
||||
|
||||
@ -16,8 +16,8 @@ use shared::{
|
||||
MaterialParameterValue, MaterialRef, MaterialShaderKind, NavigationArea, NavigationBounds,
|
||||
NavigationLink, NavigationObstacle, ObjectiveMarker, PhysicsBody, PlayerSpawn,
|
||||
PostProcessVolumeDesc, PrefabInstance, Primitive, PrimitiveShape, ProjectSun, RigidBodyDesc,
|
||||
SkinnedMeshRenderer, StaticMeshRenderer, StaticMeshRendererEntry, TeamSpawn, TriggerVolume,
|
||||
WeaponSpawn, AUTHORING_DIRECTIONAL_LUX_MAX, AUTHORING_POINT_SPOT_LUMENS_MAX,
|
||||
SkinnedMeshRenderer, StaticMeshRenderer, StaticMeshRendererEntry, TeamSpawn, TerrainDesc,
|
||||
TriggerVolume, WeaponSpawn, AUTHORING_DIRECTIONAL_LUX_MAX, AUTHORING_POINT_SPOT_LUMENS_MAX,
|
||||
COMPONENT_ANIMATION_CONTROLLER_DESC, COMPONENT_AUDIO_LISTENER_DESC,
|
||||
COMPONENT_AUDIO_SOURCE_DESC, COMPONENT_BRUSH_DESC, COMPONENT_COLLIDER_DESC,
|
||||
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_POST_PROCESS_VOLUME, COMPONENT_PREFAB_INSTANCE, COMPONENT_PRIMITIVE,
|
||||
COMPONENT_PROJECT_SUN, COMPONENT_RIGID_BODY_DESC, COMPONENT_SKINNED_MESH_RENDERER,
|
||||
COMPONENT_STATIC_MESH_RENDERER, COMPONENT_TEAM_SPAWN, COMPONENT_TRIGGER_VOLUME,
|
||||
COMPONENT_WEAPON_SPAWN,
|
||||
COMPONENT_STATIC_MESH_RENDERER, COMPONENT_TEAM_SPAWN, COMPONENT_TERRAIN_DESC,
|
||||
COMPONENT_TRIGGER_VOLUME, COMPONENT_WEAPON_SPAWN,
|
||||
};
|
||||
|
||||
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_SKINNED_MESH_RENDERER => skinned_mesh_renderer_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_MATERIAL_DESC => material_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) {
|
||||
let report = validate_brush(brush);
|
||||
if report.diagnostics.is_empty() {
|
||||
|
||||
@ -631,6 +631,10 @@ fn icon_for_actor_components(components: ActorIconComponents) -> ActorIconSpec {
|
||||
image: ActorIconImage::Mesh,
|
||||
category: ActorIconCategory::Mesh,
|
||||
},
|
||||
ActorKind::Terrain => ActorIconSpec {
|
||||
image: ActorIconImage::Mesh,
|
||||
category: ActorIconCategory::Mesh,
|
||||
},
|
||||
ActorKind::StaticMesh => {
|
||||
if components.has_primitive || components.has_static_mesh_renderer {
|
||||
ActorIconSpec {
|
||||
|
||||
@ -175,6 +175,7 @@ fn actor_kind_ron(kind: ActorKind) -> &'static str {
|
||||
match kind {
|
||||
ActorKind::Empty => "Empty",
|
||||
ActorKind::Brush => "Brush",
|
||||
ActorKind::Terrain => "Terrain",
|
||||
ActorKind::StaticMesh => "StaticMesh",
|
||||
ActorKind::SkinnedMesh => "SkinnedMesh",
|
||||
ActorKind::ImportedModel => "ImportedModel",
|
||||
|
||||
@ -11,7 +11,7 @@ use crate::{
|
||||
ActorKind, AnimationControllerDesc, AudioListenerDesc, AudioSourceDesc, BrushDesc, LevelObject,
|
||||
LightDesc, ModelRef, NavigationArea, NavigationBounds, NavigationLink, NavigationObstacle,
|
||||
ObjectiveMarker, PlayerSpawn, PostProcessVolumeDesc, PrefabInstance, PrefabRef, Primitive,
|
||||
SkinnedMeshRenderer, StaticMeshRenderer, TeamSpawn, TriggerVolume, WeaponSpawn,
|
||||
SkinnedMeshRenderer, StaticMeshRenderer, TeamSpawn, TerrainDesc, TriggerVolume, WeaponSpawn,
|
||||
AUDIO_CLIP_SUB_ASSET_ID,
|
||||
};
|
||||
|
||||
@ -31,6 +31,9 @@ pub fn infer_actor_kind(entity: EntityRef<'_>) -> Option<ActorKind> {
|
||||
if entity.get::<BrushDesc>().is_some() {
|
||||
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() {
|
||||
return Some(ActorKind::StaticMesh);
|
||||
}
|
||||
@ -84,6 +87,7 @@ pub enum ActorValidationError {
|
||||
BrushHasLight,
|
||||
BrushHasModelRef,
|
||||
InvalidBrushGeometry(String),
|
||||
InvalidTerrain(String),
|
||||
StaticMeshMissingPrimitive,
|
||||
StaticMeshHasLight,
|
||||
StaticMeshHasModelRef,
|
||||
@ -166,6 +170,7 @@ pub fn validate_actor(entity: EntityRef<'_>) -> Result<(), ActorValidationError>
|
||||
}
|
||||
let geometry_source_count = usize::from(entity.get::<Primitive>().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::<SkinnedMeshRenderer>().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));
|
||||
}
|
||||
}
|
||||
if let Some(terrain) = entity.get::<TerrainDesc>() {
|
||||
terrain
|
||||
.validate()
|
||||
.map_err(ActorValidationError::InvalidTerrain)?;
|
||||
}
|
||||
if let Some(renderer) = entity.get::<SkinnedMeshRenderer>() {
|
||||
if renderer.path.trim().is_empty() {
|
||||
return Err(ActorValidationError::SkinnedMeshInvalidRenderer);
|
||||
@ -218,6 +228,13 @@ pub fn validate_actor(entity: EntityRef<'_>) -> Result<(), ActorValidationError>
|
||||
}
|
||||
let _ = brush;
|
||||
}
|
||||
ActorKind::Terrain => {
|
||||
if entity.get::<TerrainDesc>().is_none() {
|
||||
return Err(ActorValidationError::InvalidTerrain(
|
||||
"terrain actor is missing TerrainDesc".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
ActorKind::StaticMesh => {
|
||||
let has_static_mesh_renderer = entity
|
||||
.get::<StaticMeshRenderer>()
|
||||
|
||||
@ -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.
|
||||
pub const AUTHORING_COMPONENT_PRIMITIVE: &str = "render.primitive";
|
||||
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_SKINNED_MESH_RENDERER: &str = "render.skinned_mesh_renderer";
|
||||
pub const AUTHORING_COMPONENT_MATERIAL: &str = "render.material";
|
||||
@ -318,6 +319,7 @@ pub fn authoring_component_id(key: &str) -> Option<&'static str> {
|
||||
match key {
|
||||
AUTHORING_COMPONENT_PRIMITIVE | COMPONENT_PRIMITIVE => Some(AUTHORING_COMPONENT_PRIMITIVE),
|
||||
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 => {
|
||||
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_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_MATERIAL_DESC: &str = "shared::components::MaterialDesc";
|
||||
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 {
|
||||
fn default() -> Self {
|
||||
Self::cuboid(Vec3::ONE)
|
||||
@ -1392,6 +1513,7 @@ impl Default for EditorVisibility {
|
||||
pub enum ActorKind {
|
||||
Empty,
|
||||
Brush,
|
||||
Terrain,
|
||||
StaticMesh,
|
||||
SkinnedMesh,
|
||||
ImportedModel,
|
||||
|
||||
@ -10,6 +10,7 @@ mod primitives;
|
||||
mod skinned_meshes;
|
||||
mod static_meshes;
|
||||
pub mod strip;
|
||||
mod terrain;
|
||||
mod visibility;
|
||||
|
||||
pub use lights::cascade_config_from_rendering;
|
||||
@ -37,6 +38,8 @@ use static_meshes::{
|
||||
hydrate_static_mesh_renderers, spawn_static_mesh_parts, HydratedStaticMeshPart,
|
||||
StaticMeshArtifactCache,
|
||||
};
|
||||
pub use terrain::HydratedTerrainChunk;
|
||||
use terrain::{cleanup_removed_terrain, hydrate_terrain};
|
||||
use visibility::{
|
||||
ensure_level_object_visibility_hierarchy, init_editor_visibility_on_spawn,
|
||||
sync_editor_visibility, visibility_from_editor,
|
||||
@ -73,6 +76,7 @@ impl Plugin for HydrationPlugin {
|
||||
(
|
||||
hydrate_primitives,
|
||||
hydrate_brushes,
|
||||
hydrate_terrain,
|
||||
hydrate_materials,
|
||||
hydrate_lights,
|
||||
reconcile_missing_runtime_lights,
|
||||
@ -83,6 +87,7 @@ impl Plugin for HydrationPlugin {
|
||||
hydrate_models,
|
||||
hydrate_prefabs,
|
||||
hydrate_physics,
|
||||
cleanup_removed_terrain,
|
||||
)
|
||||
.chain()
|
||||
.in_set(HydrationSet::Content),
|
||||
|
||||
314
crates/shared/src/hydration/terrain.rs
Normal file
314
crates/shared/src/hydration/terrain.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
@ -22,7 +22,8 @@ pub use components::*;
|
||||
pub use hydration::{
|
||||
cascade_config_from_rendering, flush_level_object_hydration, material_from_desc,
|
||||
strip_hydrated, strip_hydrated_entity, HydratedModelRoot, HydratedPrefabMember,
|
||||
HydratedPrefabReady, HydratedSkinnedMeshRoot, HydrationPlugin, PrefabHydrationBlocked,
|
||||
HydratedPrefabReady, HydratedSkinnedMeshRoot, HydratedTerrainChunk, HydrationPlugin,
|
||||
PrefabHydrationBlocked,
|
||||
};
|
||||
pub use material_asset::{
|
||||
load_resolved_material_from_path, MaterialAlphaMode, MaterialAsset, MaterialInstanceAsset,
|
||||
@ -112,6 +113,7 @@ impl Plugin for SharedTypesPlugin {
|
||||
.register_type::<Primitive>()
|
||||
.register_type::<PrimitiveShape>()
|
||||
.register_type::<BrushDesc>()
|
||||
.register_type::<TerrainDesc>()
|
||||
.register_type::<BrushKind>()
|
||||
.register_type::<BrushPlaneDesc>()
|
||||
.register_type::<BrushFaceDesc>()
|
||||
|
||||
@ -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 |
|
||||
| [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 |
|
||||
| [0039](adr/0039-inline-height-grid-terrain-foundation.md) | Inline authored height grids with deterministic runtime-only chunk hydration |
|
||||
|
||||
## 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/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/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/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 |
|
||||
|
||||
38
docs/adr/0039-inline-height-grid-terrain-foundation.md
Normal file
38
docs/adr/0039-inline-height-grid-terrain-foundation.md
Normal 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.
|
||||
@ -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 |
|
||||
| [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 |
|
||||
| [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-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/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/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 |
|
||||
| `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 |
|
||||
| `viewport/` | Camera, selection, gizmos, render views | architecture.md |
|
||||
| `play/` | PIE session, editor mode state | architecture.md |
|
||||
|
||||
42
docs/editor/evaluations/terrain-foundation/README.md
Normal file
42
docs/editor/evaluations/terrain-foundation/README.md
Normal 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`.
|
||||
|
||||

|
||||
|
||||
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 |
|
||||
BIN
docs/editor/evaluations/terrain-foundation/terrain-showcase-forward.png
(Stored with Git LFS)
Normal file
BIN
docs/editor/evaluations/terrain-foundation/terrain-showcase-forward.png
(Stored with Git LFS)
Normal file
Binary file not shown.
38
docs/editor/terrain.md
Normal file
38
docs/editor/terrain.md
Normal 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/).
|
||||
Loading…
Reference in New Issue
Block a user