Add terrain material layer painting
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
This commit is contained in:
parent
293811aecd
commit
d1a56f77cc
28
.cursor/plans/terrain_material_layers_2026-07-12.plan.md
Normal file
28
.cursor/plans/terrain_material_layers_2026-07-12.plan.md
Normal 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.
|
||||||
@ -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 | 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 |
|
| 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 |
|
| 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 |
|
| Audio Source / Listener inspector | Assign and audition clips; edit gain, pitch, loop/autoplay, spatial blend, attenuation, bus, listener priority, and ear gap |
|
||||||
| Edit → Project Settings… | Edit `assets/project.ron` rendering, audio buses, physics, and input |
|
| Edit → Project Settings… | Edit `assets/project.ron` rendering, audio buses, physics, and input |
|
||||||
|
|
||||||
@ -402,6 +403,7 @@ crates/
|
|||||||
- [x] Native Bevy scene New/Open/Save/Save As with dirty title tracking
|
- [x] Native Bevy scene New/Open/Save/Save As with dirty title tracking
|
||||||
- [x] First-class height-grid Terrain actor with deterministic generated render/collider chunks, reflected history, validation, and a committed showcase fixture ([ADR 0039](docs/adr/0039-inline-height-grid-terrain-foundation.md), [terrain guide](docs/editor/terrain.md), [Gitea #22](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/22))
|
- [x] 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] 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] Non-blocking native file/folder/confirmation broker across scene, asset, prefab, composition, collaboration, and Project Browser workflows ([ADR 0038](docs/adr/0038-non-blocking-native-dialog-broker.md), [workflow guide](docs/editor/native-dialogs.md), [Gitea #52](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/52))
|
||||||
- [x] Asset import, static mesh/prefab placement, texture assignment, and selection export
|
- [x] Asset import, static mesh/prefab placement, texture assignment, and selection export
|
||||||
- [x] PIE player-only snapshot/restore (authored `LevelObject` edits persist on stop)
|
- [x] PIE player-only snapshot/restore (authored `LevelObject` edits persist on stop)
|
||||||
|
|||||||
@ -30,6 +30,33 @@
|
|||||||
label: "surface_tint_instance",
|
label: "surface_tint_instance",
|
||||||
source_path: Some("assets/materials/surface_tint_instance.ron"),
|
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,
|
generate_colliders: true,
|
||||||
cast_shadows: true,
|
cast_shadows: true,
|
||||||
receive_shadows: true,
|
receive_shadows: true,
|
||||||
@ -39,7 +66,7 @@
|
|||||||
"bevy_ecs::name::Name": "Terrain Sun",
|
"bevy_ecs::name::Name": "Terrain Sun",
|
||||||
"bevy_transform::components::transform::Transform": (
|
"bevy_transform::components::transform::Transform": (
|
||||||
translation: (0.0, 8.0, 0.0),
|
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),
|
scale: (1.0, 1.0, 1.0),
|
||||||
),
|
),
|
||||||
"shared::components::ActorId": ("terrain-showcase-sun"),
|
"shared::components::ActorId": ("terrain-showcase-sun"),
|
||||||
|
|||||||
116
assets/shaders/terrain_layers.wgsl
Normal file
116
assets/shaders/terrain_layers.wgsl
Normal 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
|
||||||
|
}
|
||||||
@ -17,15 +17,92 @@ use bevy::render::render_resource::{
|
|||||||
};
|
};
|
||||||
use bevy::shader::{Shader, ShaderRef};
|
use bevy::shader::{Shader, ShaderRef};
|
||||||
use shared::{
|
use shared::{
|
||||||
asset_server_path, material_from_desc, HydratedRendererMaterialBinding, MaterialAsset,
|
asset_server_path, material_from_desc, HydratedRendererMaterialBinding,
|
||||||
MaterialInstanceAsset, MaterialParameterValue, MaterialRef, ShaderPropertyType,
|
HydratedTerrainMaterialBinding, MaterialAsset, MaterialDesc, MaterialInstanceAsset,
|
||||||
ShaderSchemaAsset, MATERIAL_INSTANCE_SCHEMA_VERSION,
|
MaterialParameterValue, MaterialRef, ShaderPropertyType, ShaderSchemaAsset,
|
||||||
|
TerrainMaterialLayer, MATERIAL_INSTANCE_SCHEMA_VERSION, TERRAIN_MATERIAL_LAYER_LIMIT,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const SURFACE_ABI_VERSION: u32 = 1;
|
pub const SURFACE_ABI_VERSION: u32 = 1;
|
||||||
pub const SURFACE_PARAMETER_LANES: usize = 16;
|
pub const SURFACE_PARAMETER_LANES: usize = 16;
|
||||||
pub const SURFACE_TEXTURE_SLOTS: usize = 8;
|
pub const SURFACE_TEXTURE_SLOTS: usize = 8;
|
||||||
pub const DEFAULT_SURFACE_SHADER_PATH: &str = "shaders/blacksite_surface.wgsl";
|
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)]
|
#[derive(ShaderType, Reflect, Debug, Clone, Copy, PartialEq)]
|
||||||
pub struct SurfaceUniform {
|
pub struct SurfaceUniform {
|
||||||
@ -121,10 +198,6 @@ impl MaterialExtension for SurfaceExtension {
|
|||||||
DEFAULT_SURFACE_SHADER_PATH.into()
|
DEFAULT_SURFACE_SHADER_PATH.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn prepass_fragment_shader() -> ShaderRef {
|
|
||||||
DEFAULT_SURFACE_SHADER_PATH.into()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn specialize(
|
fn specialize(
|
||||||
_pipeline: &MaterialExtensionPipeline,
|
_pipeline: &MaterialExtensionPipeline,
|
||||||
descriptor: &mut RenderPipelineDescriptor,
|
descriptor: &mut RenderPipelineDescriptor,
|
||||||
@ -179,10 +252,180 @@ pub struct SurfaceMaterialPlugin;
|
|||||||
impl Plugin for SurfaceMaterialPlugin {
|
impl Plugin for SurfaceMaterialPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.add_plugins(MaterialPlugin::<SurfaceMaterial>::default())
|
app.add_plugins(MaterialPlugin::<SurfaceMaterial>::default())
|
||||||
|
.add_plugins(MaterialPlugin::<TerrainLayerMaterial>::default())
|
||||||
.init_resource::<SurfaceMaterialCache>()
|
.init_resource::<SurfaceMaterialCache>()
|
||||||
|
.init_resource::<TerrainLayerMaterialCache>()
|
||||||
.init_resource::<SurfaceDiagnostics>()
|
.init_resource::<SurfaceDiagnostics>()
|
||||||
.init_resource::<SurfaceEvaluatorRegistry>()
|
.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!(SURFACE_TEXTURE_SLOTS, 8);
|
||||||
assert_eq!(std::mem::size_of::<SurfaceUniform>(), 400);
|
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]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1415,17 +1415,14 @@ pub fn apply_reflected_component(
|
|||||||
let reflected = TypedReflectDeserializer::new(registration, &type_registry)
|
let reflected = TypedReflectDeserializer::new(registration, &type_registry)
|
||||||
.deserialize(&mut deserializer)
|
.deserialize(&mut deserializer)
|
||||||
.map_err(|error| error.to_string())?;
|
.map_err(|error| error.to_string())?;
|
||||||
let exists = reflect_component.reflect(world.entity(entity)).is_some();
|
// Reflected `apply` merges list fields and cannot restore a shorter snapshot.
|
||||||
if exists {
|
// Inserting the captured concrete component replaces the value atomically.
|
||||||
reflect_component.apply(world.entity_mut(entity), reflected.as_ref());
|
|
||||||
} else {
|
|
||||||
reflect_component.insert(
|
reflect_component.insert(
|
||||||
&mut world.entity_mut(entity),
|
&mut world.entity_mut(entity),
|
||||||
reflected.as_ref(),
|
reflected.as_ref(),
|
||||||
&type_registry,
|
&type_registry,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
None => reflect_component.remove(&mut world.entity_mut(entity)),
|
None => reflect_component.remove(&mut world.entity_mut(entity)),
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@ -1451,16 +1448,11 @@ pub fn apply_reflected_default(
|
|||||||
.data::<ReflectDefault>()
|
.data::<ReflectDefault>()
|
||||||
.ok_or_else(|| format!("component `{type_path}` has no reflected Default"))?
|
.ok_or_else(|| format!("component `{type_path}` has no reflected Default"))?
|
||||||
.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(
|
reflect_component.insert(
|
||||||
&mut world.entity_mut(entity),
|
&mut world.entity_mut(entity),
|
||||||
default.as_ref(),
|
default.as_ref(),
|
||||||
&type_registry,
|
&type_registry,
|
||||||
);
|
);
|
||||||
}
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -43,6 +43,7 @@ pub use viewport::render_view;
|
|||||||
pub use viewport::rendering_diagnostics;
|
pub use viewport::rendering_diagnostics;
|
||||||
pub use viewport::selection;
|
pub use viewport::selection;
|
||||||
pub use viewport::selection_outline;
|
pub use viewport::selection_outline;
|
||||||
|
pub use viewport::terrain_paint;
|
||||||
pub use viewport::terrain_sculpt;
|
pub use viewport::terrain_sculpt;
|
||||||
pub use viewport::visualizers;
|
pub use viewport::visualizers;
|
||||||
|
|
||||||
@ -80,6 +81,7 @@ use selection_outline::SelectionOutlinePlugin;
|
|||||||
use session::EditorSessionPlugin;
|
use session::EditorSessionPlugin;
|
||||||
use settings_ui::SettingsUiPlugin;
|
use settings_ui::SettingsUiPlugin;
|
||||||
use state::EditorStatePlugin;
|
use state::EditorStatePlugin;
|
||||||
|
use terrain_paint::TerrainPaintPlugin;
|
||||||
use terrain_sculpt::TerrainSculptPlugin;
|
use terrain_sculpt::TerrainSculptPlugin;
|
||||||
use ui::EditorUiPlugin;
|
use ui::EditorUiPlugin;
|
||||||
use viewport::ViewportPlugin;
|
use viewport::ViewportPlugin;
|
||||||
@ -112,6 +114,7 @@ impl PluginGroup for EditorPluginGroup {
|
|||||||
.add(BrushEditPlugin)
|
.add(BrushEditPlugin)
|
||||||
.add(BrushToolPlugin)
|
.add(BrushToolPlugin)
|
||||||
.add(TerrainSculptPlugin)
|
.add(TerrainSculptPlugin)
|
||||||
|
.add(TerrainPaintPlugin)
|
||||||
.add(EditorCameraPlugin)
|
.add(EditorCameraPlugin)
|
||||||
.add(AudioPreviewPlugin)
|
.add(AudioPreviewPlugin)
|
||||||
.add(ActorIconsPlugin)
|
.add(ActorIconsPlugin)
|
||||||
|
|||||||
@ -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) {
|
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 {
|
let Some(mut terrain) = world.get::<TerrainDesc>(entity).cloned() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let original = terrain.clone();
|
let original = terrain.clone();
|
||||||
let mut changed = false;
|
let mut changed = false;
|
||||||
let mut requested_resolution = terrain.resolution;
|
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(
|
let card = component_card_context(
|
||||||
world,
|
world,
|
||||||
entity,
|
entity,
|
||||||
@ -3526,6 +3537,7 @@ fn terrain_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
|
|||||||
let replacement = TerrainDesc::flat(requested_resolution);
|
let replacement = TerrainDesc::flat(requested_resolution);
|
||||||
terrain.resolution = replacement.resolution;
|
terrain.resolution = replacement.resolution;
|
||||||
terrain.heights = replacement.heights;
|
terrain.heights = replacement.heights;
|
||||||
|
terrain.material_weights.clear();
|
||||||
terrain.chunk_quads = terrain.chunk_quads.min(terrain.resolution - 1).max(1);
|
terrain.chunk_quads = terrain.chunk_quads.min(terrain.resolution - 1).max(1);
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
@ -3573,16 +3585,132 @@ fn terrain_editor_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
|
|||||||
.changed();
|
.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(
|
ui.label(
|
||||||
|
egui::RichText::new(
|
||||||
terrain
|
terrain
|
||||||
.base_material
|
.base_material
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|material| material.label.as_str())
|
.map(|material| format!("Legacy base: {}", material.label))
|
||||||
.filter(|label| !label.is_empty())
|
.unwrap_or_else(|| "No layers assigned; visible terrain fallback".into()),
|
||||||
.unwrap_or("Terrain fallback"),
|
)
|
||||||
|
.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() {
|
match terrain.validate() {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
ui.label(
|
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);
|
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 {
|
if changed && terrain != original {
|
||||||
let _ = reflected_component_transaction(
|
let _ = reflected_component_transaction(
|
||||||
world,
|
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) {
|
fn brush_validation_ui(ui: &mut egui::Ui, brush: &BrushDesc) {
|
||||||
let report = validate_brush(brush);
|
let report = validate_brush(brush);
|
||||||
if report.diagnostics.is_empty() {
|
if report.diagnostics.is_empty() {
|
||||||
|
|||||||
@ -20,6 +20,7 @@ use crate::state::PlayPossession;
|
|||||||
use crate::viewport::actor_icons::ActorIconSettings;
|
use crate::viewport::actor_icons::ActorIconSettings;
|
||||||
use crate::viewport::brush_edit::{BrushEditMode, BrushElementSelection};
|
use crate::viewport::brush_edit::{BrushEditMode, BrushElementSelection};
|
||||||
use crate::viewport::brush_tool::{BrushToolPhase, BrushToolState};
|
use crate::viewport::brush_tool::{BrushToolPhase, BrushToolState};
|
||||||
|
use crate::viewport::terrain_paint::{TerrainPaintMode, TerrainPaintState};
|
||||||
use crate::viewport::terrain_sculpt::{TerrainSculptMode, TerrainSculptState};
|
use crate::viewport::terrain_sculpt::{TerrainSculptMode, TerrainSculptState};
|
||||||
use crate::viewport::{
|
use crate::viewport::{
|
||||||
material_drop::is_surface_asset_selection, snap_translation, viewport_ground_position,
|
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_selection_hud(world, ui.ctx(), rect, selected_entities);
|
||||||
scene_view_brush_draw_hints(world, ui.ctx(), rect);
|
scene_view_brush_draw_hints(world, ui.ctx(), rect);
|
||||||
scene_view_terrain_sculpt_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_render_badge(world, ui.ctx(), rect);
|
||||||
scene_view_brush_mode_badge(world, ui.ctx(), rect);
|
scene_view_brush_mode_badge(world, ui.ctx(), rect);
|
||||||
scene_view_volume_hud(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) {
|
fn key_hint(ui: &mut egui::Ui, key: &str, label: &str) {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.label(
|
ui.label(
|
||||||
@ -541,6 +589,24 @@ fn scene_view_overlay_toolbar(world: &mut World, ctx: &egui::Context, scene_rect
|
|||||||
.resource::<crate::selection::SelectedEntity>()
|
.resource::<crate::selection::SelectedEntity>()
|
||||||
.0
|
.0
|
||||||
.filter(|entity| world.get::<shared::TerrainDesc>(*entity).is_some());
|
.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"))
|
let toolbar_response = egui::Area::new(egui::Id::new("scene_view_toolbar"))
|
||||||
.fixed_pos(scene_rect.left_top() + egui::vec2(6.0, 6.0))
|
.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;
|
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();
|
ui.separator();
|
||||||
let stroking = world.resource::<TerrainSculptState>().is_stroking();
|
let stroking = world.resource::<TerrainSculptState>().is_stroking();
|
||||||
let sculpt_button_clicked = tool_button_accent(
|
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;
|
let active = state.active;
|
||||||
if active {
|
if active {
|
||||||
|
world.resource_mut::<TerrainPaintState>().stop();
|
||||||
world.resource_mut::<BrushToolState>().cancel();
|
world.resource_mut::<BrushToolState>().cancel();
|
||||||
world
|
world
|
||||||
.resource_mut::<crate::operators::ActiveOperator>()
|
.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.strength = strength;
|
||||||
state.clamp_settings();
|
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;
|
let clean_game_view = world.resource::<ViewportDisplayMode>().clean_game_view;
|
||||||
|
|||||||
@ -12,6 +12,7 @@ pub mod render_view;
|
|||||||
pub mod rendering_diagnostics;
|
pub mod rendering_diagnostics;
|
||||||
pub mod selection;
|
pub mod selection;
|
||||||
pub mod selection_outline;
|
pub mod selection_outline;
|
||||||
|
pub mod terrain_paint;
|
||||||
pub mod terrain_sculpt;
|
pub mod terrain_sculpt;
|
||||||
pub mod viewport_mode;
|
pub mod viewport_mode;
|
||||||
pub mod visualizers;
|
pub mod visualizers;
|
||||||
@ -25,5 +26,6 @@ pub use brush_edit::{BrushEditMode, BrushEditPlugin, BrushElementSelection};
|
|||||||
pub use brush_tool::{BrushToolPlugin, BrushToolState};
|
pub use brush_tool::{BrushToolPlugin, BrushToolState};
|
||||||
pub use material_drop::{MaterialDropFeedback, MaterialDropPlugin, MaterialDropState};
|
pub use material_drop::{MaterialDropFeedback, MaterialDropPlugin, MaterialDropState};
|
||||||
pub use panel::*;
|
pub use panel::*;
|
||||||
|
pub use terrain_paint::{TerrainPaintMode, TerrainPaintPlugin, TerrainPaintState};
|
||||||
pub use terrain_sculpt::{TerrainSculptMode, TerrainSculptPlugin, TerrainSculptState};
|
pub use terrain_sculpt::{TerrainSculptMode, TerrainSculptPlugin, TerrainSculptState};
|
||||||
pub use viewport_mode::EditorViewportMode;
|
pub use viewport_mode::EditorViewportMode;
|
||||||
|
|||||||
@ -24,6 +24,7 @@ use bevy_egui::egui;
|
|||||||
|
|
||||||
use crate::viewport::material_drop::{MaterialDropSet, MaterialDropState};
|
use crate::viewport::material_drop::{MaterialDropSet, MaterialDropState};
|
||||||
use crate::viewport::scene_view_ray;
|
use crate::viewport::scene_view_ray;
|
||||||
|
use crate::viewport::terrain_paint::TerrainPaintState;
|
||||||
use crate::viewport::terrain_sculpt::TerrainSculptState;
|
use crate::viewport::terrain_sculpt::TerrainSculptState;
|
||||||
|
|
||||||
#[derive(Resource, Default, Debug, Clone, Copy)]
|
#[derive(Resource, Default, Debug, Clone, Copy)]
|
||||||
@ -52,6 +53,7 @@ struct PickTargetQueries<'w, 's> {
|
|||||||
editor_only: Query<'w, 's, (), With<EditorOnly>>,
|
editor_only: Query<'w, 's, (), With<EditorOnly>>,
|
||||||
material_drop: Option<Res<'w, MaterialDropState>>,
|
material_drop: Option<Res<'w, MaterialDropState>>,
|
||||||
terrain_sculpt: Res<'w, TerrainSculptState>,
|
terrain_sculpt: Res<'w, TerrainSculptState>,
|
||||||
|
terrain_paint: Res<'w, TerrainPaintState>,
|
||||||
viewport_ui: Res<'w, ViewportUiState>,
|
viewport_ui: Res<'w, ViewportUiState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -127,7 +129,7 @@ fn handle_pick_events(
|
|||||||
for _ in click_events.read() {}
|
for _ in click_events.read() {}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
if pick_targets.terrain_sculpt.active {
|
if pick_targets.terrain_sculpt.active || pick_targets.terrain_paint.active {
|
||||||
viewport_click.0 = None;
|
viewport_click.0 = None;
|
||||||
for _ in click_events.read() {}
|
for _ in click_events.read() {}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@ -252,8 +254,9 @@ fn cycle_overlapping_viewport_pick(
|
|||||||
display: Res<ViewportDisplayMode>,
|
display: Res<ViewportDisplayMode>,
|
||||||
material_drop: Option<Res<MaterialDropState>>,
|
material_drop: Option<Res<MaterialDropState>>,
|
||||||
terrain_sculpt: Res<TerrainSculptState>,
|
terrain_sculpt: Res<TerrainSculptState>,
|
||||||
|
terrain_paint: Res<TerrainPaintState>,
|
||||||
) -> Result {
|
) -> Result {
|
||||||
if terrain_sculpt.active {
|
if terrain_sculpt.active || terrain_paint.active {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
if material_drop
|
if material_drop
|
||||||
@ -443,13 +446,18 @@ fn sync_gizmo_targets(
|
|||||||
display: Res<ViewportDisplayMode>,
|
display: Res<ViewportDisplayMode>,
|
||||||
brush_mode: Res<BrushEditMode>,
|
brush_mode: Res<BrushEditMode>,
|
||||||
terrain_sculpt: Res<TerrainSculptState>,
|
terrain_sculpt: Res<TerrainSculptState>,
|
||||||
|
terrain_paint: Res<TerrainPaintState>,
|
||||||
) {
|
) {
|
||||||
ui_state
|
ui_state
|
||||||
.selected_entities
|
.selected_entities
|
||||||
.retain(|entity| transforms.contains(entity) && !editor_only.contains(entity));
|
.retain(|entity| transforms.contains(entity) && !editor_only.contains(entity));
|
||||||
selected.0 = ui_state.selected_entities.as_slice().first().copied();
|
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 {
|
for (entity, brush_element_gizmo) in &targets {
|
||||||
if brush_element_gizmo.is_none() {
|
if brush_element_gizmo.is_none() {
|
||||||
commands.entity(entity).remove::<GizmoTarget>();
|
commands.entity(entity).remove::<GizmoTarget>();
|
||||||
@ -503,6 +511,7 @@ mod tests {
|
|||||||
.init_resource::<ViewportDisplayMode>()
|
.init_resource::<ViewportDisplayMode>()
|
||||||
.init_resource::<BrushEditMode>()
|
.init_resource::<BrushEditMode>()
|
||||||
.init_resource::<TerrainSculptState>()
|
.init_resource::<TerrainSculptState>()
|
||||||
|
.init_resource::<TerrainPaintState>()
|
||||||
.add_systems(Update, sync_gizmo_targets);
|
.add_systems(Update, sync_gizmo_targets);
|
||||||
|
|
||||||
app.update();
|
app.update();
|
||||||
@ -534,6 +543,30 @@ mod tests {
|
|||||||
.init_resource::<SelectedEntity>()
|
.init_resource::<SelectedEntity>()
|
||||||
.init_resource::<ViewportDisplayMode>()
|
.init_resource::<ViewportDisplayMode>()
|
||||||
.init_resource::<BrushEditMode>()
|
.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);
|
.add_systems(Update, sync_gizmo_targets);
|
||||||
|
|
||||||
app.update();
|
app.update();
|
||||||
|
|||||||
531
crates/editor/src/viewport/terrain_paint.rs
Normal file
531
crates/editor/src/viewport/terrain_paint.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -41,11 +41,11 @@ impl TerrainSculptMode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
struct TerrainHit {
|
pub(super) struct TerrainHit {
|
||||||
entity: Entity,
|
pub(super) entity: Entity,
|
||||||
local: Vec3,
|
pub(super) local: Vec3,
|
||||||
world: Vec3,
|
pub(super) world: Vec3,
|
||||||
world_rotation: Quat,
|
pub(super) world_rotation: Quat,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[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>>,
|
cameras: &Query<(&Camera, &GlobalTransform), With<EditorCamera>>,
|
||||||
pointer: bevy_egui::egui::Pos2,
|
pointer: bevy_egui::egui::Pos2,
|
||||||
scene_rect: bevy_egui::egui::Rect,
|
scene_rect: bevy_egui::egui::Rect,
|
||||||
@ -336,7 +336,7 @@ fn active_scene_ray(
|
|||||||
scene_view_ray(camera, transform, pointer, scene_rect)
|
scene_view_ray(camera, transform, pointer, scene_rect)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn terrain_ray_hit(
|
pub(super) fn terrain_ray_hit(
|
||||||
entity: Entity,
|
entity: Entity,
|
||||||
terrain: &TerrainDesc,
|
terrain: &TerrainDesc,
|
||||||
global: &GlobalTransform,
|
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);
|
let t = (1.0 - normalized_distance).clamp(0.0, 1.0);
|
||||||
t * t * (3.0 - 2.0 * t)
|
t * t * (3.0 - 2.0 * t)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -856,6 +856,30 @@ impl BrushDesc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub const TERRAIN_SCHEMA_VERSION: u32 = 1;
|
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
|
/// Persistent height-grid terrain authoring data. Hydration partitions the grid into generated
|
||||||
/// render/collider chunks; saved scenes retain only this descriptor.
|
/// render/collider chunks; saved scenes retain only this descriptor.
|
||||||
@ -876,6 +900,13 @@ pub struct TerrainDesc {
|
|||||||
pub chunk_quads: u32,
|
pub chunk_quads: u32,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub base_material: Option<EditorAssetRef>,
|
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")]
|
#[serde(default = "default_true")]
|
||||||
pub generate_colliders: bool,
|
pub generate_colliders: bool,
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
@ -894,6 +925,8 @@ impl Default for TerrainDesc {
|
|||||||
height_scale: default_terrain_height_scale(),
|
height_scale: default_terrain_height_scale(),
|
||||||
chunk_quads: default_terrain_chunk_quads(),
|
chunk_quads: default_terrain_chunk_quads(),
|
||||||
base_material: None,
|
base_material: None,
|
||||||
|
material_layers: Vec::new(),
|
||||||
|
material_weights: Vec::new(),
|
||||||
generate_colliders: true,
|
generate_colliders: true,
|
||||||
cast_shadows: true,
|
cast_shadows: true,
|
||||||
receive_shadows: true,
|
receive_shadows: true,
|
||||||
@ -933,6 +966,44 @@ impl TerrainDesc {
|
|||||||
if self.heights.iter().any(|height| !height.is_finite()) {
|
if self.heights.iter().any(|height| !height.is_finite()) {
|
||||||
return Err("terrain height samples must all be finite".into());
|
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 {
|
if !self.sample_spacing.is_finite() || self.sample_spacing <= 0.0 {
|
||||||
return Err("terrain sample spacing must be finite and positive".into());
|
return Err("terrain sample spacing must be finite and positive".into());
|
||||||
}
|
}
|
||||||
@ -947,6 +1018,39 @@ impl TerrainDesc {
|
|||||||
}
|
}
|
||||||
Ok(())
|
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 {
|
fn default_terrain_schema_version() -> u32 {
|
||||||
|
|||||||
@ -8,7 +8,8 @@ use bevy::prelude::*;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
authoring_component_active, load_resolved_material_from_path, AuthoringComponentStates,
|
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;
|
use super::materials::material_from_desc;
|
||||||
@ -96,9 +97,10 @@ pub fn spawn_terrain_chunks(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let material = terrain
|
let effective_layers = terrain.effective_material_layers();
|
||||||
.base_material
|
let material = effective_layers
|
||||||
.as_ref()
|
.first()
|
||||||
|
.and_then(|layer| layer.material.as_ref())
|
||||||
.and_then(|reference| reference.source_path.as_deref())
|
.and_then(|reference| reference.source_path.as_deref())
|
||||||
.and_then(|path| match load_resolved_material_from_path(path) {
|
.and_then(|path| match load_resolved_material_from_path(path) {
|
||||||
Ok((desc, _)) => Some(material_from_desc(asset_server, &desc)),
|
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}")),
|
Name::new(format!("Terrain Chunk {chunk_x},{chunk_z}")),
|
||||||
Mesh3d(mesh),
|
Mesh3d(mesh),
|
||||||
MeshMaterial3d(material.clone()),
|
MeshMaterial3d(material.clone()),
|
||||||
|
HydratedTerrainMaterialBinding {
|
||||||
|
owner,
|
||||||
|
layers: terrain.effective_material_layers(),
|
||||||
|
},
|
||||||
Transform::default(),
|
Transform::default(),
|
||||||
Visibility::Visible,
|
Visibility::Visible,
|
||||||
RaytracingExcluded,
|
RaytracingExcluded,
|
||||||
@ -188,6 +194,7 @@ pub fn terrain_chunk_mesh(
|
|||||||
let mut positions = Vec::with_capacity((width * depth) as usize);
|
let mut positions = Vec::with_capacity((width * depth) as usize);
|
||||||
let mut normals = 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 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 quad_count = (terrain.resolution - 1) as f32;
|
||||||
let half_extent = quad_count * terrain.sample_spacing * 0.5;
|
let half_extent = quad_count * terrain.sample_spacing * 0.5;
|
||||||
|
|
||||||
@ -209,6 +216,7 @@ pub fn terrain_chunk_mesh(
|
|||||||
]);
|
]);
|
||||||
normals.push(normal.to_array());
|
normals.push(normal.to_array());
|
||||||
uvs.push([x as f32 / quad_count, z as f32 / quad_count]);
|
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_POSITION, positions);
|
||||||
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
|
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
|
||||||
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs);
|
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs);
|
||||||
|
mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, colors);
|
||||||
mesh.insert_indices(Indices::U32(indices));
|
mesh.insert_indices(Indices::U32(indices));
|
||||||
if let Err(error) = mesh.generate_tangents() {
|
if let Err(error) = mesh.generate_tangents() {
|
||||||
warn!("Terrain chunk tangent generation failed: {error:?}");
|
warn!("Terrain chunk tangent generation failed: {error:?}");
|
||||||
@ -264,12 +273,61 @@ mod tests {
|
|||||||
let mut terrain = TerrainDesc::flat(5);
|
let mut terrain = TerrainDesc::flat(5);
|
||||||
terrain.heights[12] = 0.75;
|
terrain.heights[12] = 0.75;
|
||||||
terrain.sample_spacing = 2.0;
|
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 text = ron::to_string(&terrain).unwrap();
|
||||||
let restored: TerrainDesc = ron::from_str(&text).unwrap();
|
let restored: TerrainDesc = ron::from_str(&text).unwrap();
|
||||||
assert_eq!(restored, terrain);
|
assert_eq!(restored, terrain);
|
||||||
assert!(restored.validate().is_ok());
|
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]
|
#[test]
|
||||||
fn hydration_generates_runtime_only_chunks_and_cleans_up() {
|
fn hydration_generates_runtime_only_chunks_and_cleans_up() {
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
|
|||||||
@ -114,6 +114,7 @@ impl Plugin for SharedTypesPlugin {
|
|||||||
.register_type::<PrimitiveShape>()
|
.register_type::<PrimitiveShape>()
|
||||||
.register_type::<BrushDesc>()
|
.register_type::<BrushDesc>()
|
||||||
.register_type::<TerrainDesc>()
|
.register_type::<TerrainDesc>()
|
||||||
|
.register_type::<TerrainMaterialLayer>()
|
||||||
.register_type::<BrushKind>()
|
.register_type::<BrushKind>()
|
||||||
.register_type::<BrushPlaneDesc>()
|
.register_type::<BrushPlaneDesc>()
|
||||||
.register_type::<BrushFaceDesc>()
|
.register_type::<BrushFaceDesc>()
|
||||||
|
|||||||
@ -3,7 +3,10 @@
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
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.
|
/// 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>,
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@ -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 |
|
| [0037](adr/0037-collaborative-authored-file-safety.md) | Exact authored-file revisions, observational Git status, and optional ownership providers |
|
||||||
| [0038](adr/0038-non-blocking-native-dialog-broker.md) | Worker-owned native waits with one-shot main-thread workflow completion |
|
| [0038](adr/0038-non-blocking-native-dialog-broker.md) | Worker-owned native waits with one-shot main-thread workflow completion |
|
||||||
| [0039](adr/0039-inline-height-grid-terrain-foundation.md) | Inline authored height grids with deterministic runtime-only chunk hydration |
|
| [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
|
## 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/terrain.md](editor/terrain.md) | Terrain schema, inspector workflow, chunk hydration, collision, and follow-on boundaries |
|
||||||
| [editor/evaluations/material-renderer-foundation/](editor/evaluations/material-renderer-foundation/) | Live screenshots and acceptance results for the renderer/material/component foundation |
|
| [editor/evaluations/material-renderer-foundation/](editor/evaluations/material-renderer-foundation/) | Live screenshots and acceptance results for the renderer/material/component foundation |
|
||||||
| [editor/evaluations/terrain-sculpt-tools/](editor/evaluations/terrain-sculpt-tools/) | Live screenshot and acceptance results for modal terrain sculpt tools |
|
| [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/navigation-authoring/](editor/evaluations/navigation-authoring/) | Live screenshots and acceptance results for navigation authoring and runtime parity |
|
||||||
| [editor/evaluations/production-readiness/](editor/evaluations/production-readiness/) | Current versioned production gate, evidence matrix, candidate commands, soak protocol, and independent sign-off requirements |
|
| [editor/evaluations/production-readiness/](editor/evaluations/production-readiness/) | Current versioned production gate, evidence matrix, candidate commands, soak protocol, and independent sign-off requirements |
|
||||||
|
|
||||||
@ -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 |
|
| `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 |
|
| `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 |
|
| `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)
|
## Crate responsibilities (quick reference)
|
||||||
|
|
||||||
|
|||||||
36
docs/adr/0040-terrain-material-layer-weights.md
Normal file
36
docs/adr/0040-terrain-material-layer-weights.md
Normal 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.
|
||||||
@ -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/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-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-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/navigation-authoring/](evaluations/navigation-authoring/) | Live screenshots and verification record for navigation authoring and runtime parity |
|
||||||
| [evaluations/production-readiness/](evaluations/production-readiness/) | Current #50 release-candidate matrix and required clean-checkout, soak, performance, limitation, and independent-signoff evidence |
|
| [evaluations/production-readiness/](evaluations/production-readiness/) | Current #50 release-candidate matrix and required clean-checkout, soak, performance, limitation, and independent-signoff evidence |
|
||||||
|
|
||||||
@ -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 |
|
| `lib.rs` / `EditorPluginGroup` | Ordered plugin bundle, lib/bin split | ADR 0008, architecture.md |
|
||||||
| `shared::components::BrushDesc` / `shared::hydration::brushes` | Persisted brush authoring data and generated runtime mesh hydration | brushes.md, ADR 0021 |
|
| `shared::components::BrushDesc` / `shared::hydration::brushes` | Persisted brush authoring data and generated runtime mesh hydration | brushes.md, ADR 0021 |
|
||||||
| `shared::components::TerrainDesc` / `shared::hydration::terrain` | Persisted height grid and generated render/collider chunks | terrain.md, ADR 0039 |
|
| `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 |
|
| `scene/` | Level I/O, tabs, composition materialization, schema, viewport render-target setup | architecture.md, multi-scene-composition.md, ADR 0026 |
|
||||||
| `viewport/` | Camera, selection, gizmos, render views | architecture.md |
|
| `viewport/` | Camera, selection, gizmos, render views | architecture.md |
|
||||||
| `play/` | PIE session, editor mode state | architecture.md |
|
| `play/` | PIE session, editor mode state | architecture.md |
|
||||||
|
|||||||
36
docs/editor/evaluations/terrain-material-layers/README.md
Normal file
36
docs/editor/evaluations/terrain-material-layers/README.md
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
# Terrain Material Layers Acceptance
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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.
|
||||||
BIN
docs/editor/evaluations/terrain-material-layers/terrain-material-paint-stroke.png
(Stored with Git LFS)
Normal file
BIN
docs/editor/evaluations/terrain-material-layers/terrain-material-paint-stroke.png
(Stored with Git LFS)
Normal file
Binary file not shown.
@ -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
|
copy of the current neighborhood per dab, and Noise uses a deterministic per-stroke seed so authored
|
||||||
results remain reproducible.
|
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
|
`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,
|
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
|
normals, selection through generated children, collider generation, inspector state, and save
|
||||||
@ -32,19 +46,24 @@ stripping without requiring external assets.
|
|||||||
## Data And Hydration
|
## Data And Hydration
|
||||||
|
|
||||||
- Heights are finite normalized values multiplied by Height Scale at hydration time.
|
- 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.
|
- 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
|
- 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.
|
assets, allowing later stroke updates to rebuild only affected chunks.
|
||||||
- A resolvable shared Material/Material Instance source supplies the chunk material. Missing or
|
- Missing, unassigned, or invalid layer references produce a clear diagnostic and visible terrain
|
||||||
invalid references produce a warning and visible terrain fallback rather than missing geometry.
|
fallback rather than missing geometry.
|
||||||
- Generated `HydratedTerrainChunk` children own mesh, optional trimesh collider, and shadow state.
|
- Generated `HydratedTerrainChunk` children own mesh, optional trimesh collider, and shadow state.
|
||||||
They are runtime-only and never serialized as authored actors.
|
They are runtime-only and never serialized as authored actors.
|
||||||
- Foundation chunks are explicitly excluded from Solari submission and remain raster-visible while
|
- 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`;
|
Auto/Solari is active. Raster material-layer parity is shipped; matching Solari terrain evaluation
|
||||||
the editor never substitutes a black or semantically different ray-traced terrain proxy.
|
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
|
The terrain foundation is recorded in [ADR 0039](../adr/0039-inline-height-grid-terrain-foundation.md),
|
||||||
[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
|
Live native-Wayland evidence and focused verification are recorded in the
|
||||||
[terrain foundation evaluation](evaluations/terrain-foundation/).
|
[terrain foundation evaluation](evaluations/terrain-foundation/).
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user