From d1a56f77cc92a6367bfb56d06192ec620767e2c3 Mon Sep 17 00:00:00 2001 From: Rbanh Date: Sun, 12 Jul 2026 21:00:13 -0400 Subject: [PATCH] Add terrain material layer painting --- ...terrain_material_layers_2026-07-12.plan.md | 28 + README.md | 2 + .../levels/terrain_authoring_showcase.scn.ron | 29 +- assets/shaders/terrain_layers.wgsl | 116 ++++ crates/blacksite_surface/src/lib.rs | 315 ++++++++++- crates/editor/src/history/mod.rs | 32 +- crates/editor/src/lib.rs | 3 + crates/editor/src/ui/inspector.rs | 215 ++++++- crates/editor/src/ui/viewport_chrome.rs | 159 +++++- crates/editor/src/viewport/mod.rs | 2 + crates/editor/src/viewport/selection.rs | 39 +- crates/editor/src/viewport/terrain_paint.rs | 531 ++++++++++++++++++ crates/editor/src/viewport/terrain_sculpt.rs | 16 +- crates/shared/src/components.rs | 104 ++++ crates/shared/src/hydration/terrain.rs | 66 ++- crates/shared/src/lib.rs | 1 + crates/shared/src/renderer_material.rs | 22 +- docs/README.md | 3 + .../0040-terrain-material-layer-weights.md | 36 ++ docs/editor/README.md | 3 +- .../terrain-material-layers/README.md | 36 ++ .../terrain-material-paint-stroke.png | 3 + docs/editor/terrain.md | 31 +- 23 files changed, 1730 insertions(+), 62 deletions(-) create mode 100644 .cursor/plans/terrain_material_layers_2026-07-12.plan.md create mode 100644 assets/shaders/terrain_layers.wgsl create mode 100644 crates/editor/src/viewport/terrain_paint.rs create mode 100644 docs/adr/0040-terrain-material-layer-weights.md create mode 100644 docs/editor/evaluations/terrain-material-layers/README.md create mode 100644 docs/editor/evaluations/terrain-material-layers/terrain-material-paint-stroke.png diff --git a/.cursor/plans/terrain_material_layers_2026-07-12.plan.md b/.cursor/plans/terrain_material_layers_2026-07-12.plan.md new file mode 100644 index 0000000..36b79a8 --- /dev/null +++ b/.cursor/plans/terrain_material_layers_2026-07-12.plan.md @@ -0,0 +1,28 @@ +# Terrain Material Layers And Weight Painting (#24) + +## Scope + +- Persist up to four shared Material/Material Instance references on each `TerrainDesc`. +- Store normalized RGBA8 layer weights per height sample with an implicit channel-zero default. +- Blend layer albedo, normal, metallic, and roughness in the project-owned raster material path. +- Assign, clear, reorder, and tile layers from the Terrain inspector. +- Paint or erase one selected layer from the existing horizontal viewport toolbar. +- Preserve one reflected history transaction per completed pointer stroke and exact cancel restore. + +## Implementation + +1. Extend the backward-compatible terrain schema with four layer descriptors and compact weights. +2. Emit normalized weights as terrain chunk vertex colors and attach a runtime-only material binding. +3. Build a terrain-specific `ExtendedMaterial` in `blacksite_surface`; keep invalid references on the visible fallback path with diagnostics. +4. Add inspector layer controls and a mutually exclusive modal paint operator beside Sculpt. +5. Cover serialization, validation, blend transport, paint normalization, cancel, undo, and redo. +6. Update ADR 0040, terrain workflow docs, the implementation checklist, and live evidence. + +## Acceptance + +- Two or more project materials visibly blend on one terrain. +- Layer weights remain exactly normalized and survive scene save/load. +- Live paint preview follows the sampled terrain surface. +- Escape/right-click restores exact pre-stroke weights; release creates one undo entry. +- Missing or invalid material references remain visible as fallback and produce clear diagnostics. +- Formatting, workspace check/clippy/tests, level validation, and live native-Wayland QA pass. diff --git a/README.md b/README.md index eb63bf4..3918197 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,7 @@ deep-stale variants. | 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 | | Selected Terrain → viewport mountains tool | Sculpt Raise/Lower/Flatten/Smooth/Noise strokes with a terrain-following footprint; release commits one undo step, while Escape/right-click restores the pre-stroke grid | +| Terrain card → Material Layers; selected Terrain → viewport paint tool | Assign up to four shared materials, then Paint/Erase normalized layer weights with live blended preview; release commits one undo step and Escape/right-click restores the pre-stroke map | | 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 | @@ -402,6 +403,7 @@ crates/ - [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] Modal terrain Raise/Lower/Flatten/Smooth/Noise sculpting with a terrain-following footprint, deterministic noise, safe cancel restore, and one undo transaction per stroke ([terrain guide](docs/editor/terrain.md), [Gitea #23](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/23)) +- [x] Four shared terrain Material/Material Instance layers with compact normalized sample weights, blended raster hydration, drag/browse assignment, Paint/Erase preview, exact cancel, and one undo transaction per stroke ([ADR 0040](docs/adr/0040-terrain-material-layer-weights.md), [terrain guide](docs/editor/terrain.md), [Gitea #24](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/24)) - [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) diff --git a/assets/levels/terrain_authoring_showcase.scn.ron b/assets/levels/terrain_authoring_showcase.scn.ron index 2dd35a6..fc5cb3c 100644 --- a/assets/levels/terrain_authoring_showcase.scn.ron +++ b/assets/levels/terrain_authoring_showcase.scn.ron @@ -30,6 +30,33 @@ label: "surface_tint_instance", source_path: Some("assets/materials/surface_tint_instance.ron"), )), + material_layers: [ + ( + material: Some(( + asset_id: "a57f2891-8536-47b8-b476-01c08b36ac43", + sub_asset_id: "material:source", + label: "Concrete", + source_path: Some("assets/materials/concrete.ron"), + )), + uv_scale: 6.0, + ), + ( + material: Some(( + asset_id: "d6cb4151-7124-4237-aaf9-f7f8abd5fb76", + sub_asset_id: "material:source", + label: "Surface Tint", + source_path: Some("assets/materials/surface_tint.ron"), + )), + uv_scale: 10.0, + ), + ], + material_weights: [ + (255, 0, 0, 0), (255, 0, 0, 0), (224, 31, 0, 0), (255, 0, 0, 0), (255, 0, 0, 0), + (255, 0, 0, 0), (192, 63, 0, 0), (128, 127, 0, 0), (192, 63, 0, 0), (255, 0, 0, 0), + (224, 31, 0, 0), (128, 127, 0, 0), (0, 255, 0, 0), (128, 127, 0, 0), (224, 31, 0, 0), + (255, 0, 0, 0), (192, 63, 0, 0), (128, 127, 0, 0), (192, 63, 0, 0), (255, 0, 0, 0), + (255, 0, 0, 0), (255, 0, 0, 0), (224, 31, 0, 0), (255, 0, 0, 0), (255, 0, 0, 0), + ], generate_colliders: true, cast_shadows: true, receive_shadows: true, @@ -39,7 +66,7 @@ "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), + rotation: (-0.3826834, 0.0, 0.0, 0.9238795), scale: (1.0, 1.0, 1.0), ), "shared::components::ActorId": ("terrain-showcase-sun"), diff --git a/assets/shaders/terrain_layers.wgsl b/assets/shaders/terrain_layers.wgsl new file mode 100644 index 0000000..c7f8663 --- /dev/null +++ b/assets/shaders/terrain_layers.wgsl @@ -0,0 +1,116 @@ +#import bevy_pbr::{ + pbr_fragment::pbr_input_from_standard_material, + pbr_functions::{alpha_discard, calculate_tbn_mikktspace}, +} + +#ifdef PREPASS_PIPELINE +#import bevy_pbr::{ + prepass_io::{VertexOutput, FragmentOutput}, + pbr_deferred_functions::deferred_output, +} +#else +#import bevy_pbr::{ + forward_io::{VertexOutput, FragmentOutput}, + pbr_functions::{apply_pbr_lighting, main_pass_post_lighting_processing}, +} +#endif + +struct TerrainLayerUniform { + base_colors: array, 4>, + properties: array, 4>, + uv_scales: vec4, + base_texture_enabled: vec4, +} + +@group(#{MATERIAL_BIND_GROUP}) @binding(100) var terrain_layers: TerrainLayerUniform; +@group(#{MATERIAL_BIND_GROUP}) @binding(101) var base_color0: texture_2d; +@group(#{MATERIAL_BIND_GROUP}) @binding(102) var base_sampler0: sampler; +@group(#{MATERIAL_BIND_GROUP}) @binding(103) var normal0: texture_2d; +@group(#{MATERIAL_BIND_GROUP}) @binding(104) var normal_sampler0: sampler; +@group(#{MATERIAL_BIND_GROUP}) @binding(105) var base_color1: texture_2d; +@group(#{MATERIAL_BIND_GROUP}) @binding(106) var base_sampler1: sampler; +@group(#{MATERIAL_BIND_GROUP}) @binding(107) var normal1: texture_2d; +@group(#{MATERIAL_BIND_GROUP}) @binding(108) var normal_sampler1: sampler; +@group(#{MATERIAL_BIND_GROUP}) @binding(109) var base_color2: texture_2d; +@group(#{MATERIAL_BIND_GROUP}) @binding(110) var base_sampler2: sampler; +@group(#{MATERIAL_BIND_GROUP}) @binding(111) var normal2: texture_2d; +@group(#{MATERIAL_BIND_GROUP}) @binding(112) var normal_sampler2: sampler; +@group(#{MATERIAL_BIND_GROUP}) @binding(113) var base_color3: texture_2d; +@group(#{MATERIAL_BIND_GROUP}) @binding(114) var base_sampler3: sampler; +@group(#{MATERIAL_BIND_GROUP}) @binding(115) var normal3: texture_2d; +@group(#{MATERIAL_BIND_GROUP}) @binding(116) var normal_sampler3: sampler; + +fn layer_base(index: u32, uv: vec2) -> vec4 { + if index == 0u { return textureSample(base_color0, base_sampler0, uv); } + if index == 1u { return textureSample(base_color1, base_sampler1, uv); } + if index == 2u { return textureSample(base_color2, base_sampler2, uv); } + return textureSample(base_color3, base_sampler3, uv); +} + +fn layer_normal(index: u32, uv: vec2) -> vec3 { + var sample = vec3(0.5, 0.5, 1.0); + if index == 0u { sample = textureSample(normal0, normal_sampler0, uv).xyz; } + if index == 1u { sample = textureSample(normal1, normal_sampler1, uv).xyz; } + if index == 2u { sample = textureSample(normal2, normal_sampler2, uv).xyz; } + if index == 3u { sample = textureSample(normal3, normal_sampler3, uv).xyz; } + return normalize(sample * 2.0 - 1.0); +} + +@fragment +fn fragment(in: VertexOutput, @builtin(front_facing) is_front: bool) -> FragmentOutput { + var pbr_input = pbr_input_from_standard_material(in, is_front); +#ifdef VERTEX_UVS_A + let uv = in.uv; +#else + let uv = vec2(0.0); +#endif +#ifdef VERTEX_COLORS + var weights = max(in.color, vec4(0.0)); +#else + var weights = vec4(1.0, 0.0, 0.0, 0.0); +#endif + let enabled = vec4( + terrain_layers.properties[0].w, + terrain_layers.properties[1].w, + terrain_layers.properties[2].w, + terrain_layers.properties[3].w, + ); + weights *= enabled; + let total = dot(weights, vec4(1.0)); + weights = select(vec4(1.0, 0.0, 0.0, 0.0), weights / total, total > 0.0001); + + var color = vec4(0.0); + var normal_ts = vec3(0.0); + var metallic = 0.0; + var roughness = 0.0; + for (var index = 0u; index < 4u; index += 1u) { + let weight = weights[index]; + let layer_uv = uv * terrain_layers.uv_scales[index]; + let base_sample = mix( + vec4(1.0), + layer_base(index, layer_uv), + terrain_layers.base_texture_enabled[index], + ); + color += base_sample * terrain_layers.base_colors[index] * weight; + let normal_enabled = terrain_layers.properties[index].z; + normal_ts += mix(vec3(0.0, 0.0, 1.0), layer_normal(index, layer_uv), normal_enabled) * weight; + metallic += terrain_layers.properties[index].x * weight; + roughness += terrain_layers.properties[index].y * weight; + } + pbr_input.material.base_color = alpha_discard(pbr_input.material, color); + pbr_input.material.metallic = clamp(metallic, 0.0, 1.0); + pbr_input.material.perceptual_roughness = clamp(roughness, 0.001, 1.0); +#ifdef VERTEX_TANGENTS + let tbn = calculate_tbn_mikktspace(pbr_input.world_normal, in.world_tangent); + let n = normalize(normal_ts); + pbr_input.N = normalize(n.x * tbn[0] + n.y * tbn[1] + n.z * tbn[2]); +#endif +#ifdef PREPASS_PIPELINE + return deferred_output(in, pbr_input); +#else + var out: FragmentOutput; + out.color = apply_pbr_lighting(pbr_input); + out.color = main_pass_post_lighting_processing(pbr_input, out.color); + return out; +#endif +} diff --git a/crates/blacksite_surface/src/lib.rs b/crates/blacksite_surface/src/lib.rs index 5d2922b..e4c6b42 100644 --- a/crates/blacksite_surface/src/lib.rs +++ b/crates/blacksite_surface/src/lib.rs @@ -17,15 +17,92 @@ use bevy::render::render_resource::{ }; use bevy::shader::{Shader, ShaderRef}; use shared::{ - asset_server_path, material_from_desc, HydratedRendererMaterialBinding, MaterialAsset, - MaterialInstanceAsset, MaterialParameterValue, MaterialRef, ShaderPropertyType, - ShaderSchemaAsset, MATERIAL_INSTANCE_SCHEMA_VERSION, + asset_server_path, material_from_desc, HydratedRendererMaterialBinding, + HydratedTerrainMaterialBinding, MaterialAsset, MaterialDesc, MaterialInstanceAsset, + MaterialParameterValue, MaterialRef, ShaderPropertyType, ShaderSchemaAsset, + TerrainMaterialLayer, MATERIAL_INSTANCE_SCHEMA_VERSION, TERRAIN_MATERIAL_LAYER_LIMIT, }; pub const SURFACE_ABI_VERSION: u32 = 1; pub const SURFACE_PARAMETER_LANES: usize = 16; pub const SURFACE_TEXTURE_SLOTS: usize = 8; pub const DEFAULT_SURFACE_SHADER_PATH: &str = "shaders/blacksite_surface.wgsl"; +pub const TERRAIN_LAYER_SHADER_PATH: &str = "shaders/terrain_layers.wgsl"; + +#[derive(ShaderType, Reflect, Debug, Clone, Copy, PartialEq)] +pub struct TerrainLayerUniform { + pub base_colors: [Vec4; TERRAIN_MATERIAL_LAYER_LIMIT], + /// Metallic, perceptual roughness, normal-map enabled, layer enabled. + pub properties: [Vec4; TERRAIN_MATERIAL_LAYER_LIMIT], + pub uv_scales: Vec4, + pub base_texture_enabled: Vec4, +} + +impl Default for TerrainLayerUniform { + fn default() -> Self { + Self { + base_colors: [Vec4::ONE; TERRAIN_MATERIAL_LAYER_LIMIT], + properties: [Vec4::new(0.0, 0.9, 0.0, 0.0); TERRAIN_MATERIAL_LAYER_LIMIT], + uv_scales: Vec4::splat(8.0), + base_texture_enabled: Vec4::ZERO, + } + } +} + +#[derive(Asset, AsBindGroup, Reflect, Debug, Clone, Default)] +pub struct TerrainLayerExtension { + #[uniform(100)] + pub uniform: TerrainLayerUniform, + #[texture(101)] + #[sampler(102)] + pub base_color0: Option>, + #[texture(103)] + #[sampler(104)] + pub normal0: Option>, + #[texture(105)] + #[sampler(106)] + pub base_color1: Option>, + #[texture(107)] + #[sampler(108)] + pub normal1: Option>, + #[texture(109)] + #[sampler(110)] + pub base_color2: Option>, + #[texture(111)] + #[sampler(112)] + pub normal2: Option>, + #[texture(113)] + #[sampler(114)] + pub base_color3: Option>, + #[texture(115)] + #[sampler(116)] + pub normal3: Option>, +} + +impl MaterialExtension for TerrainLayerExtension { + fn fragment_shader() -> ShaderRef { + TERRAIN_LAYER_SHADER_PATH.into() + } + + fn deferred_fragment_shader() -> ShaderRef { + TERRAIN_LAYER_SHADER_PATH.into() + } +} + +pub type TerrainLayerMaterial = ExtendedMaterial; + +#[derive(Debug, Clone)] +struct TerrainLayerCacheEntry { + layers: Vec, + handle: Handle, + revision: u64, +} + +#[derive(Resource, Default)] +struct TerrainLayerMaterialCache(HashMap); + +type TerrainMaterialBindings<'w, 's> = + Query<'w, 's, (Entity, &'static HydratedTerrainMaterialBinding)>; #[derive(ShaderType, Reflect, Debug, Clone, Copy, PartialEq)] pub struct SurfaceUniform { @@ -121,10 +198,6 @@ impl MaterialExtension for SurfaceExtension { DEFAULT_SURFACE_SHADER_PATH.into() } - fn prepass_fragment_shader() -> ShaderRef { - DEFAULT_SURFACE_SHADER_PATH.into() - } - fn specialize( _pipeline: &MaterialExtensionPipeline, descriptor: &mut RenderPipelineDescriptor, @@ -179,10 +252,180 @@ pub struct SurfaceMaterialPlugin; impl Plugin for SurfaceMaterialPlugin { fn build(&self, app: &mut App) { app.add_plugins(MaterialPlugin::::default()) + .add_plugins(MaterialPlugin::::default()) .init_resource::() + .init_resource::() .init_resource::() .init_resource::() - .add_systems(Update, sync_surface_material_bindings); + .add_systems( + Update, + ( + sync_surface_material_bindings, + sync_terrain_layer_material_bindings, + ), + ); + } +} + +fn sync_terrain_layer_material_bindings( + mut commands: Commands, + asset_server: Res, + mut materials: ResMut>, + mut cache: ResMut, + mut diagnostics: ResMut, + bindings: TerrainMaterialBindings, +) { + for (entity, binding) in &bindings { + if binding.layers.is_empty() { + continue; + } + let revision = terrain_layer_revision(&binding.layers); + let cached = cache + .0 + .get(&binding.owner) + .filter(|entry| entry.layers == binding.layers && entry.revision == revision) + .map(|entry| entry.handle.clone()); + let handle = cached.unwrap_or_else(|| { + let material = + build_terrain_layer_material(&asset_server, &binding.layers, &mut diagnostics.0); + let handle = cache + .0 + .get(&binding.owner) + .map(|entry| entry.handle.clone()) + .filter(|handle| { + materials + .get_mut(handle) + .map(|mut slot| { + *slot = material.clone(); + }) + .is_some() + }) + .unwrap_or_else(|| materials.add(material)); + cache.0.insert( + binding.owner, + TerrainLayerCacheEntry { + layers: binding.layers.clone(), + handle: handle.clone(), + revision, + }, + ); + handle + }); + commands + .entity(entity) + .remove::>() + .insert(MeshMaterial3d(handle)); + } +} + +fn terrain_layer_revision(layers: &[TerrainMaterialLayer]) -> u64 { + let mut dependencies = layers + .iter() + .filter_map(|layer| layer.material.as_ref()) + .filter_map(|reference| reference.source_path.as_deref()) + .flat_map(|path| material_dependency_paths(path).unwrap_or_else(|_| vec![path.to_string()])) + .collect::>(); + dependencies.sort(); + dependencies.dedup(); + dependency_revision(&dependencies) +} + +fn build_terrain_layer_material( + asset_server: &AssetServer, + layers: &[TerrainMaterialLayer], + diagnostics: &mut Vec, +) -> TerrainLayerMaterial { + let mut extension = TerrainLayerExtension::default(); + for (index, layer) in layers.iter().take(TERRAIN_MATERIAL_LAYER_LIMIT).enumerate() { + extension.uniform.uv_scales[index] = layer.uv_scale; + let desc = resolve_terrain_layer(layer, index, diagnostics); + let color = desc.base_color.to_color().to_linear(); + extension.uniform.base_colors[index] = + Vec4::new(color.red, color.green, color.blue, color.alpha); + extension.uniform.properties[index] = Vec4::new( + desc.metallic, + desc.roughness, + f32::from(desc.normal_map_texture.is_some()), + 1.0, + ); + extension.uniform.base_texture_enabled[index] = + f32::from(desc.base_color_texture.is_some()); + let base_color = desc + .base_color_texture + .as_deref() + .map(|path| asset_server.load(asset_server_path(path))); + let normal = desc + .normal_map_texture + .as_deref() + .map(|path| asset_server.load(asset_server_path(path))); + match index { + 0 => { + extension.base_color0 = base_color; + extension.normal0 = normal; + } + 1 => { + extension.base_color1 = base_color; + extension.normal1 = normal; + } + 2 => { + extension.base_color2 = base_color; + extension.normal2 = normal; + } + 3 => { + extension.base_color3 = base_color; + extension.normal3 = normal; + } + _ => {} + } + } + TerrainLayerMaterial { + base: StandardMaterial { + base_color: Color::WHITE, + perceptual_roughness: 1.0, + ..default() + }, + extension, + } +} + +fn resolve_terrain_layer( + layer: &TerrainMaterialLayer, + index: usize, + diagnostics: &mut Vec, +) -> MaterialDesc { + let Some(reference) = layer.material.as_ref() else { + diagnostics.push(format!( + "terrain material layer {} is unassigned; using visible fallback", + index + 1 + )); + return terrain_layer_fallback(); + }; + let Some(path) = reference.source_path.as_deref() else { + diagnostics.push(format!( + "terrain material layer {} ({}) has no loadable source path; using visible fallback", + index + 1, + reference.label + )); + return terrain_layer_fallback(); + }; + match shared::load_resolved_material_from_path(path) { + Ok((desc, _)) => desc, + Err(error) => { + diagnostics.push(format!( + "terrain material layer {} ({}) could not resolve: {error}; using visible fallback", + index + 1, + reference.label + )); + terrain_layer_fallback() + } + } +} + +fn terrain_layer_fallback() -> MaterialDesc { + MaterialDesc { + base_color: shared::ColorDesc::srgb(0.24, 0.29, 0.25), + roughness: 0.92, + ..Default::default() } } @@ -759,4 +1002,60 @@ mod tests { assert_eq!(SURFACE_TEXTURE_SLOTS, 8); assert_eq!(std::mem::size_of::(), 400); } + + #[test] + fn material_extensions_leave_non_deferred_prepasses_to_standard_material() { + assert!(matches!( + ::prepass_fragment_shader(), + ShaderRef::Default + )); + assert!(matches!( + ::prepass_fragment_shader(), + ShaderRef::Default + )); + } + + #[test] + fn terrain_layer_builder_packs_project_material_values() { + let mut app = App::new(); + app.add_plugins((MinimalPlugins, AssetPlugin::default())); + let asset_server = app.world().resource::(); + let materials_root = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/materials"); + let layers = [ + TerrainMaterialLayer { + material: Some( + shared::EditorAssetRef::new("concrete", "material:source", "Concrete") + .with_source_path( + materials_root.join("concrete.ron").display().to_string(), + ), + ), + uv_scale: 6.0, + }, + TerrainMaterialLayer { + material: Some( + shared::EditorAssetRef::new("tint", "material:source", "Surface Tint") + .with_source_path( + materials_root + .join("surface_tint.ron") + .display() + .to_string(), + ), + ), + uv_scale: 10.0, + }, + ]; + let mut diagnostics = Vec::new(); + let material = build_terrain_layer_material(asset_server, &layers, &mut diagnostics); + + assert!(diagnostics.is_empty(), "{diagnostics:?}"); + assert_eq!(material.extension.uniform.uv_scales.x, 6.0); + assert_eq!(material.extension.uniform.uv_scales.y, 10.0); + assert_eq!(material.extension.uniform.properties[0].w, 1.0); + assert_eq!(material.extension.uniform.properties[1].w, 1.0); + assert_ne!( + material.extension.uniform.base_colors[0], + material.extension.uniform.base_colors[1] + ); + } } diff --git a/crates/editor/src/history/mod.rs b/crates/editor/src/history/mod.rs index ab37385..9d96ba3 100644 --- a/crates/editor/src/history/mod.rs +++ b/crates/editor/src/history/mod.rs @@ -1415,16 +1415,13 @@ pub fn apply_reflected_component( let reflected = TypedReflectDeserializer::new(registration, &type_registry) .deserialize(&mut deserializer) .map_err(|error| error.to_string())?; - let exists = reflect_component.reflect(world.entity(entity)).is_some(); - if exists { - reflect_component.apply(world.entity_mut(entity), reflected.as_ref()); - } else { - reflect_component.insert( - &mut world.entity_mut(entity), - reflected.as_ref(), - &type_registry, - ); - } + // Reflected `apply` merges list fields and cannot restore a shorter snapshot. + // Inserting the captured concrete component replaces the value atomically. + reflect_component.insert( + &mut world.entity_mut(entity), + reflected.as_ref(), + &type_registry, + ); } None => reflect_component.remove(&mut world.entity_mut(entity)), } @@ -1451,16 +1448,11 @@ pub fn apply_reflected_default( .data::() .ok_or_else(|| format!("component `{type_path}` has no reflected Default"))? .default(); - let exists = reflect_component.reflect(world.entity(entity)).is_some(); - if exists { - reflect_component.apply(world.entity_mut(entity), default.as_ref()); - } else { - reflect_component.insert( - &mut world.entity_mut(entity), - default.as_ref(), - &type_registry, - ); - } + reflect_component.insert( + &mut world.entity_mut(entity), + default.as_ref(), + &type_registry, + ); Ok(()) }) } diff --git a/crates/editor/src/lib.rs b/crates/editor/src/lib.rs index 4bbecee..859220b 100644 --- a/crates/editor/src/lib.rs +++ b/crates/editor/src/lib.rs @@ -43,6 +43,7 @@ pub use viewport::render_view; pub use viewport::rendering_diagnostics; pub use viewport::selection; pub use viewport::selection_outline; +pub use viewport::terrain_paint; pub use viewport::terrain_sculpt; pub use viewport::visualizers; @@ -80,6 +81,7 @@ use selection_outline::SelectionOutlinePlugin; use session::EditorSessionPlugin; use settings_ui::SettingsUiPlugin; use state::EditorStatePlugin; +use terrain_paint::TerrainPaintPlugin; use terrain_sculpt::TerrainSculptPlugin; use ui::EditorUiPlugin; use viewport::ViewportPlugin; @@ -112,6 +114,7 @@ impl PluginGroup for EditorPluginGroup { .add(BrushEditPlugin) .add(BrushToolPlugin) .add(TerrainSculptPlugin) + .add(TerrainPaintPlugin) .add(EditorCameraPlugin) .add(AudioPreviewPlugin) .add(ActorIconsPlugin) diff --git a/crates/editor/src/ui/inspector.rs b/crates/editor/src/ui/inspector.rs index bd03ec6..61e9e33 100644 --- a/crates/editor/src/ui/inspector.rs +++ b/crates/editor/src/ui/inspector.rs @@ -3496,12 +3496,23 @@ 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 material_candidates = brush_face_material_ref_candidates(world); + let dragging_selection = world + .get_resource::() + .and_then(|assets| assets.dragging_selection().cloned()); + let material_drop_candidate = dragging_selection.as_ref().and_then(|selection| { + asset_ref_candidate_from_selection(world, selection, AssetRefCandidateKind::Material) + }); let Some(mut terrain) = world.get::(entity).cloned() else { return; }; let original = terrain.clone(); let mut changed = false; let mut requested_resolution = terrain.resolution; + let mut remove_layer = None; + let mut swap_layers = None; + let mut locate_layer = None; + let mut accepted_drop = false; let card = component_card_context( world, entity, @@ -3526,6 +3537,7 @@ fn terrain_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { let replacement = TerrainDesc::flat(requested_resolution); terrain.resolution = replacement.resolution; terrain.heights = replacement.heights; + terrain.material_weights.clear(); terrain.chunk_quads = terrain.chunk_quads.min(terrain.resolution - 1).max(1); changed = true; } @@ -3573,16 +3585,132 @@ fn terrain_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { .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"), - ); + ui.add_space(6.0); + ui.separator(); + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Material Layers").strong()); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui + .add_enabled( + terrain.material_layers.len() < shared::TERRAIN_MATERIAL_LAYER_LIMIT, + egui::Button::new(phosphor_icon(icons::PLUS, 16.0)), + ) + .on_hover_text("Add a terrain blend channel") + .clicked() + { + let material = if terrain.material_layers.is_empty() { + terrain.base_material.take() + } else { + None + }; + terrain.material_layers.push(shared::TerrainMaterialLayer { + material, + ..Default::default() + }); + changed = true; + } + }); }); + if terrain.material_layers.is_empty() { + ui.label( + egui::RichText::new( + terrain + .base_material + .as_ref() + .map(|material| format!("Legacy base: {}", material.label)) + .unwrap_or_else(|| "No layers assigned; visible terrain fallback".into()), + ) + .color(TEXT_DIM) + .small(), + ); + } + for index in 0..terrain.material_layers.len() { + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(format!("Layer {}", index + 1)) + .strong() + .color(if index == 0 { + SELECTION_BG_MUTED + } else { + TEXT_DIM + }), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if icon_button_small(ui, icons::TRASH, "Remove layer").clicked() { + remove_layer = Some(index); + } + if ui + .add_enabled( + index + 1 < terrain.material_layers.len(), + egui::Button::new(phosphor_icon(icons::ARROW_DOWN, 15.0)).frame(false), + ) + .on_hover_text("Move layer down") + .clicked() + { + swap_layers = Some((index, index + 1)); + } + if ui + .add_enabled( + index > 0, + egui::Button::new(phosphor_icon(icons::ARROW_UP, 15.0)).frame(false), + ) + .on_hover_text("Move layer up") + .clicked() + { + swap_layers = Some((index, index - 1)); + } + }); + }); + let response = asset_selector_row( + ui, + "Material", + icons::PALETTE, + terrain.material_layers[index].material.as_ref(), + None, + true, + &material_candidates, + material_drop_candidate.as_ref(), + ); + if let Some(selected) = response.selected { + terrain.material_layers[index].material = Some(selected); + changed = true; + } + if response.clear { + terrain.material_layers[index].material = None; + changed = true; + } + if response.locate { + locate_layer = Some(index); + } + accepted_drop |= response.accepted_drop; + property_row(ui, "UV Tiling", |ui| { + changed |= ui + .add( + egui::DragValue::new(&mut terrain.material_layers[index].uv_scale) + .range(0.01..=1024.0) + .speed(0.1) + .suffix("×"), + ) + .changed(); + }); + if !terrain.material_weights.is_empty() { + let covered = terrain + .material_weights + .iter() + .filter(|weights| weights[index] > 0) + .count(); + ui.label( + egui::RichText::new(format!( + "{} / {} samples carry this layer", + covered, + terrain.material_weights.len() + )) + .color(TEXT_DIM) + .small(), + ); + } + } match terrain.validate() { Ok(()) => { ui.label( @@ -3602,6 +3730,31 @@ fn terrain_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { }); apply_component_card_response(world, entity, card_response); + if let Some(index) = remove_layer { + remove_terrain_material_layer(&mut terrain, index); + changed = true; + } + if let Some((a, b)) = swap_layers { + terrain.material_layers.swap(a, b); + for weights in &mut terrain.material_weights { + weights.swap(a, b); + } + changed = true; + } + if let Some(index) = locate_layer { + locate_asset_ref( + world, + terrain + .material_layers + .get(index) + .and_then(|layer| layer.material.as_ref()), + &material_candidates, + ); + } + if accepted_drop { + clear_asset_drag(world); + } + if changed && terrain != original { let _ = reflected_component_transaction( world, @@ -3617,6 +3770,50 @@ fn terrain_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) { } } +fn remove_terrain_material_layer(terrain: &mut TerrainDesc, index: usize) { + if index >= terrain.material_layers.len() { + return; + } + terrain.material_layers.remove(index); + if terrain.material_layers.is_empty() { + terrain.material_weights.clear(); + return; + } + for weights in &mut terrain.material_weights { + for channel in index..3 { + weights[channel] = weights[channel + 1]; + } + weights[3] = 0; + normalize_terrain_weights(weights, terrain.material_layers.len()); + } +} + +fn normalize_terrain_weights(weights: &mut [u8; 4], layer_count: usize) { + for weight in weights.iter_mut().skip(layer_count) { + *weight = 0; + } + let sum: u16 = weights + .iter() + .take(layer_count) + .copied() + .map(u16::from) + .sum(); + if sum == 0 { + *weights = [255, 0, 0, 0]; + return; + } + let source = *weights; + let mut assigned = 0_u16; + for index in 0..layer_count { + weights[index] = ((u16::from(source[index]) * 255) / sum) as u8; + assigned += u16::from(weights[index]); + } + let largest = (0..layer_count) + .max_by_key(|index| source[*index]) + .unwrap_or(0); + weights[largest] = weights[largest].saturating_add((255 - assigned) as u8); +} + fn brush_validation_ui(ui: &mut egui::Ui, brush: &BrushDesc) { let report = validate_brush(brush); if report.diagnostics.is_empty() { diff --git a/crates/editor/src/ui/viewport_chrome.rs b/crates/editor/src/ui/viewport_chrome.rs index d3c5caa..e554abd 100644 --- a/crates/editor/src/ui/viewport_chrome.rs +++ b/crates/editor/src/ui/viewport_chrome.rs @@ -20,6 +20,7 @@ use crate::state::PlayPossession; use crate::viewport::actor_icons::ActorIconSettings; use crate::viewport::brush_edit::{BrushEditMode, BrushElementSelection}; use crate::viewport::brush_tool::{BrushToolPhase, BrushToolState}; +use crate::viewport::terrain_paint::{TerrainPaintMode, TerrainPaintState}; use crate::viewport::terrain_sculpt::{TerrainSculptMode, TerrainSculptState}; use crate::viewport::{ material_drop::is_surface_asset_selection, snap_translation, viewport_ground_position, @@ -149,6 +150,7 @@ pub fn viewport_tab_ui( scene_view_selection_hud(world, ui.ctx(), rect, selected_entities); scene_view_brush_draw_hints(world, ui.ctx(), rect); scene_view_terrain_sculpt_hints(world, ui.ctx(), rect); + scene_view_terrain_paint_hints(world, ui.ctx(), rect); scene_view_render_badge(world, ui.ctx(), rect); scene_view_brush_mode_badge(world, ui.ctx(), rect); scene_view_volume_hud(world, ui.ctx(), rect); @@ -349,6 +351,52 @@ fn scene_view_terrain_sculpt_hints(world: &World, ctx: &egui::Context, scene_rec }); } +fn scene_view_terrain_paint_hints(world: &World, ctx: &egui::Context, scene_rect: egui::Rect) { + let Some(tool) = world.get_resource::() else { + return; + }; + if !tool.active { + return; + } + egui::Area::new(egui::Id::new("scene_view_terrain_paint_hints")) + .fixed_pos(scene_rect.left_top() + egui::vec2(8.0, 52.0)) + .interactable(false) + .show(ctx, |ui| { + overlay_chip_frame().show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(format!( + "Terrain {} • Layer {}", + tool.mode.label(), + tool.active_layer + 1 + )) + .color(SELECTION) + .strong(), + ); + ui.label( + egui::RichText::new(format!("R {:.1}m", tool.radius)) + .color(TEXT_DIM) + .monospace() + .small(), + ); + ui.label( + egui::RichText::new(format!("S {:.0}%", tool.strength * 100.0)) + .color(SUCCESS) + .monospace() + .small(), + ); + }); + ui.add_space(4.0); + ui.horizontal_wrapped(|ui| { + key_hint(ui, "LMB Drag", tool.mode.label()); + key_hint(ui, "Esc", "Cancel stroke / close"); + key_hint(ui, "RMB", "Cancel stroke / close"); + key_hint(ui, "Ctrl+Z", "Undo stroke"); + }); + }); + }); +} + fn key_hint(ui: &mut egui::Ui, key: &str, label: &str) { ui.horizontal(|ui| { ui.label( @@ -541,6 +589,24 @@ fn scene_view_overlay_toolbar(world: &mut World, ctx: &egui::Context, scene_rect .resource::() .0 .filter(|entity| world.get::(*entity).is_some()); + let terrain_layer_labels = terrain_selected + .and_then(|entity| world.get::(entity)) + .map(|terrain| { + terrain + .material_layers + .iter() + .enumerate() + .map(|(index, layer)| { + layer + .material + .as_ref() + .map(|material| material.label.clone()) + .filter(|label| !label.trim().is_empty()) + .unwrap_or_else(|| format!("Layer {}", index + 1)) + }) + .collect::>() + }) + .unwrap_or_default(); let toolbar_response = egui::Area::new(egui::Id::new("scene_view_toolbar")) .fixed_pos(scene_rect.left_top() + egui::vec2(6.0, 6.0)) @@ -672,7 +738,8 @@ fn scene_view_overlay_toolbar(world: &mut World, ctx: &egui::Context, scene_rect } let sculpt_active = world.resource::().active; - if terrain_selected.is_some() || sculpt_active { + let paint_active = world.resource::().active; + if terrain_selected.is_some() || sculpt_active || paint_active { ui.separator(); let stroking = world.resource::().is_stroking(); let sculpt_button_clicked = tool_button_accent( @@ -698,6 +765,7 @@ fn scene_view_overlay_toolbar(world: &mut World, ctx: &egui::Context, scene_rect } let active = state.active; if active { + world.resource_mut::().stop(); world.resource_mut::().cancel(); world .resource_mut::() @@ -746,6 +814,95 @@ fn scene_view_overlay_toolbar(world: &mut World, ctx: &egui::Context, scene_rect state.strength = strength; state.clamp_settings(); } + + let paint_stroking = world.resource::().is_stroking(); + let can_paint = terrain_layer_labels.len() >= 2 || paint_active; + let paint_button_clicked = ui + .add_enabled( + can_paint, + egui::Button::new(phosphor_icon(icons::PAINT_BRUSH, 16.0)) + .selected(paint_active), + ) + .on_hover_text(if can_paint { + if paint_active { + "Close terrain material paint tool" + } else { + "Paint selected terrain material layers" + } + } else { + "Assign at least two terrain material layers first" + }) + .clicked(); + if paint_button_clicked { + world.resource_mut::().0 = None; + } + if paint_button_clicked && !paint_stroking { + let layer_count = terrain_layer_labels.len(); + let state = &mut *world.resource_mut::(); + if state.active { + state.stop(); + } else if let Some(entity) = terrain_selected { + state.start(entity, layer_count); + } + let active = state.active; + if active { + world.resource_mut::().stop(); + world.resource_mut::().cancel(); + world + .resource_mut::() + .status = Some(crate::operators::OperatorStatus { + id: "terrain.paint".into(), + label: "Terrain Paint".into(), + phase: crate::operators::OperatorPhase::Preview, + hint: "LMB drag paints weights; Esc or RMB cancels".into(), + warnings: Vec::new(), + }); + } + } + if paint_active { + let mut mode = world.resource::().mode; + ui.selectable_value(&mut mode, TerrainPaintMode::Paint, "Paint") + .on_hover_text("Increase the selected layer weight"); + ui.selectable_value(&mut mode, TerrainPaintMode::Erase, "Erase") + .on_hover_text("Reduce the selected layer weight"); + let mut active_layer = + world.resource::().active_layer; + egui::ComboBox::from_id_salt("terrain_paint_layer") + .selected_text( + terrain_layer_labels + .get(active_layer) + .cloned() + .unwrap_or_else(|| "Layer".into()), + ) + .show_ui(ui, |ui| { + for (index, label) in terrain_layer_labels.iter().enumerate() { + ui.selectable_value(&mut active_layer, index, label); + } + }); + let mut radius = world.resource::().radius; + let mut strength = world.resource::().strength; + ui.add( + egui::DragValue::new(&mut radius) + .range(0.25..=128.0) + .speed(0.1) + .prefix("R ") + .suffix(" m"), + ) + .on_hover_text("Brush radius"); + ui.add( + egui::DragValue::new(&mut strength) + .range(0.01..=1.0) + .speed(0.02) + .prefix("S "), + ) + .on_hover_text("Weight strength per dab"); + let mut state = world.resource_mut::(); + state.mode = mode; + state.active_layer = active_layer; + state.radius = radius; + state.strength = strength; + state.clamp_settings(); + } } let clean_game_view = world.resource::().clean_game_view; diff --git a/crates/editor/src/viewport/mod.rs b/crates/editor/src/viewport/mod.rs index 6a19f6e..e1b675b 100644 --- a/crates/editor/src/viewport/mod.rs +++ b/crates/editor/src/viewport/mod.rs @@ -12,6 +12,7 @@ pub mod render_view; pub mod rendering_diagnostics; pub mod selection; pub mod selection_outline; +pub mod terrain_paint; pub mod terrain_sculpt; pub mod viewport_mode; pub mod visualizers; @@ -25,5 +26,6 @@ pub use brush_edit::{BrushEditMode, BrushEditPlugin, BrushElementSelection}; pub use brush_tool::{BrushToolPlugin, BrushToolState}; pub use material_drop::{MaterialDropFeedback, MaterialDropPlugin, MaterialDropState}; pub use panel::*; +pub use terrain_paint::{TerrainPaintMode, TerrainPaintPlugin, TerrainPaintState}; pub use terrain_sculpt::{TerrainSculptMode, TerrainSculptPlugin, TerrainSculptState}; pub use viewport_mode::EditorViewportMode; diff --git a/crates/editor/src/viewport/selection.rs b/crates/editor/src/viewport/selection.rs index 947b9ad..a6e6adc 100644 --- a/crates/editor/src/viewport/selection.rs +++ b/crates/editor/src/viewport/selection.rs @@ -24,6 +24,7 @@ use bevy_egui::egui; use crate::viewport::material_drop::{MaterialDropSet, MaterialDropState}; use crate::viewport::scene_view_ray; +use crate::viewport::terrain_paint::TerrainPaintState; use crate::viewport::terrain_sculpt::TerrainSculptState; #[derive(Resource, Default, Debug, Clone, Copy)] @@ -52,6 +53,7 @@ struct PickTargetQueries<'w, 's> { editor_only: Query<'w, 's, (), With>, material_drop: Option>, terrain_sculpt: Res<'w, TerrainSculptState>, + terrain_paint: Res<'w, TerrainPaintState>, viewport_ui: Res<'w, ViewportUiState>, } @@ -127,7 +129,7 @@ fn handle_pick_events( for _ in click_events.read() {} return Ok(()); } - if pick_targets.terrain_sculpt.active { + if pick_targets.terrain_sculpt.active || pick_targets.terrain_paint.active { viewport_click.0 = None; for _ in click_events.read() {} return Ok(()); @@ -252,8 +254,9 @@ fn cycle_overlapping_viewport_pick( display: Res, material_drop: Option>, terrain_sculpt: Res, + terrain_paint: Res, ) -> Result { - if terrain_sculpt.active { + if terrain_sculpt.active || terrain_paint.active { return Ok(()); } if material_drop @@ -443,13 +446,18 @@ fn sync_gizmo_targets( display: Res, brush_mode: Res, terrain_sculpt: Res, + terrain_paint: Res, ) { ui_state .selected_entities .retain(|entity| transforms.contains(entity) && !editor_only.contains(entity)); selected.0 = ui_state.selected_entities.as_slice().first().copied(); - if display.clean_game_view || brush_mode.is_element_mode() || terrain_sculpt.active { + if display.clean_game_view + || brush_mode.is_element_mode() + || terrain_sculpt.active + || terrain_paint.active + { for (entity, brush_element_gizmo) in &targets { if brush_element_gizmo.is_none() { commands.entity(entity).remove::(); @@ -503,6 +511,7 @@ mod tests { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() .add_systems(Update, sync_gizmo_targets); app.update(); @@ -534,6 +543,30 @@ mod tests { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() + .add_systems(Update, sync_gizmo_targets); + + app.update(); + assert!(app.world().get::(terrain).is_none()); + } + + #[test] + fn terrain_paint_owns_pointer_without_transform_gizmo() { + let mut app = App::new(); + let terrain = app + .world_mut() + .spawn((LevelObject, Transform::default(), GizmoTarget::default())) + .id(); + let mut ui_state = UiState::default_layout(); + ui_state.selected_entities.select_replace(terrain); + let mut paint = TerrainPaintState::default(); + paint.start(terrain, 2); + app.insert_resource(ui_state) + .insert_resource(paint) + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() .add_systems(Update, sync_gizmo_targets); app.update(); diff --git a/crates/editor/src/viewport/terrain_paint.rs b/crates/editor/src/viewport/terrain_paint.rs new file mode 100644 index 0000000..ed80ded --- /dev/null +++ b/crates/editor/src/viewport/terrain_paint.rs @@ -0,0 +1,531 @@ +//! Modal terrain material-weight painting with exact cancel and grouped history. + +use bevy::prelude::*; +use bevy_egui::EguiContexts; +use shared::{TerrainDesc, AUTHORING_COMPONENT_TERRAIN, COMPONENT_TERRAIN_DESC}; + +use super::terrain_sculpt::{active_scene_ray, smooth_falloff, terrain_ray_hit, TerrainHit}; +use crate::camera::EditorCamera; +use crate::history::reflected_component_transaction; +use crate::operators::{ActiveOperator, OperatorPhase, OperatorStatus}; +use crate::scene_io::SceneIo; +use crate::selection::{SelectedEntity, ViewportClick}; +use crate::state::scene_tools_active; +use crate::ui::UiState; +use crate::viewport::ViewportDisplayMode; + +const MIN_RADIUS: f32 = 0.25; +const MAX_RADIUS: f32 = 128.0; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum TerrainPaintMode { + #[default] + Paint, + Erase, +} + +impl TerrainPaintMode { + pub fn label(self) -> &'static str { + match self { + Self::Paint => "Paint", + Self::Erase => "Erase", + } + } +} + +#[derive(Debug, Clone)] +struct TerrainPaintStroke { + entity: Entity, + original: TerrainDesc, + last_local: Vec3, +} + +#[derive(Resource, Debug, Clone)] +pub struct TerrainPaintState { + pub active: bool, + pub mode: TerrainPaintMode, + pub active_layer: usize, + pub radius: f32, + pub strength: f32, + hover: Option, + stroke: Option, + target: Option, +} + +impl Default for TerrainPaintState { + fn default() -> Self { + Self { + active: false, + mode: TerrainPaintMode::Paint, + active_layer: 1, + radius: 4.0, + strength: 0.35, + hover: None, + stroke: None, + target: None, + } + } +} + +impl TerrainPaintState { + pub fn start(&mut self, target: Entity, layer_count: usize) { + self.active = true; + self.target = Some(target); + self.active_layer = self.active_layer.min(layer_count.saturating_sub(1)); + } + + pub fn stop(&mut self) { + self.active = false; + self.hover = None; + self.target = None; + } + + pub fn is_stroking(&self) -> bool { + self.stroke.is_some() + } + + pub fn clamp_settings(&mut self) { + self.radius = self.radius.clamp(MIN_RADIUS, MAX_RADIUS); + self.strength = self.strength.clamp(0.01, 1.0); + } +} + +pub struct TerrainPaintPlugin; + +impl Plugin for TerrainPaintPlugin { + fn build(&self, app: &mut App) { + app.init_resource::().add_systems( + Update, + (terrain_paint_input, draw_terrain_paint_preview) + .chain() + .run_if(scene_tools_active), + ); + } +} + +#[allow(clippy::too_many_arguments)] +fn terrain_paint_input( + mut commands: Commands, + mut state: ResMut, + mut selected: ResMut, + keys: Res>, + buttons: Res>, + mut contexts: EguiContexts, + ui_state: Res, + display: Res, + cameras: Query<(&Camera, &GlobalTransform), With>, + mut terrains: Query<(&GlobalTransform, &mut TerrainDesc)>, + mut viewport_click: ResMut, + mut scene_io: ResMut, + mut active_operator: ResMut, +) -> Result { + if !state.active { + return Ok(()); + } + viewport_click.0 = None; + state.clamp_settings(); + let Some(entity) = state.target.or(selected.0) else { + cancel_stroke(&mut state, &mut terrains); + state.stop(); + return Ok(()); + }; + if selected.0 != Some(entity) { + selected.0 = Some(entity); + } + if display.clean_game_view { + cancel_stroke(&mut state, &mut terrains); + state.stop(); + return Ok(()); + } + let ctx = contexts.ctx_mut()?; + let cancel_requested = + keys.just_pressed(KeyCode::Escape) || buttons.just_pressed(MouseButton::Right); + if cancel_requested { + if state.is_stroking() { + cancel_stroke(&mut state, &mut terrains); + scene_io.status = "Terrain paint stroke canceled".into(); + set_status( + &mut active_operator, + OperatorPhase::Canceled, + "Weights restored", + ); + } else { + state.stop(); + scene_io.status = "Terrain paint tool closed".into(); + set_status(&mut active_operator, OperatorPhase::Canceled, "Tool closed"); + } + return Ok(()); + } + let Ok((global, mut terrain)) = terrains.get_mut(entity) else { + state.stroke = None; + state.stop(); + return Ok(()); + }; + let layer_count = terrain.material_layers.len(); + if layer_count == 0 { + state.stop(); + scene_io.status = "Add a terrain material layer before painting".into(); + return Ok(()); + } + state.active_layer = state.active_layer.min(layer_count - 1); + state.hover = ui_state + .viewport_pointer_pos + .and_then(|pointer| active_scene_ray(&cameras, pointer, ui_state.viewport_rect)) + .and_then(|ray| terrain_ray_hit(entity, &terrain, global, ray)); + + if buttons.just_pressed(MouseButton::Left) && !ctx.egui_wants_pointer_input() { + if let Some(hit) = state.hover.filter(|hit| hit.entity == entity) { + let original = terrain.clone(); + terrain.ensure_material_weights(); + apply_weight_dab( + &mut terrain, + hit.local, + state.radius, + state.strength, + state.active_layer, + state.mode, + ); + state.stroke = Some(TerrainPaintStroke { + entity, + original, + last_local: hit.local, + }); + set_status( + &mut active_operator, + OperatorPhase::Preview, + format!( + "{} layer {} in progress", + state.mode.label(), + state.active_layer + 1 + ), + ); + } + } else if buttons.pressed(MouseButton::Left) { + let radius = state.radius; + let strength = state.strength; + let layer = state.active_layer; + let mode = state.mode; + if let (Some(hit), Some(stroke)) = (state.hover, state.stroke.as_mut()) { + if hit.entity == stroke.entity { + let step = (radius * 0.2).max(terrain.sample_spacing * 0.25); + let delta = hit.local - stroke.last_local; + let distance = Vec2::new(delta.x, delta.z).length(); + if distance >= step { + let count = (distance / step).floor() as usize; + for index in 1..=count { + let point = stroke.last_local + delta * (index as f32 / count as f32); + apply_weight_dab(&mut terrain, point, radius, strength, layer, mode); + } + stroke.last_local = hit.local; + } + } + } + } + + if buttons.just_released(MouseButton::Left) { + if let Some(stroke) = state.stroke.take() { + let final_terrain = terrain.clone(); + let mode = state.mode; + commands.queue(move |world: &mut World| { + if let Err(error) = commit_paint_stroke(world, stroke, final_terrain) { + world.resource_mut::().status = + format!("Terrain paint commit failed: {error}"); + } + }); + scene_io.status = format!("{} terrain material weights committed", mode.label()); + set_status( + &mut active_operator, + OperatorPhase::Committed, + format!("{} weights; Ctrl+Z to undo", mode.label()), + ); + } + } + Ok(()) +} + +fn commit_paint_stroke( + world: &mut World, + stroke: TerrainPaintStroke, + final_terrain: TerrainDesc, +) -> Result<(), String> { + if let Ok(mut actor) = world.get_entity_mut(stroke.entity) { + actor.insert(stroke.original); + } + reflected_component_transaction( + world, + stroke.entity, + "Paint Terrain Material", + AUTHORING_COMPONENT_TERRAIN, + COMPONENT_TERRAIN_DESC, + move |world, entity| { + world.entity_mut(entity).insert(final_terrain); + Ok(()) + }, + ) +} + +fn cancel_stroke( + state: &mut TerrainPaintState, + terrains: &mut Query<(&GlobalTransform, &mut TerrainDesc)>, +) { + let Some(stroke) = state.stroke.take() else { + return; + }; + if let Ok((_, mut terrain)) = terrains.get_mut(stroke.entity) { + *terrain = stroke.original; + } +} + +fn set_status(active: &mut ActiveOperator, phase: OperatorPhase, hint: impl Into) { + active.status = Some(OperatorStatus { + id: "terrain.paint".into(), + label: "Terrain Paint".into(), + phase, + hint: hint.into(), + warnings: Vec::new(), + }); +} + +fn apply_weight_dab( + terrain: &mut TerrainDesc, + center: Vec3, + radius: f32, + strength: f32, + active_layer: usize, + mode: TerrainPaintMode, +) { + terrain.ensure_material_weights(); + let layer_count = terrain.material_layers.len().clamp(1, 4); + if active_layer >= layer_count { + return; + } + let radius = radius.max(MIN_RADIUS); + let half_extent = (terrain.resolution - 1) as f32 * terrain.sample_spacing * 0.5; + for z in 0..terrain.resolution { + for x in 0..terrain.resolution { + let sample = Vec2::new( + x as f32 * terrain.sample_spacing - half_extent, + z as f32 * terrain.sample_spacing - half_extent, + ); + let distance = sample.distance(Vec2::new(center.x, center.z)); + if distance > radius { + continue; + } + let amount = strength.clamp(0.0, 1.0) * smooth_falloff(distance / radius); + let index = (z * terrain.resolution + x) as usize; + terrain.material_weights[index] = adjust_weights( + terrain.material_weights[index], + active_layer, + layer_count, + amount, + mode, + ); + } + } +} + +fn adjust_weights( + weights: [u8; 4], + active_layer: usize, + layer_count: usize, + amount: f32, + mode: TerrainPaintMode, +) -> [u8; 4] { + let mut values = weights.map(|value| f32::from(value) / 255.0); + for value in values.iter_mut().skip(layer_count) { + *value = 0.0; + } + let current = values[active_layer]; + let target = match mode { + TerrainPaintMode::Paint => current + (1.0 - current) * amount, + TerrainPaintMode::Erase => current * (1.0 - amount), + }; + let other_sum: f32 = values + .iter() + .take(layer_count) + .enumerate() + .filter(|(index, _)| *index != active_layer) + .map(|(_, value)| *value) + .sum(); + if other_sum <= f32::EPSILON { + if layer_count == 1 { + values[active_layer] = 1.0; + } else { + values.fill(0.0); + values[active_layer] = target; + let fallback = if active_layer == 0 { 1 } else { 0 }; + values[fallback] = 1.0 - target; + } + } else { + let scale = (1.0 - target) / other_sum; + for (index, value) in values.iter_mut().take(layer_count).enumerate() { + if index != active_layer { + *value *= scale; + } + } + values[active_layer] = target; + } + quantize_weights(values, layer_count) +} + +fn quantize_weights(values: [f32; 4], layer_count: usize) -> [u8; 4] { + let mut result = [0_u8; 4]; + let mut fractions = [(0_usize, 0.0_f32); 4]; + let mut assigned = 0_u16; + for index in 0..layer_count { + let scaled = values[index].clamp(0.0, 1.0) * 255.0; + let floor = scaled.floor() as u8; + result[index] = floor; + assigned += u16::from(floor); + fractions[index] = (index, scaled - f32::from(floor)); + } + fractions[..layer_count].sort_by(|a, b| b.1.total_cmp(&a.1)); + for offset in 0..usize::from(255_u16 - assigned) { + result[fractions[offset % layer_count].0] += 1; + } + result +} + +fn draw_terrain_paint_preview(state: Res, mut gizmos: Gizmos) { + if !state.active { + return; + } + let Some(hit) = state.hover else { + return; + }; + let color = match state.mode { + TerrainPaintMode::Paint => Color::srgba(0.22, 0.78, 1.0, 0.95), + TerrainPaintMode::Erase => Color::srgba(1.0, 0.64, 0.22, 0.95), + }; + let rotation = hit.world_rotation * Quat::from_rotation_x(std::f32::consts::FRAC_PI_2); + gizmos + .circle( + Isometry3d::new(hit.world + Vec3::Y * 0.035, rotation), + state.radius, + color, + ) + .resolution(48); + gizmos.sphere( + Isometry3d::from_translation(hit.world + Vec3::Y * 0.04), + 0.06, + color, + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::history::{apply_command_redo, apply_command_undo, EditorHistory}; + use shared::{ActorKind, LevelObject}; + + #[test] + fn paint_and_erase_keep_weights_exactly_normalized() { + let painted = adjust_weights([255, 0, 0, 0], 1, 2, 0.5, TerrainPaintMode::Paint); + assert_eq!(painted.iter().copied().map(u16::from).sum::(), 255); + assert!(painted[1] > 0); + let erased = adjust_weights(painted, 1, 2, 1.0, TerrainPaintMode::Erase); + assert_eq!(erased, [255, 0, 0, 0]); + } + + #[test] + fn dab_only_changes_samples_inside_footprint() { + let mut terrain = TerrainDesc::flat(5); + terrain.material_layers = vec![Default::default(), Default::default()]; + apply_weight_dab( + &mut terrain, + Vec3::ZERO, + 1.1, + 1.0, + 1, + TerrainPaintMode::Paint, + ); + assert!(terrain.material_weights[12][1] > 0); + assert_eq!(terrain.material_weights[0], [255, 0, 0, 0]); + assert!(terrain.validate().is_ok()); + } + + #[test] + fn many_dabs_commit_as_one_undoable_paint_stroke() { + let mut app = App::new(); + app.register_type::() + .register_type::() + .register_type::() + .register_type::(); + let world = app.world_mut(); + world.init_resource::(); + world.init_resource::(); + let mut original = TerrainDesc::flat(9); + original.material_layers = vec![Default::default(), Default::default()]; + let entity = world + .spawn((LevelObject, ActorKind::Terrain, original.clone())) + .id(); + let mut final_terrain = original.clone(); + for x in [-2.0, 0.0, 2.0] { + apply_weight_dab( + &mut final_terrain, + Vec3::new(x, 0.0, 0.0), + 2.5, + 0.5, + 1, + TerrainPaintMode::Paint, + ); + } + world.entity_mut(entity).insert(final_terrain.clone()); + commit_paint_stroke( + world, + TerrainPaintStroke { + entity, + original: original.clone(), + last_local: Vec3::ZERO, + }, + final_terrain.clone(), + ) + .unwrap(); + + assert_eq!(world.resource::().undo_depth(), 1); + assert_eq!(world.get::(entity), Some(&final_terrain)); + apply_command_undo(world); + assert_eq!(world.get::(entity), Some(&original)); + apply_command_redo(world); + assert_eq!(world.get::(entity), Some(&final_terrain)); + } + + #[test] + fn cancel_restores_implicit_pre_stroke_weights() { + fn cancel_once( + mut state: ResMut, + mut terrains: Query<(&GlobalTransform, &mut TerrainDesc)>, + ) { + cancel_stroke(&mut state, &mut terrains); + } + + let mut app = App::new(); + let mut original = TerrainDesc::flat(5); + original.material_layers = vec![Default::default(), Default::default()]; + let mut preview = original.clone(); + apply_weight_dab( + &mut preview, + Vec3::ZERO, + 3.0, + 0.8, + 1, + TerrainPaintMode::Paint, + ); + let entity = app + .world_mut() + .spawn((GlobalTransform::default(), preview)) + .id(); + let mut state = TerrainPaintState::default(); + state.start(entity, 2); + state.stroke = Some(TerrainPaintStroke { + entity, + original: original.clone(), + last_local: Vec3::ZERO, + }); + app.insert_resource(state).add_systems(Update, cancel_once); + + app.update(); + assert_eq!(app.world().get::(entity), Some(&original)); + assert!(!app.world().resource::().is_stroking()); + } +} diff --git a/crates/editor/src/viewport/terrain_sculpt.rs b/crates/editor/src/viewport/terrain_sculpt.rs index 1dffce1..6495217 100644 --- a/crates/editor/src/viewport/terrain_sculpt.rs +++ b/crates/editor/src/viewport/terrain_sculpt.rs @@ -41,11 +41,11 @@ impl TerrainSculptMode { } #[derive(Debug, Clone, Copy)] -struct TerrainHit { - entity: Entity, - local: Vec3, - world: Vec3, - world_rotation: Quat, +pub(super) struct TerrainHit { + pub(super) entity: Entity, + pub(super) local: Vec3, + pub(super) world: Vec3, + pub(super) world_rotation: Quat, } #[derive(Debug, Clone)] @@ -324,7 +324,7 @@ fn sculpt_history_label(mode: TerrainSculptMode) -> &'static str { } } -fn active_scene_ray( +pub(super) fn active_scene_ray( cameras: &Query<(&Camera, &GlobalTransform), With>, pointer: bevy_egui::egui::Pos2, scene_rect: bevy_egui::egui::Rect, @@ -336,7 +336,7 @@ fn active_scene_ray( scene_view_ray(camera, transform, pointer, scene_rect) } -fn terrain_ray_hit( +pub(super) fn terrain_ray_hit( entity: Entity, terrain: &TerrainDesc, global: &GlobalTransform, @@ -495,7 +495,7 @@ fn apply_dab( } } -fn smooth_falloff(normalized_distance: f32) -> f32 { +pub(super) fn smooth_falloff(normalized_distance: f32) -> f32 { let t = (1.0 - normalized_distance).clamp(0.0, 1.0); t * t * (3.0 - 2.0 * t) } diff --git a/crates/shared/src/components.rs b/crates/shared/src/components.rs index f226621..d9efe7a 100644 --- a/crates/shared/src/components.rs +++ b/crates/shared/src/components.rs @@ -856,6 +856,30 @@ impl BrushDesc { } pub const TERRAIN_SCHEMA_VERSION: u32 = 1; +pub const TERRAIN_MATERIAL_LAYER_LIMIT: usize = 4; + +fn default_terrain_layer_uv_scale() -> f32 { + 8.0 +} + +/// One shared project material assigned to a terrain blend channel. +#[derive(Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct TerrainMaterialLayer { + #[serde(default)] + pub material: Option, + #[serde(default = "default_terrain_layer_uv_scale")] + pub uv_scale: f32, +} + +impl Default for TerrainMaterialLayer { + fn default() -> Self { + Self { + material: None, + uv_scale: default_terrain_layer_uv_scale(), + } + } +} /// Persistent height-grid terrain authoring data. Hydration partitions the grid into generated /// render/collider chunks; saved scenes retain only this descriptor. @@ -876,6 +900,13 @@ pub struct TerrainDesc { pub chunk_quads: u32, #[serde(default)] pub base_material: Option, + /// Up to four project Material/Material Instance assignments. Empty retains the legacy + /// `base_material` path and an empty weight map means 100% channel zero. + #[serde(default)] + pub material_layers: Vec, + /// RGBA8 weights per height sample. Channels correspond to `material_layers`. + #[serde(default)] + pub material_weights: Vec<[u8; TERRAIN_MATERIAL_LAYER_LIMIT]>, #[serde(default = "default_true")] pub generate_colliders: bool, #[serde(default = "default_true")] @@ -894,6 +925,8 @@ impl Default for TerrainDesc { height_scale: default_terrain_height_scale(), chunk_quads: default_terrain_chunk_quads(), base_material: None, + material_layers: Vec::new(), + material_weights: Vec::new(), generate_colliders: true, cast_shadows: true, receive_shadows: true, @@ -933,6 +966,44 @@ impl TerrainDesc { if self.heights.iter().any(|height| !height.is_finite()) { return Err("terrain height samples must all be finite".into()); } + if self.material_layers.len() > TERRAIN_MATERIAL_LAYER_LIMIT { + return Err(format!( + "terrain supports at most {TERRAIN_MATERIAL_LAYER_LIMIT} material layers" + )); + } + for (index, layer) in self.material_layers.iter().enumerate() { + if !layer.uv_scale.is_finite() || layer.uv_scale <= 0.0 { + return Err(format!( + "terrain material layer {} UV scale must be finite and positive", + index + 1 + )); + } + } + if !self.material_weights.is_empty() { + if self.material_weights.len() != expected { + return Err(format!( + "terrain resolution {} requires {expected} material weights, found {}", + self.resolution, + self.material_weights.len() + )); + } + if self + .material_weights + .iter() + .any(|weights| weights.iter().copied().map(u16::from).sum::() != 255) + { + return Err("terrain material weights must sum to 255 at every sample".into()); + } + let active_channels = self.material_layers.len().max(1); + if self.material_weights.iter().any(|weights| { + weights + .iter() + .skip(active_channels) + .any(|weight| *weight != 0) + }) { + return Err("terrain material weights use an unassigned layer channel".into()); + } + } if !self.sample_spacing.is_finite() || self.sample_spacing <= 0.0 { return Err("terrain sample spacing must be finite and positive".into()); } @@ -947,6 +1018,39 @@ impl TerrainDesc { } Ok(()) } + + /// Material channels used by hydration, including the legacy base-material fallback. + pub fn effective_material_layers(&self) -> Vec { + if !self.material_layers.is_empty() { + return self.material_layers.clone(); + } + self.base_material + .clone() + .map(|material| TerrainMaterialLayer { + material: Some(material), + ..Default::default() + }) + .into_iter() + .collect() + } + + /// Material weights for one sample, normalized for vertex transport. + pub fn normalized_material_weights(&self, sample_index: usize) -> [f32; 4] { + let weights = self + .material_weights + .get(sample_index) + .copied() + .unwrap_or([255, 0, 0, 0]); + weights.map(|weight| f32::from(weight) / 255.0) + } + + /// Materializes the implicit channel-zero map before an editor paint operation. + pub fn ensure_material_weights(&mut self) { + let expected = self.resolution as usize * self.resolution as usize; + if self.material_weights.len() != expected { + self.material_weights = vec![[255, 0, 0, 0]; expected]; + } + } } fn default_terrain_schema_version() -> u32 { diff --git a/crates/shared/src/hydration/terrain.rs b/crates/shared/src/hydration/terrain.rs index bf3e023..2703e32 100644 --- a/crates/shared/src/hydration/terrain.rs +++ b/crates/shared/src/hydration/terrain.rs @@ -8,7 +8,8 @@ use bevy::prelude::*; use crate::{ authoring_component_active, load_resolved_material_from_path, AuthoringComponentStates, - InspectorOrder, LevelObject, RaytracingExcluded, TerrainDesc, COMPONENT_TERRAIN_DESC, + HydratedTerrainMaterialBinding, InspectorOrder, LevelObject, RaytracingExcluded, TerrainDesc, + COMPONENT_TERRAIN_DESC, }; use super::materials::material_from_desc; @@ -96,9 +97,10 @@ pub fn spawn_terrain_chunks( return; } - let material = terrain - .base_material - .as_ref() + let effective_layers = terrain.effective_material_layers(); + let material = effective_layers + .first() + .and_then(|layer| layer.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)), @@ -135,6 +137,10 @@ pub fn spawn_terrain_chunks( Name::new(format!("Terrain Chunk {chunk_x},{chunk_z}")), Mesh3d(mesh), MeshMaterial3d(material.clone()), + HydratedTerrainMaterialBinding { + owner, + layers: terrain.effective_material_layers(), + }, Transform::default(), Visibility::Visible, RaytracingExcluded, @@ -188,6 +194,7 @@ pub fn terrain_chunk_mesh( 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 mut colors = 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; @@ -209,6 +216,7 @@ pub fn terrain_chunk_mesh( ]); normals.push(normal.to_array()); uvs.push([x as f32 / quad_count, z as f32 / quad_count]); + colors.push(terrain.normalized_material_weights((z * terrain.resolution + x) as usize)); } } @@ -230,6 +238,7 @@ pub fn terrain_chunk_mesh( mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions); mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals); mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs); + mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, colors); mesh.insert_indices(Indices::U32(indices)); if let Err(error) = mesh.generate_tangents() { warn!("Terrain chunk tangent generation failed: {error:?}"); @@ -264,12 +273,61 @@ mod tests { let mut terrain = TerrainDesc::flat(5); terrain.heights[12] = 0.75; terrain.sample_spacing = 2.0; + terrain.material_layers = vec![ + crate::TerrainMaterialLayer { + material: Some(crate::EditorAssetRef::new( + "ground", + "material:source", + "Ground", + )), + uv_scale: 6.0, + }, + crate::TerrainMaterialLayer { + material: Some(crate::EditorAssetRef::new( + "rock", + "material:source", + "Rock", + )), + uv_scale: 12.0, + }, + ]; + terrain.ensure_material_weights(); + terrain.material_weights[12] = [64, 191, 0, 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 chunk_mesh_transports_normalized_material_weights_as_vertex_colors() { + let mut terrain = TerrainDesc::flat(3); + terrain.ensure_material_weights(); + terrain.material_weights[4] = [64, 191, 0, 0]; + let mesh = terrain_chunk_mesh(&terrain, 0, 0, 2, 2).unwrap(); + let Some(bevy::mesh::VertexAttributeValues::Float32x4(colors)) = + mesh.attribute(Mesh::ATTRIBUTE_COLOR) + else { + panic!("terrain chunks should carry RGBA layer weights"); + }; + assert_eq!(colors.len(), 9); + assert!((colors[4][0] - 64.0 / 255.0).abs() < 0.0001); + assert!((colors[4][1] - 191.0 / 255.0).abs() < 0.0001); + } + + #[test] + fn descriptor_rejects_non_normalized_material_weights() { + let mut terrain = TerrainDesc::flat(3); + terrain.material_weights = vec![[128, 128, 0, 0]; 9]; + assert!(terrain.validate().unwrap_err().contains("sum to 255")); + + terrain.material_weights = vec![[127, 0, 128, 0]; 9]; + assert!(terrain + .validate() + .unwrap_err() + .contains("unassigned layer channel")); + } + #[test] fn hydration_generates_runtime_only_chunks_and_cleans_up() { let mut app = App::new(); diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index 88392a5..8fd3b7b 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -114,6 +114,7 @@ impl Plugin for SharedTypesPlugin { .register_type::() .register_type::() .register_type::() + .register_type::() .register_type::() .register_type::() .register_type::() diff --git a/crates/shared/src/renderer_material.rs b/crates/shared/src/renderer_material.rs index e210c0d..8acb173 100644 --- a/crates/shared/src/renderer_material.rs +++ b/crates/shared/src/renderer_material.rs @@ -3,7 +3,10 @@ use bevy::prelude::*; use serde::{Deserialize, Serialize}; -use crate::{ComponentInstanceId, EditorAssetRef, MaterialParameter, MaterialTextureBinding}; +use crate::{ + ComponentInstanceId, EditorAssetRef, MaterialParameter, MaterialTextureBinding, + TerrainMaterialLayer, +}; /// A stable reference to a project material, material instance, or imported material subasset. /// @@ -112,6 +115,23 @@ pub struct HydratedRendererMaterialBinding { pub effective_material: Option, } +/// Runtime ownership marker consumed by the project terrain-layer renderer. +#[derive(Component, Reflect, Debug, Clone, PartialEq)] +#[reflect(Component, Default, Debug, PartialEq)] +pub struct HydratedTerrainMaterialBinding { + pub owner: Entity, + pub layers: Vec, +} + +impl Default for HydratedTerrainMaterialBinding { + fn default() -> Self { + Self { + owner: Entity::PLACEHOLDER, + layers: Vec::new(), + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/docs/README.md b/docs/README.md index 499475e..d83cf10 100644 --- a/docs/README.md +++ b/docs/README.md @@ -54,6 +54,7 @@ Immutable-style log of significant decisions. Add a new numbered ADR when changi | [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 | +| [0040](adr/0040-terrain-material-layer-weights.md) | Four-channel terrain material layers, compact normalized weights, and raster transport | ## Editor framework @@ -83,6 +84,7 @@ Immutable-style log of significant decisions. Add a new numbered ADR when changi | [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/terrain-sculpt-tools/](editor/evaluations/terrain-sculpt-tools/) | Live screenshot and acceptance results for modal terrain sculpt tools | +| [editor/evaluations/terrain-material-layers/](editor/evaluations/terrain-material-layers/) | Live screenshot and acceptance results for terrain material assignment, blending, painting, and history | | [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 | @@ -106,6 +108,7 @@ Detailed milestone and feature plans live in [`.cursor/plans/`](../.cursor/plans | `source_control_collaboration_safety_*.plan.md` | Exact authored-file guards, observational Git status, conflict recovery, and provider contract | | `production_readiness_acceptance_*.plan.md` | Release-candidate evidence matrix, blocker sequence, clean-checkout checks, soak, budgets, and independent sign-off | | `material_library_and_targeted_drop_*.plan.md` | Dedicated Material Library, exact viewport slot/primitive/brush targeting, hover preview, cancel, and grouped history | +| `terrain_material_layers_*.plan.md` | Terrain shared-material layers, normalized weights, blended hydration, and modal painting | ## Crate responsibilities (quick reference) diff --git a/docs/adr/0040-terrain-material-layer-weights.md b/docs/adr/0040-terrain-material-layer-weights.md new file mode 100644 index 0000000..454334b --- /dev/null +++ b/docs/adr/0040-terrain-material-layer-weights.md @@ -0,0 +1,36 @@ +# ADR 0040: Terrain Material Layers And Weight Transport + +## Status + +Accepted + +## Context + +Terrain needs multiple shared project materials, editable weight painting, deterministic persistence, +and raster behavior that remains consistent between the editor and game. Duplicating full material +descriptors into scenes would break the shared Material/Material Instance contract. Storing floating +point weights would also create avoidable scene churn and normalization drift. + +## Decision + +`TerrainDesc` stores at most four `TerrainMaterialLayer` entries referencing shared assets and one +RGBA8 weight tuple per height sample. Every explicit tuple sums to 255; an absent weight array means +full channel-zero coverage for backward compatibility. Generated terrain chunks transport normalized +weights through `Mesh::ATTRIBUTE_COLOR`. + +`shared` owns persistence, validation, chunk generation, and a runtime-only terrain material binding. +`blacksite_surface` owns the terrain-specific `ExtendedMaterial` and blends each resolved layer's +albedo, tangent-space normal, metallic, and roughness inputs. Invalid or unavailable references leave +the existing visible terrain fallback in place and emit diagnostics. Terrain remains excluded from +Solari geometry until a matching ray-tracing evaluator exists. + +Editor paint strokes materialize the implicit default map only when needed. Each pointer stroke is a +single reflected `TerrainDesc` transaction; cancel restores the exact original descriptor. + +## Consequences + +- Four channels give fixed shader bindings, compact deterministic scenes, and simple normalization. +- Adding more than four simultaneous layers requires a deliberate schema and renderer revision. +- Weight resolution follows the height grid; independent high-resolution splat maps remain future work. +- Terrain raster rendering gains project-material texture parity while Solari terrain parity remains an + explicit later milestone rather than using a semantically different proxy. diff --git a/docs/editor/README.md b/docs/editor/README.md index 7d6ea2d..1ef140c 100644 --- a/docs/editor/README.md +++ b/docs/editor/README.md @@ -33,6 +33,7 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a | [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/terrain-sculpt-tools/](evaluations/terrain-sculpt-tools/) | Live screenshot and verification record for modal sculpt controls, footprint, and stroke history | +| [evaluations/terrain-material-layers/](evaluations/terrain-material-layers/) | Live screenshot and verification record for blended layers, assignment, painting, and history | | [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 | @@ -42,7 +43,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 | +| `shared::components::TerrainDesc` / `shared::hydration::terrain` / `blacksite_surface` | Persisted height/layer grids, generated chunks, and raster blending | terrain.md, ADR 0039, ADR 0040 | | `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 | diff --git a/docs/editor/evaluations/terrain-material-layers/README.md b/docs/editor/evaluations/terrain-material-layers/README.md new file mode 100644 index 0000000..120a45d --- /dev/null +++ b/docs/editor/evaluations/terrain-material-layers/README.md @@ -0,0 +1,36 @@ +# Terrain Material Layers Acceptance + +## Evidence + +![Terrain layer paint stroke](terrain-material-paint-stroke.png) + +The native Wayland editor is shown with the deterministic terrain showcase selected, two shared +material layers visible in the Inspector, Paint active in the existing horizontal viewport toolbar, +and the cyan terrain-following brush footprint over the blended PBR surface. + +## Live Acceptance + +- `Concrete` and `Surface Tint` blend continuously across all four generated chunks. +- Layer assignment, clear, reorder, remove, UV tiling, and per-layer sample coverage are visible in + the Terrain card without nesting a second panel. +- Paint and Erase share the existing horizontal toolbar with layer, radius, and strength controls. +- A Paint drag changed layer-two coverage from 13/25 to 16/25 samples and produced exactly one + `Undo: Paint Terrain Material` history entry. +- Ctrl+Z restored 13/25 samples; Ctrl+Y restored 16/25 samples. +- The cyan footprint followed the actual sampled terrain surface and the transform gizmo remained + absent while Paint owned viewport input. +- Forward PBR and the project `blacksite_surface` terrain extension compiled without GPU pipeline + errors. The invalid deferred-only prepass override found during QA was removed from both project + material extensions. + +## Automated Coverage + +- Terrain layer/weight RON round-trip and structural validation. +- RGBA8 normalization rejection and normalized vertex-color transport. +- Paint/Erase normalization and brush-footprint isolation. +- Multi-dab single-transaction undo/redo. +- Exact restoration of an implicit pre-stroke weight map on cancel. + +Packaged build tests remain intentionally skipped per current project direction. Source workspace +format/check/clippy/tests, project validation, navigation bake validation, and diff hygiene are the +required gate for this ticket. diff --git a/docs/editor/evaluations/terrain-material-layers/terrain-material-paint-stroke.png b/docs/editor/evaluations/terrain-material-layers/terrain-material-paint-stroke.png new file mode 100644 index 0000000..346fee2 --- /dev/null +++ b/docs/editor/evaluations/terrain-material-layers/terrain-material-paint-stroke.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ead056b713942bd89f131325880becfbccf9322469112fedfada96ebe3db529c +size 1198141 diff --git a/docs/editor/terrain.md b/docs/editor/terrain.md index cf6d33e..c9ceb5b 100644 --- a/docs/editor/terrain.md +++ b/docs/editor/terrain.md @@ -24,6 +24,20 @@ closes the sculpt tool. Flatten captures the height under the initial press. Smo copy of the current neighborhood per dab, and Noise uses a deterministic per-stroke seed so authored results remain reproducible. +## Material Layers And Paint Workflow + +The Terrain card owns up to four shared Material or Material Instance layers. Use the plus icon to +add a channel, then browse or drag a material into its selector. Each layer exposes independent UV +tiling and can be reordered or removed; channel weights move with the layer. The first added layer +adopts a legacy Base Material assignment so existing terrain converts without visual loss. + +With at least two layers assigned, select the paint-brush button beside Sculpt in the existing +horizontal viewport toolbar. Choose **Paint** or **Erase**, select the target layer, then LMB-drag. +Paint increases the selected channel while proportionally reducing the others; Erase redistributes +weight to the remaining channels. Every sample stays normalized to 255, the terrain-following cyan +or amber footprint previews the brush, and release records one undo entry. Escape or right-click +restores the exact pre-stroke weights or closes the idle tool. + `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 @@ -32,19 +46,24 @@ stripping without requiring external assets. ## Data And Hydration - Heights are finite normalized values multiplied by Height Scale at hydration time. +- Up to four Material/Material Instance references remain shared assets rather than copied material + descriptors. RGBA8 weights are stored per height sample; an absent array means full layer zero. +- Generated chunk vertex colors carry normalized weights into the `blacksite_surface` terrain + material, which blends layer base color/texture, normal, metallic, and roughness inputs. - 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. +- Missing, unassigned, or invalid layer references produce a clear diagnostic 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. + Auto/Solari is active. Raster material-layer parity is shipped; matching Solari terrain evaluation + remains explicit future work, and the editor never substitutes a semantically different proxy. -Material layers and weight painting are tracked by Gitea `#24`. The persistence decision is recorded in -[ADR 0039](../adr/0039-inline-height-grid-terrain-foundation.md). +The terrain foundation is recorded in [ADR 0039](../adr/0039-inline-height-grid-terrain-foundation.md), +and layer weight persistence/render transport in +[ADR 0040](../adr/0040-terrain-material-layer-weights.md). Live native-Wayland evidence and focused verification are recorded in the [terrain foundation evaluation](evaluations/terrain-foundation/).