Fix sample hydration rendering regressions

This commit is contained in:
Rbanh 2026-07-13 03:07:31 -04:00
parent 06010c5922
commit d52cc2e3b7
8 changed files with 193 additions and 39 deletions

View File

@ -287,6 +287,42 @@
),
dependencies: [],
),
(
id: ("607864bf-fa77-4f9e-acc9-2c0906d34163"),
path: "assets/levels/samples/material_lab.scn.ron",
label: "material_lab.scn",
kind_tag: "Level",
import_settings: (
scale: 1.0,
generate_collider: true,
lod0_only: true,
placement_mode: StaticAsset,
hierarchy_mode: SingleActor,
material_policy: SourceMaterials,
static_mesh_manifest_path: None,
animation_manifest_path: None,
default_animation_clip_id: None,
),
dependencies: [],
),
(
id: ("bca13177-afa9-4691-9b40-288059c7a57e"),
path: "assets/levels/samples/brush_blockout.scn.ron",
label: "brush_blockout.scn",
kind_tag: "Level",
import_settings: (
scale: 1.0,
generate_collider: true,
lod0_only: true,
placement_mode: StaticAsset,
hierarchy_mode: SingleActor,
material_policy: SourceMaterials,
static_mesh_manifest_path: None,
animation_manifest_path: None,
default_animation_clip_id: None,
),
dependencies: [],
),
(
id: ("5a2307d0-48e5-40d3-a5c9-527c0cfd30f4"),
path: "assets/levels/editor_scene.scn.ron",
@ -395,6 +431,24 @@
),
dependencies: [],
),
(
id: ("e42185de-cda2-4a9e-9df0-df67ccd86123"),
path: "assets/samples/editor_samples.ron",
label: "editor_samples",
kind_tag: "Level",
import_settings: (
scale: 1.0,
generate_collider: true,
lod0_only: true,
placement_mode: StaticAsset,
hierarchy_mode: SingleActor,
material_policy: SourceMaterials,
static_mesh_manifest_path: None,
animation_manifest_path: None,
default_animation_clip_id: None,
),
dependencies: [],
),
(
id: ("04a4e00e-4732-43db-8e99-9cd99b94667b"),
path: "assets/animations/generated/113f74df-e39c-41d4-9b5b-e48efe541f7f.animation.ron",

View File

@ -6,6 +6,7 @@
use std::collections::HashMap;
use std::fs;
use bevy::material::OpaqueRendererMethod;
use bevy::mesh::MeshVertexBufferLayoutRef;
use bevy::pbr::{
ExtendedMaterial, MaterialExtension, MaterialExtensionKey, MaterialExtensionPipeline,
@ -393,6 +394,9 @@ fn build_terrain_layer_material(
base: StandardMaterial {
base_color: Color::WHITE,
perceptual_roughness: 1.0,
// Terrain is intentionally absent from the Solari acceleration structure until its
// ray-tracing evaluator exists, so keep the layer blend in the raster forward pass.
opaque_render_method: OpaqueRendererMethod::Forward,
..default()
},
extension,
@ -1079,6 +1083,10 @@ mod tests {
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_eq!(
material.base.opaque_render_method,
OpaqueRendererMethod::Forward
);
assert_ne!(
material.extension.uniform.base_colors[0],
material.extension.uniform.base_colors[1]

View File

@ -2171,6 +2171,8 @@ fn load_composed_subscenes(
}
fn finalize_scene_load(world: &mut World) {
shared::initialize_level_object_visibility_hierarchy(world);
let rendering = world
.resource::<settings::ProjectSettings>()
.rendering
@ -2490,12 +2492,20 @@ mod tests {
.world_mut()
.spawn((
LevelObject,
EditorVisibility { visible: false },
BrushDesc::default(),
ColliderDesc::static_cuboid(Vec3::ONE),
))
.id();
finalize_scene_load(app.world_mut());
assert_eq!(
app.world().get::<Visibility>(actor),
Some(&Visibility::Hidden),
"scene actors must receive authored visibility before Update hydration"
);
assert!(app.world().get::<InheritedVisibility>(actor).is_some());
assert!(app.world().get::<ViewVisibility>(actor).is_some());
assert!(
app.world().get::<Children>(actor).is_none(),
"scene finalization must not create collider children ahead of Update"
@ -2511,6 +2521,7 @@ mod tests {
.world()
.get::<avian3d::prelude::ColliderConstructor>(first_child)
.is_some());
assert!(app.world().get::<InheritedVisibility>(actor).is_some());
app.update();
assert_eq!(
@ -2522,6 +2533,57 @@ mod tests {
);
}
#[test]
fn brush_sample_parents_own_visibility_before_generated_meshes_attach() {
let project_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.canonicalize()
.unwrap();
let sample_path = project_root.join("assets/levels/samples/brush_blockout.scn.ron");
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(AssetPlugin::default())
.add_plugins(bevy::pbr::MaterialPlugin::<StandardMaterial>::default())
.init_asset::<Mesh>()
.add_plugins(shared::SharedTypesPlugin);
app.world_mut()
.insert_resource(settings::ProjectSettings::default());
app.world_mut()
.insert_resource(crate::project_io::ProjectWorkspace {
root: project_root.display().to_string(),
..Default::default()
});
app.world_mut()
.insert_resource(crate::ui::hierarchy_state::HierarchyPanelState::default());
load_level(app.world_mut(), &sample_path).unwrap();
let brush_actors: Vec<Entity> = app
.world_mut()
.query_filtered::<Entity, (With<LevelObject>, With<BrushDesc>)>()
.iter(app.world())
.collect();
assert!(!brush_actors.is_empty());
for actor in &brush_actors {
assert!(app.world().get::<InheritedVisibility>(*actor).is_some());
assert!(app.world().get::<ViewVisibility>(*actor).is_some());
assert!(app.world().get::<Children>(*actor).is_none());
}
app.update();
for actor in brush_actors {
let generated_meshes = app
.world()
.get::<Children>(actor)
.expect("brush hydration should attach generated mesh children");
assert!(generated_meshes
.iter()
.any(|child| app.world().get::<Mesh3d>(child).is_some()));
assert!(app.world().get::<InheritedVisibility>(actor).is_some());
}
}
#[test]
fn scene_serialization_persists_composition_without_composed_members() {
let mut app = App::new();

View File

@ -40,16 +40,17 @@ use static_meshes::{
};
pub use terrain::HydratedTerrainChunk;
use terrain::{cleanup_removed_terrain, hydrate_terrain};
pub use visibility::initialize_level_object_visibility_hierarchy;
use visibility::{
ensure_level_object_visibility_hierarchy, init_editor_visibility_on_spawn,
sync_editor_visibility, visibility_from_editor,
sync_editor_visibility,
};
use crate::{
authoring_component_active, AuthoringComponentStates, BrushDesc, ColliderDesc,
EditorVisibility, InspectorOrder, LevelObject, MaterialDesc, MaterialOverride, Primitive,
StaticMeshRenderer, COMPONENT_BRUSH_DESC, COMPONENT_COLLIDER_DESC, COMPONENT_LIGHT_DESC,
COMPONENT_MATERIAL_DESC, COMPONENT_PRIMITIVE, COMPONENT_STATIC_MESH_RENDERER,
authoring_component_active, AuthoringComponentStates, BrushDesc, ColliderDesc, InspectorOrder,
LevelObject, MaterialDesc, MaterialOverride, Primitive, StaticMeshRenderer,
COMPONENT_BRUSH_DESC, COMPONENT_COLLIDER_DESC, COMPONENT_LIGHT_DESC, COMPONENT_MATERIAL_DESC,
COMPONENT_PRIMITIVE, COMPONENT_STATIC_MESH_RENDERER,
};
/// Registers hydration systems in deterministic order.
@ -115,6 +116,8 @@ pub fn flush_level_object_hydration(world: &mut World) {
world.insert_resource(StaticMeshArtifactCache::default());
}
initialize_level_object_visibility_hierarchy(world);
let primitives: Vec<(Entity, Primitive)> = world
.query_filtered::<(
Entity,
@ -250,12 +253,6 @@ pub fn flush_level_object_hydration(world: &mut World) {
}
}
let visibility_targets: Vec<(Entity, EditorVisibility)> = world
.query_filtered::<(Entity, &EditorVisibility), With<LevelObject>>()
.iter(world)
.map(|(entity, vis)| (entity, *vis))
.collect();
let rendering = world
.get_resource::<settings::ProjectSettings>()
.map(|s| s.rendering.clone())
@ -348,25 +345,6 @@ pub fn flush_level_object_hydration(world: &mut World) {
}
state.apply(world);
for (entity, editor) in visibility_targets {
let visibility = visibility_from_editor(editor);
if let Some(mut vis) = world.get_mut::<Visibility>(entity) {
*vis = visibility;
} else if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
entity_mut.insert(visibility);
}
if world.get::<InheritedVisibility>(entity).is_none() {
if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
entity_mut.insert(InheritedVisibility::default());
}
}
if world.get::<ViewVisibility>(entity).is_none() {
if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
entity_mut.insert(ViewVisibility::default());
}
}
}
}
#[cfg(test)]

View File

@ -3,6 +3,24 @@
use crate::{EditorVisibility, LevelObject};
use bevy::prelude::*;
/// Establishes the runtime visibility hierarchy synchronously for all authored actors.
///
/// Scene loading can occur between scheduled hydration sets, so callers that materialize a scene
/// must run this before generated render children can be attached in the same frame.
pub fn initialize_level_object_visibility_hierarchy(world: &mut World) {
let targets: Vec<(Entity, EditorVisibility)> = world
.query_filtered::<(Entity, Option<&EditorVisibility>), With<LevelObject>>()
.iter(world)
.map(|(entity, visibility)| (entity, visibility.copied().unwrap_or_default()))
.collect();
for (entity, editor_visibility) in targets {
if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
entity_mut.insert((editor_visibility, visibility_from_editor(editor_visibility)));
}
}
}
#[expect(
clippy::type_complexity,
reason = "the query mirrors Bevy's three visibility hierarchy components"
@ -81,7 +99,9 @@ pub fn visibility_from_editor(editor: EditorVisibility) -> Visibility {
#[cfg(test)]
mod tests {
use super::ensure_level_object_visibility_hierarchy;
use super::{
ensure_level_object_visibility_hierarchy, initialize_level_object_visibility_hierarchy,
};
use crate::{EditorVisibility, LevelObject};
use bevy::prelude::*;
@ -102,4 +122,34 @@ mod tests {
assert!(world.get::<InheritedVisibility>(entity).is_some());
assert!(world.get::<ViewVisibility>(entity).is_some());
}
#[test]
fn immediate_initializer_establishes_hierarchy_before_scheduled_hydration() {
let mut world = World::new();
let entity = world
.spawn((LevelObject, EditorVisibility { visible: false }))
.id();
initialize_level_object_visibility_hierarchy(&mut world);
assert_eq!(world.get::<Visibility>(entity), Some(&Visibility::Hidden));
assert!(world.get::<InheritedVisibility>(entity).is_some());
assert!(world.get::<ViewVisibility>(entity).is_some());
}
#[test]
fn immediate_initializer_migrates_missing_authored_visibility() {
let mut world = World::new();
let entity = world.spawn(LevelObject).id();
initialize_level_object_visibility_hierarchy(&mut world);
assert_eq!(
world.get::<EditorVisibility>(entity),
Some(&EditorVisibility::default())
);
assert_eq!(world.get::<Visibility>(entity), Some(&Visibility::Visible));
assert!(world.get::<InheritedVisibility>(entity).is_some());
assert!(world.get::<ViewVisibility>(entity).is_some());
}
}

View File

@ -20,10 +20,10 @@ pub use actor::{infer_actor_kind, validate_actor, ActorValidationError};
pub use animation::*;
pub use components::*;
pub use hydration::{
cascade_config_from_rendering, flush_level_object_hydration, material_from_desc,
strip_hydrated, strip_hydrated_entity, HydratedModelRoot, HydratedPrefabMember,
HydratedPrefabReady, HydratedSkinnedMeshRoot, HydratedTerrainChunk, HydrationPlugin,
PrefabHydrationBlocked,
cascade_config_from_rendering, flush_level_object_hydration,
initialize_level_object_visibility_hierarchy, material_from_desc, strip_hydrated,
strip_hydrated_entity, HydratedModelRoot, HydratedPrefabMember, HydratedPrefabReady,
HydratedSkinnedMeshRoot, HydratedTerrainChunk, HydrationPlugin, PrefabHydrationBlocked,
};
pub use material_asset::{
load_resolved_material_from_path, MaterialAlphaMode, MaterialAsset, MaterialInstanceAsset,

View File

@ -22,7 +22,8 @@ weights through `Mesh::ATTRIBUTE_COLOR`.
`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.
Solari geometry until a matching ray-tracing evaluator exists, and its opaque material is therefore
forced through Bevy's forward raster path even when the project default is deferred.
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.

View File

@ -59,9 +59,10 @@ references deliberately validate terrain dependency resolution and layer transpo
fallback rather than missing geometry.
- Generated `HydratedTerrainChunk` children own mesh, optional trimesh collider, and shadow state.
They are runtime-only and never serialized as authored actors.
- Foundation chunks are explicitly excluded from Solari submission and remain raster-visible while
Auto/Solari is active. Raster material-layer parity is shipped; matching Solari terrain evaluation
remains explicit future work, and the editor never substitutes a semantically different proxy.
- Foundation chunks are explicitly excluded from Solari submission and force their layer blend
through Bevy's forward opaque raster path while Auto/Solari is active. Raster material-layer
parity is shipped; matching Solari terrain evaluation remains explicit future work, and the editor
never substitutes a semantically different proxy.
The terrain foundation is recorded in [ADR 0039](../adr/0039-inline-height-grid-terrain-foundation.md),
and layer weight persistence/render transport in