From bea625f9878148347e0fda0529bd0d6ba7b1a327 Mon Sep 17 00:00:00 2001 From: chazmm Date: Wed, 9 Sep 2026 17:35:40 +0100 Subject: [PATCH] Batch retail material bindings to reduce CPU render commands --- Cargo.lock | 2 + crates/skate-game/Cargo.toml | 4 + crates/skate-game/src/profiling.rs | 2 +- .../skate-game/src/retail_bindless_tests.rs | 60 ++++++++++++ crates/skate-game/src/retail_depth.wgsl | 18 +++- .../src/retail_material_bindings.wgsl | 54 +++++++++++ crates/skate-game/src/retail_render.rs | 13 ++- crates/skate-game/src/retail_world.wgsl | 93 ++++++++++++++----- crates/skate-game/src/skate_world.rs | 15 +-- docs/cpu-material-binding-experiment.md | 61 ++++++++++++ docs/render-batching-experiment.md | 5 + 11 files changed, 282 insertions(+), 45 deletions(-) create mode 100644 crates/skate-game/src/retail_bindless_tests.rs create mode 100644 crates/skate-game/src/retail_material_bindings.wgsl create mode 100644 docs/cpu-material-binding-experiment.md diff --git a/Cargo.lock b/Cargo.lock index be18cdc1..e13f4a08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4738,6 +4738,8 @@ version = "0.1.0" dependencies = [ "bevy", "half", + "naga", + "naga_oil", "serde", "serde_json", "skate-core", diff --git a/crates/skate-game/Cargo.toml b/crates/skate-game/Cargo.toml index a1efacec..d8a0cdb0 100644 --- a/crates/skate-game/Cargo.toml +++ b/crates/skate-game/Cargo.toml @@ -34,3 +34,7 @@ skate-mods = { path = "../skate-mods" } skate-vehicles = { path = "../skate-vehicles" } skate-net = { path = "../skate-net" } + +[dev-dependencies] +naga = { version = "=27.0.3", features = ["wgsl-in"] } +naga_oil = { version = "=0.20.0", default-features = false } diff --git a/crates/skate-game/src/profiling.rs b/crates/skate-game/src/profiling.rs index 57412b93..91accae0 100644 --- a/crates/skate-game/src/profiling.rs +++ b/crates/skate-game/src/profiling.rs @@ -454,7 +454,7 @@ fn end_frame( "assets", json!({"meshes":meshes.len(),"images":images.len(),"entities":entities.iter().count()}), ); - capture.send(json!({"name":"configuration","ph":"i","s":"g","pid":1,"tid":0,"ts":capture.origin.elapsed().as_micros() as u64,"args":{"map_fingerprint":format!("{:016x}",config.map_fingerprint),"difficulty":config.difficulty.key(),"timestamp_queries":device.as_ref().is_some_and(|d|d.features().contains(bevy::render::settings::WgpuFeatures::TIMESTAMP_QUERY)),"windows":windows.iter().map(|w|json!({"width":w.physical_width(),"height":w.physical_height(),"present_mode":format!("{:?}",w.present_mode)})).collect::>(),"cameras":cameras.iter().map(|(c,m)|json!({"size":c.physical_target_size().map(|s|[s.x,s.y]),"msaa":m.samples()})).collect::>()}})); + capture.send(json!({"name":"configuration","ph":"i","s":"g","pid":1,"tid":0,"ts":capture.origin.elapsed().as_micros() as u64,"args":{"map_fingerprint":format!("{:016x}",config.map_fingerprint),"difficulty":config.difficulty.key(),"retail_bindless":device.as_ref().map(|d|bevy::pbr::material_uses_bindless_resources::(d)),"timestamp_queries":device.as_ref().is_some_and(|d|d.features().contains(bevy::render::settings::WgpuFeatures::TIMESTAMP_QUERY)),"windows":windows.iter().map(|w|json!({"width":w.physical_width(),"height":w.physical_height(),"present_mode":format!("{:?}",w.present_mode)})).collect::>(),"cameras":cameras.iter().map(|(c,m)|json!({"size":c.physical_target_size().map(|s|[s.x,s.y]),"msaa":m.samples()})).collect::>()}})); } for diagnostic in diagnostics.iter() { let path = diagnostic.path().as_str(); diff --git a/crates/skate-game/src/retail_bindless_tests.rs b/crates/skate-game/src/retail_bindless_tests.rs new file mode 100644 index 00000000..485fd5bc --- /dev/null +++ b/crates/skate-game/src/retail_bindless_tests.rs @@ -0,0 +1,60 @@ +//! GPU-free validation of the actual material shaders and binding-array access. +//! Bevy vertex interfaces are real; unrelated lighting functions/resources use +//! interface fixtures. Runtime pipeline/device validation remains a user check. +use naga_oil::compose::{Composer, ComposableModuleDescriptor, NagaModuleDescriptor, ShaderDefValue}; +use std::collections::HashMap; + +#[test] +fn material_index_table_matches_shader_resources() { + use bevy::render::render_resource::{AsBindGroup, BindlessResourceType as R}; + let descriptor = super::RetailWorldMaterial::bindless_descriptor().unwrap(); + assert_eq!(descriptor.resources.as_ref(), &[ + R::DataBuffer, R::Texture2d, R::SamplerFiltering, + R::Texture2d, R::SamplerFiltering, R::Texture2d, R::SamplerFiltering, + R::Texture2d, R::SamplerFiltering, R::Texture2d, R::SamplerFiltering, + R::Texture2d, R::SamplerFiltering, R::Texture2d, R::SamplerFiltering, + R::TextureCube, R::Buffer, + ]); + assert_eq!(descriptor.index_tables.len(), 1); + let table = &descriptor.index_tables[0]; + assert_eq!((table.binding_number.0, table.indices.start.0, table.indices.end.0), (0, 0, 17)); + let mut buffers: Vec<_> = descriptor.buffers.iter().map(|b| (b.bindless_index.0, b.binding_number.0)).collect(); + buffers.sort_unstable(); + assert_eq!(buffers, [(0, 17), (16, 18)]); +} + +fn validate(bindless: bool, prepass: bool, extras: &[&str]) { + let mut defs = HashMap::from([("MATERIAL_BIND_GROUP".into(),ShaderDefValue::UInt(3))]); + for &name in ["VERTEX_UVS_A","VERTEX_UVS_B","VERTEX_TANGENTS","VERTEX_COLORS","VERTEX_NORMALS","VERTEX_OUTPUT_INSTANCE_INDEX"].iter().chain(extras) { + defs.insert(name.into(),ShaderDefValue::Bool(true)); + } + if bindless { defs.insert("BINDLESS".into(),ShaderDefValue::Bool(true)); } + let mut composer = Composer::default().with_capabilities(naga::valid::Capabilities::all()); + let fixtures = [ + ("forward",include_str!("../../../vendor/bevy_pbr/src/render/forward_io.wgsl")), + ("prepass",include_str!("../../../vendor/bevy_pbr/src/prepass/prepass_io.wgsl")), + ("mesh", "#define_import_path bevy_pbr::mesh_bindings\nstruct Mesh { material_and_lightmap_bind_group_slot: u32 }\n@group(2) @binding(0) var mesh: array;"), + ("frame", "#define_import_path bevy_pbr::mesh_view_bindings\nstruct View {view_from_world:mat4x4,viewport:vec4,world_position:vec3,padding:f32}\nstruct Light {flags:u32}\nstruct Lights {n_directional_lights:u32,directional_lights:array}\n@group(0) @binding(0) var view:View;\n@group(0) @binding(1) var lights:Lights;"), + ("shadows", "#define_import_path bevy_pbr::shadows\nfn fetch_directional_shadow(id:u32,p:vec4,n:vec3,z:f32)->f32 {return 1.0;}"), + ("motion", "#define_import_path bevy_pbr::pbr_prepass_functions\nfn calculate_motion_vector(p:vec4,q:vec4)->vec2 {return vec2(0.0);}"), + ("bindings",include_str!("retail_material_bindings.wgsl")), + ]; + for (path,source) in fixtures { + if let Err(error) = composer.add_composable_module(ComposableModuleDescriptor {source,file_path:path,shader_defs:defs.clone(),..Default::default()}) { + panic!("{}",error.emit_to_string(&composer)); + } + } + let source = if prepass {include_str!("retail_depth.wgsl")} else {include_str!("retail_world.wgsl")}; + let module = composer.make_naga_module(NagaModuleDescriptor {source,file_path:"retail",shader_defs:defs,..Default::default()}) + .unwrap_or_else(|e| panic!("{}",e.emit_to_string(&composer))); + naga::valid::Validator::new(naga::valid::ValidationFlags::all(),naga::valid::Capabilities::all()).validate(&module).unwrap(); +} + +#[test] +fn material_shaders_validate_with_bindless_and_fallback_bindings() { + for bindless in [false,true] { + validate(bindless,false,&[]); + validate(bindless,true,&[]); + validate(bindless,true,&["PREPASS_FRAGMENT","NORMAL_PREPASS","NORMAL_PREPASS_OR_DEFERRED_PREPASS","MOTION_VECTOR_PREPASS","UNCLIPPED_DEPTH_ORTHO_EMULATION"]); + } +} diff --git a/crates/skate-game/src/retail_depth.wgsl b/crates/skate-game/src/retail_depth.wgsl index 5d59ee0f..00394267 100644 --- a/crates/skate-game/src/retail_depth.wgsl +++ b/crates/skate-game/src/retail_depth.wgsl @@ -5,17 +5,25 @@ #ifdef MOTION_VECTOR_PREPASS #import bevy_pbr::pbr_prepass_functions::calculate_motion_vector #endif -struct WorldParams { mode: vec4, foliage_debug: vec4, surface: vec4, family: vec4, fog_ramp: vec4, fog_color: vec4, shadow_color: vec4, sun_direction: vec4, decal: vec4, water: array, 4> } -@group(#{MATERIAL_BIND_GROUP}) @binding(0) var p: WorldParams; -@group(#{MATERIAL_BIND_GROUP}) @binding(1) var diffuse: texture_2d; -@group(#{MATERIAL_BIND_GROUP}) @binding(2) var diffuse_sampler: sampler; +#import skate_retail::material_bindings as bindings +#ifdef BINDLESS +#import bevy_pbr::mesh_bindings::mesh +#endif @fragment fn fragment(i: VertexOutput) #ifdef PREPASS_FRAGMENT -> FragmentOutput #endif { - if textureSample(diffuse,diffuse_sampler,i.uv).a < p.mode.z { discard; } +#ifdef BINDLESS + let index = bindings::indices[mesh[i.instance_index].material_and_lightmap_bind_group_slot & 0xffffu]; + let alpha = textureSample(bindings::textures[index.diffuse],bindings::samplers[index.diffuse_sampler],i.uv).a; + let cutoff = bindings::params[index.params].mode.z; +#else + let alpha = textureSample(bindings::diffuse,bindings::diffuse_sampler,i.uv).a; + let cutoff = bindings::p.mode.z; +#endif + if alpha < cutoff { discard; } #ifdef PREPASS_FRAGMENT var out: FragmentOutput; #ifdef NORMAL_PREPASS diff --git a/crates/skate-game/src/retail_material_bindings.wgsl b/crates/skate-game/src/retail_material_bindings.wgsl new file mode 100644 index 00000000..02b385a6 --- /dev/null +++ b/crates/skate-game/src/retail_material_bindings.wgsl @@ -0,0 +1,54 @@ +#define_import_path skate_retail::material_bindings +struct WorldParams { + mode: vec4, foliage_debug: vec4, surface: vec4, family: vec4, + fog_ramp: vec4, fog_color: vec4, shadow_color: vec4, sun_direction: vec4, decal: vec4, water: array, 4>, +} +struct FrameState { shadow: vec4, clock: vec4, pca: array, 7> } + +#ifdef BINDLESS +struct MaterialIndices { + params: u32, + diffuse: u32, + diffuse_sampler: u32, + lightmap: u32, + lm_sampler: u32, + normal_map: u32, + normal_sampler: u32, + detail_map: u32, + detail_sampler: u32, + macro_map: u32, + macro_sampler: u32, + decal_map: u32, + decal_sampler: u32, + specular_map: u32, + specular_sampler: u32, + environment_map: u32, + frame_state: u32, +} +@group(#{MATERIAL_BIND_GROUP}) @binding(0) var indices: array; +@group(#{MATERIAL_BIND_GROUP}) @binding(1) var samplers: binding_array; +@group(#{MATERIAL_BIND_GROUP}) @binding(5) var textures: binding_array>; +@group(#{MATERIAL_BIND_GROUP}) @binding(8) var cubes: binding_array>; +@group(#{MATERIAL_BIND_GROUP}) @binding(17) var params: array; +@group(#{MATERIAL_BIND_GROUP}) @binding(18) var frames: binding_array; +#else +@group(#{MATERIAL_BIND_GROUP}) @binding(0) var p: WorldParams; +@group(#{MATERIAL_BIND_GROUP}) @binding(1) var diffuse: texture_2d; +@group(#{MATERIAL_BIND_GROUP}) @binding(2) var diffuse_sampler: sampler; +@group(#{MATERIAL_BIND_GROUP}) @binding(3) var lightmap: texture_2d; +@group(#{MATERIAL_BIND_GROUP}) @binding(4) var lm_sampler: sampler; +@group(#{MATERIAL_BIND_GROUP}) @binding(5) var normal_map: texture_2d; +@group(#{MATERIAL_BIND_GROUP}) @binding(6) var normal_sampler: sampler; +@group(#{MATERIAL_BIND_GROUP}) @binding(7) var detail_map: texture_2d; +@group(#{MATERIAL_BIND_GROUP}) @binding(8) var detail_sampler: sampler; +@group(#{MATERIAL_BIND_GROUP}) @binding(9) var macro_map: texture_2d; +@group(#{MATERIAL_BIND_GROUP}) @binding(10) var macro_sampler: sampler; +@group(#{MATERIAL_BIND_GROUP}) @binding(11) var decal_map: texture_2d; +@group(#{MATERIAL_BIND_GROUP}) @binding(12) var decal_sampler: sampler; +@group(#{MATERIAL_BIND_GROUP}) @binding(13) var specular_map: texture_2d; +@group(#{MATERIAL_BIND_GROUP}) @binding(14) var specular_sampler: sampler; +@group(#{MATERIAL_BIND_GROUP}) @binding(15) var environment_map: texture_cube; + +@group(#{MATERIAL_BIND_GROUP}) @binding(16) var frame_state: FrameState; + +#endif diff --git a/crates/skate-game/src/retail_render.rs b/crates/skate-game/src/retail_render.rs index 50a27ff8..457b02d6 100644 --- a/crates/skate-game/src/retail_render.rs +++ b/crates/skate-game/src/retail_render.rs @@ -11,6 +11,9 @@ use bevy::{ use std::collections::BTreeMap; pub(crate) struct RetailRenderPlugin; +#[cfg(test)] +#[path = "retail_bindless_tests.rs"] +mod bindless_tests; // Use the same path derivation as embedded_asset!: alternate binary targets // have a different crate namespace even though they share these source files. fn retail_shader(path: &str) -> ShaderRef { @@ -18,7 +21,6 @@ fn retail_shader(path: &str) -> ShaderRef { } impl Plugin for RetailRenderPlugin { fn build(&self, app: &mut App) { - crate::skate_world::shadow_geometry::install(app); shadow::install(app); exposure::install(app); if std::env::var_os("SKATE_WEATHERING_COMPARE").is_some_and(|v| v == "1") { @@ -29,6 +31,7 @@ impl Plugin for RetailRenderPlugin { eprintln!("SKATE_FOLIAGE_DEBUG: solid cyan tree-wall cards, magenta other foliage; alpha rejection disabled for foliage only"); } embedded_asset!(app, "retail_world.wgsl"); + bevy::shader::load_shader_library!(app, "retail_material_bindings.wgsl"); embedded_asset!(app, "retail_tone.wgsl"); embedded_asset!(app, "retail_depth.wgsl"); embedded_asset!(app, "retail_sky.wgsl"); @@ -111,8 +114,9 @@ pub(crate) struct WorldParams { #[derive(Asset, TypePath, AsBindGroup, Debug, Clone)] #[bind_group_data(RetailKey)] +#[data(0, WorldParams, binding_array(17))] +#[bindless(limit(64))] pub(crate) struct RetailWorldMaterial { - #[uniform(0)] pub params: WorldParams, #[texture(1)] #[sampler(2)] @@ -137,11 +141,14 @@ pub(crate) struct RetailWorldMaterial { pub specular: Option>, #[texture(15, dimension = "cube")] pub environment: Option>, - #[storage(16, read_only)] + #[storage(16, read_only, binding_array(18))] pub shadow_state: Handle, pub alpha: AlphaMode, pub two_sided: bool, } +impl From<&RetailWorldMaterial> for WorldParams { + fn from(material: &RetailWorldMaterial) -> Self { material.params.clone() } +} /// Identity of the actual GPU inputs, independent of unused source metadata. #[derive(PartialEq, Eq, Hash)] pub(crate) struct WorldMaterialKey { diff --git a/crates/skate-game/src/retail_world.wgsl b/crates/skate-game/src/retail_world.wgsl index 04eada51..076d1e1d 100644 --- a/crates/skate-game/src/retail_world.wgsl +++ b/crates/skate-game/src/retail_world.wgsl @@ -1,29 +1,11 @@ #import bevy_pbr::{forward_io::VertexOutput, mesh_view_bindings as frame} #import bevy_pbr::shadows::fetch_directional_shadow -struct WorldParams { - mode: vec4, foliage_debug: vec4, surface: vec4, family: vec4, - fog_ramp: vec4, fog_color: vec4, shadow_color: vec4, sun_direction: vec4, decal: vec4, water: array, 4>, -} -@group(#{MATERIAL_BIND_GROUP}) @binding(0) var p: WorldParams; -@group(#{MATERIAL_BIND_GROUP}) @binding(1) var diffuse: texture_2d; -@group(#{MATERIAL_BIND_GROUP}) @binding(2) var diffuse_sampler: sampler; -@group(#{MATERIAL_BIND_GROUP}) @binding(3) var lightmap: texture_2d; -@group(#{MATERIAL_BIND_GROUP}) @binding(4) var lm_sampler: sampler; -@group(#{MATERIAL_BIND_GROUP}) @binding(5) var normal_map: texture_2d; -@group(#{MATERIAL_BIND_GROUP}) @binding(6) var normal_sampler: sampler; -@group(#{MATERIAL_BIND_GROUP}) @binding(7) var detail_map: texture_2d; -@group(#{MATERIAL_BIND_GROUP}) @binding(8) var detail_sampler: sampler; -@group(#{MATERIAL_BIND_GROUP}) @binding(9) var macro_map: texture_2d; -@group(#{MATERIAL_BIND_GROUP}) @binding(10) var macro_sampler: sampler; -@group(#{MATERIAL_BIND_GROUP}) @binding(11) var decal_map: texture_2d; -@group(#{MATERIAL_BIND_GROUP}) @binding(12) var decal_sampler: sampler; -@group(#{MATERIAL_BIND_GROUP}) @binding(13) var specular_map: texture_2d; -@group(#{MATERIAL_BIND_GROUP}) @binding(14) var specular_sampler: sampler; -@group(#{MATERIAL_BIND_GROUP}) @binding(15) var environment_map: texture_cube; - -struct FrameState { shadow: vec4, clock: vec4, pca: array, 7> } -@group(#{MATERIAL_BIND_GROUP}) @binding(16) var frame_state: FrameState; +#import skate_retail::material_bindings::{WorldParams, FrameState} +#import skate_retail::material_bindings as bindings +#ifdef BINDLESS +#import bevy_pbr::mesh_bindings::mesh +#endif // Textures and base UVs both have V flipped by the exporter. Scale in the // authored coordinate system, then return to the flipped texture rows. @@ -31,8 +13,25 @@ fn scaled_uv(uv: vec2, scale: f32) -> vec2 { return vec2(uv.x*scale, 1.0-(1.0-uv.y)*scale); } -@fragment -fn fragment(i: VertexOutput) -> @location(0) vec4 { +fn shade(i: VertexOutput, + p: WorldParams, + frame_state: FrameState, + diffuse: texture_2d, + diffuse_sampler: sampler, + lightmap: texture_2d, + lm_sampler: sampler, + normal_map: texture_2d, + normal_sampler: sampler, + detail_map: texture_2d, + detail_sampler: sampler, + macro_map: texture_2d, + macro_sampler: sampler, + decal_map: texture_2d, + decal_sampler: sampler, + specular_map: texture_2d, + specular_sampler: sampler, + environment_map: texture_cube +) -> vec4 { let fam = u32(p.mode.x); let flags = u32(p.mode.y); var diffuse_uv=i.uv; @@ -269,3 +268,47 @@ fn fragment(i: VertexOutput) -> @location(0) vec4 { if a.a < p.mode.z { discard; } return vec4(xe,alpha); } + +@fragment +fn fragment(i: VertexOutput) -> @location(0) vec4 { +#ifdef BINDLESS + let index = bindings::indices[mesh[i.instance_index].material_and_lightmap_bind_group_slot & 0xffffu]; + return shade(i, + bindings::params[index.params], + bindings::frames[index.frame_state], + bindings::textures[index.diffuse], + bindings::samplers[index.diffuse_sampler], + bindings::textures[index.lightmap], + bindings::samplers[index.lm_sampler], + bindings::textures[index.normal_map], + bindings::samplers[index.normal_sampler], + bindings::textures[index.detail_map], + bindings::samplers[index.detail_sampler], + bindings::textures[index.macro_map], + bindings::samplers[index.macro_sampler], + bindings::textures[index.decal_map], + bindings::samplers[index.decal_sampler], + bindings::textures[index.specular_map], + bindings::samplers[index.specular_sampler], + bindings::cubes[index.environment_map]); +#else + return shade(i, + bindings::p, + bindings::frame_state, + bindings::diffuse, + bindings::diffuse_sampler, + bindings::lightmap, + bindings::lm_sampler, + bindings::normal_map, + bindings::normal_sampler, + bindings::detail_map, + bindings::detail_sampler, + bindings::macro_map, + bindings::macro_sampler, + bindings::decal_map, + bindings::decal_sampler, + bindings::specular_map, + bindings::specular_sampler, + bindings::environment_map); +#endif +} diff --git a/crates/skate-game/src/skate_world.rs b/crates/skate-game/src/skate_world.rs index feb169ea..d1688379 100644 --- a/crates/skate-game/src/skate_world.rs +++ b/crates/skate-game/src/skate_world.rs @@ -20,6 +20,7 @@ use skate_core::{ use skate_data::skate_map::SkateMap; use std::collections::HashMap; +#[cfg(test)] #[path = "retail_shadow_geometry.rs"] pub(crate) mod shadow_geometry; @@ -625,7 +626,6 @@ pub(crate) fn spawn( // lighting still needs separate geometry batches but can share a PBR material. let pbr_ids = self::material_ids(&map.materials, &texture_ids, false); let groups = consolidated_groups(map, &texture_ids, tuning, &mut texture); - let mut shadow_geometry = shadow_geometry::ShadowGeometry::default(); eprintln!( "SKATE_RENDER_BATCHES count={} triangles={}", groups.len(), @@ -635,10 +635,6 @@ pub(crate) fn spawn( vec![None; map.materials.len()]; for RenderGroup { material: material_index, indices, retail } in groups { let m = &map.materials[material_index]; - let shadow_proxy = retail.as_ref().is_some_and(shadow_geometry::eligible); - if shadow_proxy { - shadow_geometry.add(&map.geometry, &indices, retail.as_ref().unwrap().two_sided); - } // Reindex each batch, preserving authored normals and both UV sets. let mut remap = HashMap::new(); let mut vertices = Vec::new(); @@ -698,8 +694,7 @@ pub(crate) fn spawn( } if let Some(material) = retail { let material = retail_materials.add(material); - let mut entity = commands.spawn((Name::new(m.name.clone()), Mesh3d(meshes.add(mesh)), MeshMaterial3d(material), Transform::default())); - if shadow_proxy { entity.insert(bevy::light::NotShadowCaster); } + commands.spawn((Name::new(m.name.clone()), Mesh3d(meshes.add(mesh)), MeshMaterial3d(material), Transform::default())); continue; } // Vertex colours above carry retail decal coordinates, never PBR tint. @@ -745,7 +740,6 @@ pub(crate) fn spawn( }); } } - shadow_geometry.spawn(&map.geometry, commands, meshes, materials); for light in &map.lights { let color = Color::linear_rgb(light.color[0], light.color[1], light.color[2]); let transform = Transform::from_translation(Vec3::from_array(light.position)); @@ -850,7 +844,7 @@ mod tests { } #[test] - fn opaque_shadow_batches_are_owned_and_retired_with_the_map() { + fn retail_batches_are_owned_and_retired_with_the_map() { let map = retail_demo(); let mut world = World::new(); world.init_resource::>(); @@ -861,8 +855,7 @@ mod tests { let mut scene = crate::map_render::PreparedScene::new(&world); scene.prepare(Some(&map), std::path::Path::new("unused")); scene.publish(&mut world); - assert!(world.query_filtered::>().iter(&world).count() > 0); - assert!(world.query::<&bevy::camera::visibility::RenderLayers>().iter(&world).any(|layers| layers.intersects(&bevy::camera::visibility::RenderLayers::layer(shadow_geometry::LAYER)))); + assert_eq!(world.query_filtered::>().iter(&world).count(), 0); crate::map_render::MapAssets::retire(&mut world); assert_eq!(world.query_filtered::>().iter(&world).count(), 0); assert_eq!(world.resource::>().len(), 0); diff --git a/docs/cpu-material-binding-experiment.md b/docs/cpu-material-binding-experiment.md new file mode 100644 index 00000000..8cc838e3 --- /dev/null +++ b/docs/cpu-material-binding-experiment.md @@ -0,0 +1,61 @@ +# CPU material binding experiment + +The user reports poor FPS while looking into University and good FPS looking out +of the map, still CPU bound. The map being resident does not establish that every +triangle is drawn. The captured CPU render work is the actionable bottleneck. + +The v4 audit capture `university-audit-7802-28261.json` recorded 4,918 frames in +20 seconds without dropped trace events. Mean command generation was 1.782 ms per +frame; the main opaque encoder finish accounted for 0.995 ms, versus 0.349 ms in +opaque draw traversal. The v5 shadow batch capture +`university-shadow-batches-12344-4318.json` recorded 3,309 frames without dropped +events. Main encoder finish remained 1.012 ms per frame. Shadow visibility fell +from 0.466 to 0.349 ms, but that did not address the dominant main-pass cost. +These are different workloads, not a controlled performance comparison. + +A discarded CPU wall-occlusion prototype split at most 256 coarse batches and +used conservative whole-AABB occlusion against real opaque wall triangles. Its +static inward view culled only 55 of 3,512 candidates while costing about 1.15 ms. +It was removed before shipping; adding that CPU cost was unjustified. + +## Implementation + +RetailWorldMaterial now uses Bevy's bindless material allocator, with a resource +limit of 64 per slab. Materials share texture/sampler arrays and a packed parameter +buffer. The existing shared frame/shadow state remains a buffer reference. This +reduces distinct material binding groups and allows compatible meshes to share +multidraw batches. Pipeline, mesh allocation and resource capacity still constrain +batching; this is not a promise of a 64-fold draw reduction. + +The main and depth/shadow shaders use the material slot stored in Bevy's mesh +instance data. Both retain the original bindings when Bevy reports that bindless +resources are unsupported. The common shader module is permanently loaded using +Bevy's shader-library macro so custom imports resolve at runtime. The shading +body, alpha rejection, geometry and texture content are preserved. No user +graphics setting, native physics, input or simulation cadence changes. + +The unsuccessful v5 extra shadow meshes and their light-layer adjustment are +removed from the active renderer. Normal world meshes cast shadows again. The +data-only shadow experiment remains test-only for historical reproducibility. + +Trace configuration includes `retail_bindless`: true means the device supports +the selected material-array path, false means fallback, null means device data +was unavailable. This indicates capability selection, not measured draw counts. + +## Validation and acceptance + +GPU-free shader tests compose the actual material shaders and real Bevy vertex +interfaces, using fixtures for unrelated lighting resources/functions. They +validate six combinations: main, shadow depth and depth with normal/motion output, +each with bindless enabled and disabled. A separate test checks the Rust-generated +resource index table and buffer bindings against the shader contract. + +The release shader tests passed (both tests, six shader variants). Eleven world +adapter tests and two scene lifecycle tests passed; the offline map inventory +test remains ignored in this ordinary regression run. + +Runtime pipeline creation, appearance and FPS remain unverified until the user +runs the private executable. The agent does not launch gameplay. Compare v4 and +v6 from the same stationary inward-facing view with identical settings, then the +same route. Inspect `retail_bindless`, main opaque encoder finish, total command +generation and frame percentiles. Keep the earlier executables for rollback. diff --git a/docs/render-batching-experiment.md b/docs/render-batching-experiment.md index 53a7bfbd..22e746ce 100644 --- a/docs/render-batching-experiment.md +++ b/docs/render-batching-experiment.md @@ -1,5 +1,10 @@ # University render batching experiment +**Superseded in the active renderer:** the user's v5 test did not improve the +reported bottleneck. The v6 [CPU material binding experiment](cpu-material-binding-experiment.md) +removes the extra shadow meshes and layer adjustment. The inventory below records +the historical v5 candidate, not the current production spawn path. + The user authorized engine/content visual tradeoffs after the initial exact-output audit, while keeping resolution, MSAA, user settings and native gameplay timing unchanged. This first structural experiment targets redundant shadow submission;