Add terrain material layer painting
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run

This commit is contained in:
Rbanh 2026-07-12 21:00:13 -04:00
parent 293811aecd
commit d1a56f77cc
23 changed files with 1730 additions and 62 deletions

View File

@ -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.

View File

@ -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)

View File

@ -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"),

View File

@ -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<vec4<f32>, 4>,
properties: array<vec4<f32>, 4>,
uv_scales: vec4<f32>,
base_texture_enabled: vec4<f32>,
}
@group(#{MATERIAL_BIND_GROUP}) @binding(100) var<uniform> terrain_layers: TerrainLayerUniform;
@group(#{MATERIAL_BIND_GROUP}) @binding(101) var base_color0: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(102) var base_sampler0: sampler;
@group(#{MATERIAL_BIND_GROUP}) @binding(103) var normal0: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(104) var normal_sampler0: sampler;
@group(#{MATERIAL_BIND_GROUP}) @binding(105) var base_color1: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(106) var base_sampler1: sampler;
@group(#{MATERIAL_BIND_GROUP}) @binding(107) var normal1: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(108) var normal_sampler1: sampler;
@group(#{MATERIAL_BIND_GROUP}) @binding(109) var base_color2: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(110) var base_sampler2: sampler;
@group(#{MATERIAL_BIND_GROUP}) @binding(111) var normal2: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(112) var normal_sampler2: sampler;
@group(#{MATERIAL_BIND_GROUP}) @binding(113) var base_color3: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(114) var base_sampler3: sampler;
@group(#{MATERIAL_BIND_GROUP}) @binding(115) var normal3: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(116) var normal_sampler3: sampler;
fn layer_base(index: u32, uv: vec2<f32>) -> vec4<f32> {
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<f32>) -> vec3<f32> {
var sample = vec3<f32>(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<f32>(0.0);
#endif
#ifdef VERTEX_COLORS
var weights = max(in.color, vec4<f32>(0.0));
#else
var weights = vec4<f32>(1.0, 0.0, 0.0, 0.0);
#endif
let enabled = vec4<f32>(
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<f32>(1.0));
weights = select(vec4<f32>(1.0, 0.0, 0.0, 0.0), weights / total, total > 0.0001);
var color = vec4<f32>(0.0);
var normal_ts = vec3<f32>(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<f32>(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<f32>(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
}

View File

@ -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<Handle<Image>>,
#[texture(103)]
#[sampler(104)]
pub normal0: Option<Handle<Image>>,
#[texture(105)]
#[sampler(106)]
pub base_color1: Option<Handle<Image>>,
#[texture(107)]
#[sampler(108)]
pub normal1: Option<Handle<Image>>,
#[texture(109)]
#[sampler(110)]
pub base_color2: Option<Handle<Image>>,
#[texture(111)]
#[sampler(112)]
pub normal2: Option<Handle<Image>>,
#[texture(113)]
#[sampler(114)]
pub base_color3: Option<Handle<Image>>,
#[texture(115)]
#[sampler(116)]
pub normal3: Option<Handle<Image>>,
}
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<StandardMaterial, TerrainLayerExtension>;
#[derive(Debug, Clone)]
struct TerrainLayerCacheEntry {
layers: Vec<TerrainMaterialLayer>,
handle: Handle<TerrainLayerMaterial>,
revision: u64,
}
#[derive(Resource, Default)]
struct TerrainLayerMaterialCache(HashMap<Entity, TerrainLayerCacheEntry>);
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::<SurfaceMaterial>::default())
.add_plugins(MaterialPlugin::<TerrainLayerMaterial>::default())
.init_resource::<SurfaceMaterialCache>()
.init_resource::<TerrainLayerMaterialCache>()
.init_resource::<SurfaceDiagnostics>()
.init_resource::<SurfaceEvaluatorRegistry>()
.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<AssetServer>,
mut materials: ResMut<Assets<TerrainLayerMaterial>>,
mut cache: ResMut<TerrainLayerMaterialCache>,
mut diagnostics: ResMut<SurfaceDiagnostics>,
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::<MeshMaterial3d<StandardMaterial>>()
.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::<Vec<_>>();
dependencies.sort();
dependencies.dedup();
dependency_revision(&dependencies)
}
fn build_terrain_layer_material(
asset_server: &AssetServer,
layers: &[TerrainMaterialLayer],
diagnostics: &mut Vec<String>,
) -> 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<String>,
) -> 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::<SurfaceUniform>(), 400);
}
#[test]
fn material_extensions_leave_non_deferred_prepasses_to_standard_material() {
assert!(matches!(
<SurfaceExtension as MaterialExtension>::prepass_fragment_shader(),
ShaderRef::Default
));
assert!(matches!(
<TerrainLayerExtension as MaterialExtension>::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::<AssetServer>();
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]
);
}
}

View File

@ -1415,17 +1415,14 @@ 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 {
// 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)),
}
Ok(())
@ -1451,16 +1448,11 @@ pub fn apply_reflected_default(
.data::<ReflectDefault>()
.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,
);
}
Ok(())
})
}

View File

@ -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)

View File

@ -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::<EditorAssets>()
.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::<TerrainDesc>(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.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| material.label.as_str())
.filter(|label| !label.is_empty())
.unwrap_or("Terrain fallback"),
.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() {

View File

@ -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::<TerrainPaintState>() 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::<crate::selection::SelectedEntity>()
.0
.filter(|entity| world.get::<shared::TerrainDesc>(*entity).is_some());
let terrain_layer_labels = terrain_selected
.and_then(|entity| world.get::<shared::TerrainDesc>(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::<Vec<_>>()
})
.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::<TerrainSculptState>().active;
if terrain_selected.is_some() || sculpt_active {
let paint_active = world.resource::<TerrainPaintState>().active;
if terrain_selected.is_some() || sculpt_active || paint_active {
ui.separator();
let stroking = world.resource::<TerrainSculptState>().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::<TerrainPaintState>().stop();
world.resource_mut::<BrushToolState>().cancel();
world
.resource_mut::<crate::operators::ActiveOperator>()
@ -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::<TerrainPaintState>().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::<ViewportClick>().0 = None;
}
if paint_button_clicked && !paint_stroking {
let layer_count = terrain_layer_labels.len();
let state = &mut *world.resource_mut::<TerrainPaintState>();
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::<TerrainSculptState>().stop();
world.resource_mut::<BrushToolState>().cancel();
world
.resource_mut::<crate::operators::ActiveOperator>()
.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::<TerrainPaintState>().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::<TerrainPaintState>().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::<TerrainPaintState>().radius;
let mut strength = world.resource::<TerrainPaintState>().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::<TerrainPaintState>();
state.mode = mode;
state.active_layer = active_layer;
state.radius = radius;
state.strength = strength;
state.clamp_settings();
}
}
let clean_game_view = world.resource::<ViewportDisplayMode>().clean_game_view;

View File

@ -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;

View File

@ -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<EditorOnly>>,
material_drop: Option<Res<'w, MaterialDropState>>,
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<ViewportDisplayMode>,
material_drop: Option<Res<MaterialDropState>>,
terrain_sculpt: Res<TerrainSculptState>,
terrain_paint: Res<TerrainPaintState>,
) -> 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<ViewportDisplayMode>,
brush_mode: Res<BrushEditMode>,
terrain_sculpt: Res<TerrainSculptState>,
terrain_paint: Res<TerrainPaintState>,
) {
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::<GizmoTarget>();
@ -503,6 +511,7 @@ mod tests {
.init_resource::<ViewportDisplayMode>()
.init_resource::<BrushEditMode>()
.init_resource::<TerrainSculptState>()
.init_resource::<TerrainPaintState>()
.add_systems(Update, sync_gizmo_targets);
app.update();
@ -534,6 +543,30 @@ mod tests {
.init_resource::<SelectedEntity>()
.init_resource::<ViewportDisplayMode>()
.init_resource::<BrushEditMode>()
.init_resource::<TerrainPaintState>()
.add_systems(Update, sync_gizmo_targets);
app.update();
assert!(app.world().get::<GizmoTarget>(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::<SelectedEntity>()
.init_resource::<ViewportDisplayMode>()
.init_resource::<BrushEditMode>()
.init_resource::<TerrainSculptState>()
.add_systems(Update, sync_gizmo_targets);
app.update();

View File

@ -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<TerrainHit>,
stroke: Option<TerrainPaintStroke>,
target: Option<Entity>,
}
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::<TerrainPaintState>().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<TerrainPaintState>,
mut selected: ResMut<SelectedEntity>,
keys: Res<ButtonInput<KeyCode>>,
buttons: Res<ButtonInput<MouseButton>>,
mut contexts: EguiContexts,
ui_state: Res<UiState>,
display: Res<ViewportDisplayMode>,
cameras: Query<(&Camera, &GlobalTransform), With<EditorCamera>>,
mut terrains: Query<(&GlobalTransform, &mut TerrainDesc)>,
mut viewport_click: ResMut<ViewportClick>,
mut scene_io: ResMut<SceneIo>,
mut active_operator: ResMut<ActiveOperator>,
) -> 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::<SceneIo>().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<String>) {
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<TerrainPaintState>, 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::<u16>(), 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::<ActorKind>()
.register_type::<TerrainDesc>()
.register_type::<shared::TerrainMaterialLayer>()
.register_type::<shared::EditorAssetRef>();
let world = app.world_mut();
world.init_resource::<EditorHistory>();
world.init_resource::<SceneIo>();
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::<EditorHistory>().undo_depth(), 1);
assert_eq!(world.get::<TerrainDesc>(entity), Some(&final_terrain));
apply_command_undo(world);
assert_eq!(world.get::<TerrainDesc>(entity), Some(&original));
apply_command_redo(world);
assert_eq!(world.get::<TerrainDesc>(entity), Some(&final_terrain));
}
#[test]
fn cancel_restores_implicit_pre_stroke_weights() {
fn cancel_once(
mut state: ResMut<TerrainPaintState>,
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::<TerrainDesc>(entity), Some(&original));
assert!(!app.world().resource::<TerrainPaintState>().is_stroking());
}
}

View File

@ -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<EditorCamera>>,
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)
}

View File

@ -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<EditorAssetRef>,
#[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<EditorAssetRef>,
/// 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<TerrainMaterialLayer>,
/// 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::<u16>() != 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<TerrainMaterialLayer> {
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 {

View File

@ -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();

View File

@ -114,6 +114,7 @@ impl Plugin for SharedTypesPlugin {
.register_type::<PrimitiveShape>()
.register_type::<BrushDesc>()
.register_type::<TerrainDesc>()
.register_type::<TerrainMaterialLayer>()
.register_type::<BrushKind>()
.register_type::<BrushPlaneDesc>()
.register_type::<BrushFaceDesc>()

View File

@ -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<MaterialRef>,
}
/// 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<TerrainMaterialLayer>,
}
impl Default for HydratedTerrainMaterialBinding {
fn default() -> Self {
Self {
owner: Entity::PLACEHOLDER,
layers: Vec::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;

View File

@ -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)

View File

@ -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.

View File

@ -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 |

View File

@ -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.

Binary file not shown.

View File

@ -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/).