From e109a0a34e9cc0e9b06beedc6b96006480094917 Mon Sep 17 00:00:00 2001 From: chazmm Date: Wed, 9 Sep 2026 16:59:13 +0100 Subject: [PATCH] Batch opaque retail shadow geometry independently of surface materials --- crates/skate-game/src/retail_render.rs | 31 +++ .../skate-game/src/retail_shadow_geometry.rs | 227 ++++++++++++++++++ crates/skate-game/src/skate_world.rs | 169 ++++++++++++- docs/render-batching-experiment.md | 84 +++++++ docs/university-performance-assessment.md | 5 + 5 files changed, 509 insertions(+), 7 deletions(-) create mode 100644 crates/skate-game/src/retail_shadow_geometry.rs create mode 100644 docs/render-batching-experiment.md diff --git a/crates/skate-game/src/retail_render.rs b/crates/skate-game/src/retail_render.rs index 71f28f9e..50a27ff8 100644 --- a/crates/skate-game/src/retail_render.rs +++ b/crates/skate-game/src/retail_render.rs @@ -18,6 +18,7 @@ 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") { @@ -141,6 +142,36 @@ pub(crate) struct RetailWorldMaterial { pub alpha: AlphaMode, pub two_sided: bool, } +/// Identity of the actual GPU inputs, independent of unused source metadata. +#[derive(PartialEq, Eq, Hash)] +pub(crate) struct WorldMaterialKey { + params: [[u32; 4]; 13], + images: [Option>; 8], + shadow: AssetId, + alpha: [u32; 2], + two_sided: bool, +} +impl RetailWorldMaterial { + pub(crate) fn batch_key(&self) -> Option { + // Blended geometry keeps its previous mesh centers and sorting groups. + let alpha = match self.alpha { + AlphaMode::Opaque => [0, 0], + AlphaMode::Mask(cutoff) => [1, cutoff.to_bits()], + _ => return None, + }; + let p = &self.params; + Some(WorldMaterialKey { + params: [p.mode, p.foliage_debug, p.surface, p.family, p.fog_ramp, + p.fog_color, p.shadow_color, p.sun_direction, p.decal, + p.water[0], p.water[1], p.water[2], p.water[3]] + .map(|v| v.to_array().map(f32::to_bits)), + images: [&self.diffuse, &self.lightmap, &self.normal, &self.detail, + &self.macro_map, &self.decal, &self.specular, &self.environment] + .map(|h| h.as_ref().map(Handle::id)), + shadow: self.shadow_state.id(), alpha, two_sided: self.two_sided, + }) + } +} #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub(crate) struct RetailKey { two_sided: bool, diff --git a/crates/skate-game/src/retail_shadow_geometry.rs b/crates/skate-game/src/retail_shadow_geometry.rs new file mode 100644 index 00000000..520df366 --- /dev/null +++ b/crates/skate-game/src/retail_shadow_geometry.rs @@ -0,0 +1,227 @@ +//! Shadow-only batches for static opaque retail geometry. Main meshes and all +//! collision data remain untouched. Alpha-tested/blended casters keep their own +//! materials so foliage silhouettes and transparency are not approximated. +use crate::map_render::AssetSink; +use bevy::{ + camera::visibility::{RenderLayers, VisibilitySystems}, + prelude::*, +}; +use std::collections::HashMap; + +pub(crate) const LAYER: usize = 27; +const CELL: f32 = 64.; + +pub(super) fn eligible(material: &crate::retail_render::RetailWorldMaterial) -> bool { + matches!(material.alpha, AlphaMode::Opaque) && material.params.mode.z < 0. +} + +pub(crate) fn install(app: &mut App) { + app.add_systems( + PostUpdate, + include_world_layer.before(VisibilitySystems::CheckVisibility), + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use bevy::ecs::system::RunSystemOnce; + + #[test] + fn shadow_layer_reaches_world_lights_but_not_cameras_or_player_only_lights() { + let mut world = World::new(); + let directional = world.spawn(DirectionalLight::default()).id(); + let point = world.spawn(PointLight::default()).id(); + let spot = world.spawn(SpotLight::default()).id(); + let player = world + .spawn((DirectionalLight::default(), RenderLayers::layer(28))) + .id(); + let isolated = world + .spawn((PointLight::default(), RenderLayers::layer(9))) + .id(); + let camera = world + .spawn((Camera3d::default(), RenderLayers::from_layers(&[0, 28]))) + .id(); + world.run_system_once(include_world_layer).unwrap(); + for entity in [directional, point, spot] { + let layers = world.get::(entity).unwrap(); + assert!(layers.intersects(&RenderLayers::default())); + assert!(layers.intersects(&RenderLayers::layer(LAYER))); + } + for entity in [player, isolated, camera] { + assert!( + !world + .get::(entity) + .unwrap() + .intersects(&RenderLayers::layer(LAYER)) + ); + } + } + + #[test] + fn spatial_shadow_batches_keep_boundary_crossing_triangles_normals_and_cull_modes() { + let mut map = skate_data::skate_map::SkateMap::parse(include_bytes!( + "../../../maps/format-demo.skate" + )) + .unwrap(); + map.geometry.vertices.truncate(3); + for (v, p) in + map.geometry + .vertices + .iter_mut() + .zip([[-130., 0., 0.], [130., 0., 0.], [0., 200., 1.]]) + { + v.position = p; + } + map.geometry.vertices[0].normal = [-0., 0.25, 0.75]; + let mut geometry = ShadowGeometry::default(); + geometry.add(&map.geometry, &[0, 1, 2], false); + geometry.add(&map.geometry, &[2, 1, 0], true); + assert_eq!(geometry.batches.len(), 2); + assert_eq!(geometry.batches[0], (false, vec![0, 1, 2])); + assert_eq!(geometry.batches[1], (true, vec![2, 1, 0])); + let mut commands = crate::map_render::SceneCommands::default(); + let mut meshes = Assets::::default(); + let mut materials = Assets::::default(); + geometry.spawn(&map.geometry, &mut commands, &mut meshes, &mut materials); + // Scene publication is tested through the normal map-retirement path; + // inspect generated CPU mesh data here without constructing a renderer. + assert_eq!(meshes.len(), 2); + for (_, mesh) in meshes.iter() { + let bevy::mesh::VertexAttributeValues::Float32x3(positions) = + mesh.attribute(Mesh::ATTRIBUTE_POSITION).unwrap() + else { + panic!() + }; + assert!(positions.contains(&[-130., 0., 0.])); + assert!(positions.contains(&[130., 0., 0.])); + let bevy::mesh::VertexAttributeValues::Float32x3(normals) = + mesh.attribute(Mesh::ATTRIBUTE_NORMAL).unwrap() + else { + panic!() + }; + assert!( + normals + .iter() + .any(|n| n.map(f32::to_bits) == [-0.0f32, 0.25, 0.75].map(f32::to_bits)) + ); + } + assert!(materials.iter().any(|(_, m)| m.cull_mode.is_none())); + assert!( + materials + .iter() + .any(|(_, m)| m.cull_mode == Some(bevy::render::render_resource::Face::Back)) + ); + } +} + +fn include_world_layer( + mut commands: Commands, + lights: Query< + (Entity, Option<&RenderLayers>), + Or<(With, With, With)>, + >, +) { + for (entity, layers) in &lights { + let layers = layers.cloned().unwrap_or_default(); + // World lights gain the shadow-only geometry. Player-only layer 28 and + // other isolated light masks retain their exact existing membership. + if layers.intersects(&RenderLayers::default()) + && !layers.intersects(&RenderLayers::layer(LAYER)) + { + commands.entity(entity).insert(layers.with(LAYER)); + } + } +} + +#[derive(Default)] +pub(super) struct ShadowGeometry { + lookup: HashMap<([i32; 3], bool), usize>, + pub(super) batches: Vec<(bool, Vec)>, +} +impl ShadowGeometry { + pub(super) fn add( + &mut self, + geometry: &skate_data::skate_map::Geometry, + indices: &[u32], + two_sided: bool, + ) { + for triangle in indices.chunks_exact(3) { + let center = triangle + .iter() + .map(|&i| Vec3::from_array(geometry.vertices[i as usize].position) / 3.) + .sum::(); + let cell = center.to_array().map(|v| (v / CELL).floor() as i32); + let batch = *self.lookup.entry((cell, two_sided)).or_insert_with(|| { + let index = self.batches.len(); + self.batches.push((two_sided, Vec::new())); + index + }); + // Full triangles, including those crossing a cell boundary. Bevy + // derives conservative bounds from all their positions, not the cell. + self.batches[batch].1.extend_from_slice(triangle); + } + } + + pub(super) fn spawn( + self, + geometry: &skate_data::skate_map::Geometry, + commands: &mut crate::map_render::SceneCommands, + meshes: &mut impl AssetSink, + materials: &mut impl AssetSink, + ) { + eprintln!( + "SKATE_SHADOW_BATCHES count={} triangles={}", + self.batches.len(), + self.batches.iter().map(|(_, i)| i.len() / 3).sum::() + ); + let mut handles: [Option>; 2] = [None, None]; + for (two_sided, indices) in self.batches { + let material = handles[usize::from(two_sided)] + .get_or_insert_with(|| { + materials.add(StandardMaterial { + unlit: true, + double_sided: two_sided, + cull_mode: if two_sided { + None + } else { + Some(bevy::render::render_resource::Face::Back) + }, + ..default() + }) + }) + .clone(); + let mut remap = HashMap::new(); + let mut positions = Vec::new(); + let mut normals = Vec::new(); + let local: Vec = indices + .into_iter() + .map(|i| { + *remap.entry(i).or_insert_with(|| { + let v = &geometry.vertices[i as usize]; + let index = positions.len() as u32; + positions.push(v.position); + // Shadow normal bias still sees the original normal, not a + // recomputed face normal from simplified geometry. + normals.push(v.normal); + index + }) + }) + .collect(); + let mesh = Mesh::new( + bevy::mesh::PrimitiveTopology::TriangleList, + bevy::asset::RenderAssetUsages::RENDER_WORLD, + ) + .with_inserted_attribute(Mesh::ATTRIBUTE_POSITION, positions) + .with_inserted_attribute(Mesh::ATTRIBUTE_NORMAL, normals) + .with_inserted_indices(bevy::mesh::Indices::U32(local)); + commands.spawn(( + Name::new("Retail opaque shadow batch"), + Mesh3d(meshes.add(mesh)), + MeshMaterial3d(material), + Transform::default(), + RenderLayers::layer(LAYER), + )); + } + } +} diff --git a/crates/skate-game/src/skate_world.rs b/crates/skate-game/src/skate_world.rs index 4175f345..feb169ea 100644 --- a/crates/skate-game/src/skate_world.rs +++ b/crates/skate-game/src/skate_world.rs @@ -20,6 +20,9 @@ use skate_core::{ use skate_data::skate_map::SkateMap; use std::collections::HashMap; +#[path = "retail_shadow_geometry.rs"] +pub(crate) mod shadow_geometry; + pub(crate) fn validate_runtime(map: &SkateMap) -> Result<(), String> { let _span = info_span!("validate_map").entered(); let archive = retail_archive(map)?; @@ -422,9 +425,88 @@ fn render_groups( groups } +struct RenderGroup { + material: usize, + indices: Vec, + retail: Option, +} + +fn consolidated_groups( + map: &SkateMap, + texture_ids: &[u32], + tuning: &crate::retail_render::MaterialTuning, + texture: &mut impl FnMut(u32, u8) -> Option>, +) -> Vec { + let ids = render_material_ids(&map.materials, texture_ids); + let mut lookup = HashMap::new(); + let mut groups: Vec = Vec::new(); + for (material, indices) in render_groups(&map.geometry, &ids) { + let m = &map.materials[material]; + let retail = m.retail_definition.as_deref() + .and_then(crate::retail_render::Definition::parse) + .filter(|d| d.supported(tuning)).map(|d| d.build(m, tuning, texture)); + // Tangent construction must not switch paths when batches are combined. + let explicit = indices.iter().all(|&i| map.geometry.vertices[i as usize].tangent_frame.is_some()); + let tangent_mode = if explicit { 0 } else if m.textures[2] != 0 { 1 } else { 2 }; + // Tangent generation can average shared vertices across old boundaries. + // Keep those batches separate rather than changing their shading. + if let Some(key) = retail.as_ref().and_then(|m| m.batch_key()).filter(|_| tangent_mode != 1) { + let destination = *lookup.entry((key, tangent_mode)).or_insert(groups.len()); + if destination < groups.len() { + groups[destination].indices.extend(indices); + continue; + } + } + groups.push(RenderGroup { material, indices, retail }); + } + groups +} + #[cfg(test)] mod performance_inventory { use super::*; + #[test] + #[ignore = "requires SKATE_MAP_INVENTORY, SKATE_ASSET_ROOT and SKATE_MAP_INVENTORY_OUT"] + fn consolidated_batches() { + let map = SkateMap::load(std::path::Path::new(&std::env::var_os("SKATE_MAP_INVENTORY").unwrap())).unwrap(); + let tuning = crate::retail_render::MaterialTuning::load(std::path::Path::new(&std::env::var_os("SKATE_ASSET_ROOT").unwrap())); + let textures = render_texture_ids(&map.textures); + let baseline = render_groups(&map.geometry, &render_material_ids(&map.materials, &textures)); + let mut texture = |id: u32, role: u8| { + let id = textures[id as usize]; + if id == 0 || (role == 5 && map.textures[id as usize - 1].height != map.textures[id as usize - 1].width * 6) { return None; } + Some(Handle::Uuid(bevy::asset::uuid::Uuid::from_u128((u128::from(id) << 8) | u128::from(role)), default())) + }; + let groups = consolidated_groups(&map, &textures, &tuning, &mut texture); + let mut before: Vec<[u32; 3]> = map.geometry.indices.chunks_exact(3).map(|t| t.try_into().unwrap()).collect(); + let mut after: Vec<[u32; 3]> = groups.iter().flat_map(|g| g.indices.chunks_exact(3).map(|t| t.try_into().unwrap())).collect(); + before.sort_unstable(); after.sort_unstable(); assert_eq!(before, after); + let mut opaque = 0; let mut masked = 0; let mut blended = 0; let mut fallback = 0; + for group in &groups { + match group.retail.as_ref().map(|m| m.alpha) { + Some(AlphaMode::Opaque) => opaque += 1, + Some(AlphaMode::Mask(_)) => masked += 1, + Some(_) => blended += 1, + None => fallback += 1, + } + } + let mut shadows = shadow_geometry::ShadowGeometry::default(); + let mut source_shadow_triangles: Vec<[u32; 3]> = Vec::new(); + let mut replaced_casters = 0; + for group in &groups { + if let Some(material) = group.retail.as_ref().filter(|m| shadow_geometry::eligible(m)) { + shadows.add(&map.geometry, &group.indices, material.two_sided); + source_shadow_triangles.extend(group.indices.chunks_exact(3).map(|t| [t[0], t[1], t[2]])); + replaced_casters += 1; + } + } + let mut batched_shadow_triangles: Vec<[u32; 3]> = shadows.batches.iter().flat_map(|(_, indices)| indices.chunks_exact(3).map(|t| t.try_into().unwrap())).collect(); + source_shadow_triangles.sort_unstable(); batched_shadow_triangles.sort_unstable(); + assert_eq!(source_shadow_triangles, batched_shadow_triangles); + let proxy_vertices: usize = shadows.batches.iter().map(|(_, indices)| indices.iter().collect::>().len()).sum(); + let result = serde_json::json!({"baseline_batches":baseline.len(),"consolidated_batches":groups.len(),"opaque":opaque,"masked":masked,"blended":blended,"fallback":fallback,"triangles":after.len(),"map_lights":map.lights.len(),"triangle_multiset_preserved":true,"replaced_opaque_shadow_casters":replaced_casters,"opaque_shadow_batches":shadows.batches.len(),"shadow_triangle_multiset_preserved":true,"opaque_shadow_triangles":source_shadow_triangles.len(),"proxy_vertices":proxy_vertices,"proxy_vertex_index_bytes":proxy_vertices*24+source_shadow_triangles.len()*12}); + std::fs::write(std::env::var_os("SKATE_MAP_INVENTORY_OUT").unwrap(),serde_json::to_vec_pretty(&result).unwrap()).unwrap(); + } /// Explicit read-only inventory, never initializes Bevy or a GPU. Asset path /// stays in the environment; output contains counts/bounds only. #[test] @@ -539,11 +621,11 @@ pub(crate) fn spawn( .clone(), ) }; - let material_ids = render_material_ids(&map.materials, &texture_ids); // Lightmaps belong to mesh entities, not StandardMaterial. Distinct baked // 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 = render_groups(&map.geometry, &material_ids); + 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(), @@ -551,8 +633,12 @@ pub(crate) fn spawn( ); let mut material_handles: Vec>> = vec![None; map.materials.len()]; - for (material_index, indices) in groups { + 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(); @@ -610,11 +696,10 @@ pub(crate) fn spawn( warn!("SKATE material {} tangent generation: {error}", m.name); } } - if let Some(definition) = m.retail_definition.as_deref() - .and_then(crate::retail_render::Definition::parse).filter(|d| d.supported(tuning)) { - let material = definition.build(m, tuning, &mut texture); + if let Some(material) = retail { let material = retail_materials.add(material); - commands.spawn((Name::new(m.name.clone()), Mesh3d(meshes.add(mesh)), MeshMaterial3d(material), Transform::default())); + 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); } continue; } // Vertex colours above carry retail decal coordinates, never PBR tint. @@ -660,6 +745,7 @@ 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)); @@ -714,6 +800,75 @@ mod tests { fn demo() -> SkateMap { SkateMap::parse(include_bytes!("../../../maps/format-demo.skate")).unwrap() } + fn retail_demo() -> SkateMap { + fn definition(unused: &str) -> Vec { + fn text(bytes: &mut Vec, value: &str) { bytes.extend_from_slice(&(value.len() as u32).to_le_bytes()); bytes.extend_from_slice(value.as_bytes()); } + let mut bytes = vec![0; 16]; + text(&mut bytes, "world.default"); + bytes.extend_from_slice(&1u32.to_le_bytes()); // supported family + bytes.extend_from_slice(&0u32.to_le_bytes()); // one-sided + bytes.extend_from_slice(&0u32.to_le_bytes()); // no explicit bindings + bytes.extend_from_slice(&1u32.to_le_bytes()); + text(&mut bytes, "unused_source_property"); + bytes.extend_from_slice(&1u32.to_le_bytes()); text(&mut bytes, unused); + text(&mut bytes, ""); bytes + } + let mut map = demo(); + map.materials[0].retail_definition = Some(definition("a")); + map.materials[0].alpha_mode = 0; + let mut second = demo().materials.remove(0); + second.retail_definition = Some(definition("b")); + second.alpha_mode = 0; + second.roughness = 0.12345; // ignored by the retail shader + map.materials.push(second); + let original_vertices = map.geometry.vertices.len() as u32; + let duplicates: Vec<_> = map.geometry.vertices.iter().map(|v| { let mut v = v.clone(); v.material = 2; v.position[0] += 3.; v }).collect(); + map.geometry.vertices.extend(duplicates); + map.geometry.indices.extend(map.geometry.indices.clone().into_iter().map(|i| i + original_vertices)); + for v in &mut map.geometry.vertices { v.tangent_frame = Some([0, 127, 0, 127]); } + map + } + + #[test] + fn effective_batches_ignore_unused_metadata_but_preserve_lighting_and_blend_groups() { + let mut map = retail_demo(); + let textures = render_texture_ids(&map.textures); + let tuning = crate::retail_render::MaterialTuning::default(); + let mut texture = |id: u32, role: u8| (id != 0).then(|| Handle::Uuid(bevy::asset::uuid::Uuid::from_u128((u128::from(textures[id as usize]) << 8) | u128::from(role)), default())); + assert_eq!(render_groups(&map.geometry, &render_material_ids(&map.materials, &textures)).len(), 2); + let groups = consolidated_groups(&map, &textures, &tuning, &mut texture); + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].indices.len(), map.geometry.indices.len()); + let original_lightmap = map.materials[1].textures[1]; + map.materials[1].textures[1] = if original_lightmap == 0 { map.materials[0].textures[0] } else { 0 }; + assert_eq!(consolidated_groups(&map, &textures, &tuning, &mut texture).len(), 2); + map.materials[1].textures[1] = original_lightmap; + for m in &mut map.materials { m.alpha_mode = 2; } + let groups = consolidated_groups(&map, &textures, &tuning, &mut texture); + assert_eq!(groups.len(), 2); + assert!(groups.iter().all(|g| !shadow_geometry::eligible(g.retail.as_ref().unwrap()))); + } + + #[test] + fn opaque_shadow_batches_are_owned_and_retired_with_the_map() { + let map = retail_demo(); + let mut world = World::new(); + world.init_resource::>(); + world.init_resource::>(); + world.init_resource::>(); + world.init_resource::>(); + world.init_resource::>(); + 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)))); + crate::map_render::MapAssets::retire(&mut world); + assert_eq!(world.query_filtered::>().iter(&world).count(), 0); + assert_eq!(world.resource::>().len(), 0); + assert_eq!(world.resource::>().len(), 0); + assert_eq!(world.resource::>().len(), 0); + } #[test] fn render_batches_preserve_complete_triangles_and_materials() { let map = demo(); diff --git a/docs/render-batching-experiment.md b/docs/render-batching-experiment.md new file mode 100644 index 00000000..b38080d3 --- /dev/null +++ b/docs/render-batching-experiment.md @@ -0,0 +1,84 @@ +# University render batching experiment + +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; +it does not yet simplify triangle geometry or lower shadow-map quality. + +## What the inventory established + +Read-only analysis used the same University v14 package and actual material builder +as the preceding captures. Synthetic image handles in the inventory preserve +canonical texture identity, sampler role and invalid-cube fallback; no GPU starts. + +| Quantity | Before | Experiment | +|---|---:|---:| +| Main world batches before visibility | 4,870 | 4,868 | +| Opaque static world shadow casters/batches | 3,414 | 680 | +| Opaque shadow triangles | 1,142,084 | 1,142,084 | +| All main world triangles | 1,645,617 | 1,645,617 | + +Exact main-material consolidation saves only two batches (0.04%). It is not the +large main-pass optimization suggested by the original material count. Further +main-pass gains would require a different binding/submission strategy or an +intentional content change, not merely removing unused material metadata. + +Opaque shadow batch count falls by about 80%. The remaining 1,414 masked, 12 blended +and 28 fallback world batches retain their existing shadow paths. Consequently, +the static-world caster candidate count is approximately 2,134 rather than 4,868 +after main consolidation, before per-view/cascade culling. These are resource +counts, not measured draws, GPU time or an FPS improvement. + +The additional proxy meshes contain 1,358,304 vertices and 1,142,084 triangles: +46,304,304 bytes (44.2 MiB) of position/normal/index payload. Allocator and renderer +metadata are additional. This trades memory and some mesh preparation for fewer +shadow candidates and material switches; only a runtime comparison can establish +the net benefit. The 64 m cell size is a conservative initial experiment, not a +measured optimum. + +## Implementation + +`RetailWorldMaterial::batch_key` compares every current uniform lane by float bits, +texture/sampler-role handles, shared storage, alpha cutoff and culling state. +Source-only metadata no longer forces separate identical retail batches. Blended +geometry retains its old grouping and sorting centers. Batches needing generated +tangents remain separate to avoid changing averaging across old boundaries. + +For opaque static retail meshes with no alpha rejection, `retail_shadow_geometry` +assigns complete triangles to 64 m cells by centroid and separates one-sided and +two-sided casters. Bounds come from the full vertex positions, including triangles +that cross cell boundaries. The shadow meshes retain authored positions, normals, +indices and winding. They use two shared opaque depth-capable StandardMaterials, +so they do not bind all the world's surface textures for depth rendering. + +Original opaque main meshes receive `NotShadowCaster`; their shadow geometry is +on reserved internal render layer 27. World lights whose masks contain layer 0 +also receive layer 27 before visibility processing. Main cameras do not receive +that layer. The layer-28 player-only shadow light is unchanged. This also handles +world point/spot lights; University itself contains no authored map lights. + +Alpha-tested foliage, transparent surfaces, unsupported/fallback materials, sky, +backdrops and character meshes retain their prior paths. Collision, rails, physics, +input sampling and simulation cadence are untouched. All proxy entities and meshes +use the normal map asset ownership/retirement path, including abandoned preparation. + +## Verification and runtime acceptance + +Thirteen release tests passed for the map/render adapter, including new checks for +effective material identity, distinct lightmap/blend groups, full boundary-crossing +shadow triangles, exact normal bits, culling modes, reserved light/camera layers +and proxy retirement. Two scene lifecycle tests and two character-lighting tests +also passed. The separate ignored University inventory test passed exact triangle +multiset equality for both main geometry and the opaque shadow geometry. + +No gameplay or GPU was started by the agent. Runtime acceptance still needs the +private build to load and render correctly, especially main-world lighting on the +skater, player shadows on the ground, foliage silhouettes, camera movement and +map transitions. Compare shadow visibility, shadow queue/draw costs, render command +generation, frame percentiles and memory against the preceding build. The previous +EXE/PDB and captures remain available for comparison and rollback. + +The capture includes `main_opaque_pass_close` and `main_opaque_encoder_finish` CPU +scopes added in the preceding build. They should identify whether the dominant +main-pass command time is draw traversal or pass/encoder completion before a larger +material binding redesign is attempted. No FPS gain is claimed from the inventory. diff --git a/docs/university-performance-assessment.md b/docs/university-performance-assessment.md index 72de8600..ee9ccbcf 100644 --- a/docs/university-performance-assessment.md +++ b/docs/university-performance-assessment.md @@ -1,5 +1,10 @@ # University: performance assessment, 9 September 2026 +Follow-up: the user subsequently authorized engine/content visual tradeoffs. +See [the render batching experiment](render-batching-experiment.md) for the newer +material census and static shadow batching implementation. The original constraints +and capture findings below describe the preceding investigation. + This investigation preserves the current rendered image and native-derived gameplay contract. It does not lower resolution, texture precision, sampler fidelity, density, draw distance, shadows, animation quality or physics cadence. It introduces no LOD.