Batch retail material bindings to reduce CPU render commands

This commit is contained in:
chazmm committed 2026-09-09 17:35:40 +01:00
1 parent e0320796c1
commit bea625f987
11 files changed
+282 -45

No files matched your search

Generated
+2
View File
@@ -4738,6 +4738,8 @@ version = "0.1.0"
dependencies = [
"bevy",
"half",
"naga",
"naga_oil",
"serde",
"serde_json",
"skate-core",
+4
View File
@@ -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 }
+1 -1
View File
@@ -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::<Vec<_>>(),"cameras":cameras.iter().map(|(c,m)|json!({"size":c.physical_target_size().map(|s|[s.x,s.y]),"msaa":m.samples()})).collect::<Vec<_>>()}}));
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::<crate::retail_render::RetailWorldMaterial>(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::<Vec<_>>(),"cameras":cameras.iter().map(|(c,m)|json!({"size":c.physical_target_size().map(|s|[s.x,s.y]),"msaa":m.samples()})).collect::<Vec<_>>()}}));
}
for diagnostic in diagnostics.iter() {
let path = diagnostic.path().as_str();
@@ -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<storage> mesh: array<Mesh>;"),
("frame", "#define_import_path bevy_pbr::mesh_view_bindings\nstruct View {view_from_world:mat4x4<f32>,viewport:vec4<f32>,world_position:vec3<f32>,padding:f32}\nstruct Light {flags:u32}\nstruct Lights {n_directional_lights:u32,directional_lights:array<Light,10>}\n@group(0) @binding(0) var<uniform> view:View;\n@group(0) @binding(1) var<storage> lights:Lights;"),
("shadows", "#define_import_path bevy_pbr::shadows\nfn fetch_directional_shadow(id:u32,p:vec4<f32>,n:vec3<f32>,z:f32)->f32 {return 1.0;}"),
("motion", "#define_import_path bevy_pbr::pbr_prepass_functions\nfn calculate_motion_vector(p:vec4<f32>,q:vec4<f32>)->vec2<f32> {return vec2<f32>(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"]);
}
}
+13 -5
View File
@@ -5,17 +5,25 @@
#ifdef MOTION_VECTOR_PREPASS
#import bevy_pbr::pbr_prepass_functions::calculate_motion_vector
#endif
struct WorldParams { mode: vec4<f32>, foliage_debug: vec4<f32>, surface: vec4<f32>, family: vec4<f32>, fog_ramp: vec4<f32>, fog_color: vec4<f32>, shadow_color: vec4<f32>, sun_direction: vec4<f32>, decal: vec4<f32>, water: array<vec4<f32>, 4> }
@group(#{MATERIAL_BIND_GROUP}) @binding(0) var<uniform> p: WorldParams;
@group(#{MATERIAL_BIND_GROUP}) @binding(1) var diffuse: texture_2d<f32>;
@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
@@ -0,0 +1,54 @@
#define_import_path skate_retail::material_bindings
struct WorldParams {
mode: vec4<f32>, foliage_debug: vec4<f32>, surface: vec4<f32>, family: vec4<f32>,
fog_ramp: vec4<f32>, fog_color: vec4<f32>, shadow_color: vec4<f32>, sun_direction: vec4<f32>, decal: vec4<f32>, water: array<vec4<f32>, 4>,
}
struct FrameState { shadow: vec4<f32>, clock: vec4<f32>, pca: array<vec4<f32>, 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<storage> indices: array<MaterialIndices>;
@group(#{MATERIAL_BIND_GROUP}) @binding(1) var samplers: binding_array<sampler>;
@group(#{MATERIAL_BIND_GROUP}) @binding(5) var textures: binding_array<texture_2d<f32>>;
@group(#{MATERIAL_BIND_GROUP}) @binding(8) var cubes: binding_array<texture_cube<f32>>;
@group(#{MATERIAL_BIND_GROUP}) @binding(17) var<storage> params: array<WorldParams>;
@group(#{MATERIAL_BIND_GROUP}) @binding(18) var<storage> frames: binding_array<FrameState>;
#else
@group(#{MATERIAL_BIND_GROUP}) @binding(0) var<uniform> p: WorldParams;
@group(#{MATERIAL_BIND_GROUP}) @binding(1) var diffuse: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(2) var diffuse_sampler: sampler;
@group(#{MATERIAL_BIND_GROUP}) @binding(3) var lightmap: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(4) var lm_sampler: sampler;
@group(#{MATERIAL_BIND_GROUP}) @binding(5) var normal_map: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(6) var normal_sampler: sampler;
@group(#{MATERIAL_BIND_GROUP}) @binding(7) var detail_map: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(8) var detail_sampler: sampler;
@group(#{MATERIAL_BIND_GROUP}) @binding(9) var macro_map: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(10) var macro_sampler: sampler;
@group(#{MATERIAL_BIND_GROUP}) @binding(11) var decal_map: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(12) var decal_sampler: sampler;
@group(#{MATERIAL_BIND_GROUP}) @binding(13) var specular_map: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(14) var specular_sampler: sampler;
@group(#{MATERIAL_BIND_GROUP}) @binding(15) var environment_map: texture_cube<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(16) var<storage, read> frame_state: FrameState;
#endif
+10 -3
View File
@@ -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<Handle<Image>>,
#[texture(15, dimension = "cube")]
pub environment: Option<Handle<Image>>,
#[storage(16, read_only)]
#[storage(16, read_only, binding_array(18))]
pub shadow_state: Handle<bevy::render::storage::ShaderStorageBuffer>,
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 {
+68 -25
View File
@@ -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<f32>, foliage_debug: vec4<f32>, surface: vec4<f32>, family: vec4<f32>,
fog_ramp: vec4<f32>, fog_color: vec4<f32>, shadow_color: vec4<f32>, sun_direction: vec4<f32>, decal: vec4<f32>, water: array<vec4<f32>, 4>,
}
@group(#{MATERIAL_BIND_GROUP}) @binding(0) var<uniform> p: WorldParams;
@group(#{MATERIAL_BIND_GROUP}) @binding(1) var diffuse: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(2) var diffuse_sampler: sampler;
@group(#{MATERIAL_BIND_GROUP}) @binding(3) var lightmap: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(4) var lm_sampler: sampler;
@group(#{MATERIAL_BIND_GROUP}) @binding(5) var normal_map: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(6) var normal_sampler: sampler;
@group(#{MATERIAL_BIND_GROUP}) @binding(7) var detail_map: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(8) var detail_sampler: sampler;
@group(#{MATERIAL_BIND_GROUP}) @binding(9) var macro_map: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(10) var macro_sampler: sampler;
@group(#{MATERIAL_BIND_GROUP}) @binding(11) var decal_map: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(12) var decal_sampler: sampler;
@group(#{MATERIAL_BIND_GROUP}) @binding(13) var specular_map: texture_2d<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(14) var specular_sampler: sampler;
@group(#{MATERIAL_BIND_GROUP}) @binding(15) var environment_map: texture_cube<f32>;
struct FrameState { shadow: vec4<f32>, clock: vec4<f32>, pca: array<vec4<f32>, 7> }
@group(#{MATERIAL_BIND_GROUP}) @binding(16) var<storage, read> 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<f32>, scale: f32) -> vec2<f32> {
return vec2<f32>(uv.x*scale, 1.0-(1.0-uv.y)*scale);
}
@fragment
fn fragment(i: VertexOutput) -> @location(0) vec4<f32> {
fn shade(i: VertexOutput,
p: WorldParams,
frame_state: FrameState,
diffuse: texture_2d<f32>,
diffuse_sampler: sampler,
lightmap: texture_2d<f32>,
lm_sampler: sampler,
normal_map: texture_2d<f32>,
normal_sampler: sampler,
detail_map: texture_2d<f32>,
detail_sampler: sampler,
macro_map: texture_2d<f32>,
macro_sampler: sampler,
decal_map: texture_2d<f32>,
decal_sampler: sampler,
specular_map: texture_2d<f32>,
specular_sampler: sampler,
environment_map: texture_cube<f32>
) -> vec4<f32> {
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<f32> {
if a.a < p.mode.z { discard; }
return vec4<f32>(xe,alpha);
}
@fragment
fn fragment(i: VertexOutput) -> @location(0) vec4<f32> {
#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
}
+4 -11
View File
@@ -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::<Assets<Mesh>>();
@@ -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::<Entity, With<bevy::light::NotShadowCaster>>().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::<Entity, With<bevy::light::NotShadowCaster>>().iter(&world).count(), 0);
crate::map_render::MapAssets::retire(&mut world);
assert_eq!(world.query_filtered::<Entity, With<crate::map_render::MapEntity>>().iter(&world).count(), 0);
assert_eq!(world.resource::<Assets<Mesh>>().len(), 0);
+61
View File
@@ -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.
+5
View File
@@ -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;