Batch opaque retail shadow geometry independently of surface materials

This commit is contained in:
chazmm committed 2026-09-09 16:59:13 +01:00
1 parent 632b46a2ef
commit e109a0a34e
5 files changed
+509 -7

No files matched your search

+31
View File
@@ -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<AssetId<Image>>; 8],
shadow: AssetId<bevy::render::storage::ShaderStorageBuffer>,
alpha: [u32; 2],
two_sided: bool,
}
impl RetailWorldMaterial {
pub(crate) fn batch_key(&self) -> Option<WorldMaterialKey> {
// 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,
@@ -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::<RenderLayers>(entity).unwrap();
assert!(layers.intersects(&RenderLayers::default()));
assert!(layers.intersects(&RenderLayers::layer(LAYER)));
}
for entity in [player, isolated, camera] {
assert!(
!world
.get::<RenderLayers>(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::<Mesh>::default();
let mut materials = Assets::<StandardMaterial>::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<DirectionalLight>, With<PointLight>, With<SpotLight>)>,
>,
) {
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<u32>)>,
}
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::<Vec3>();
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<Mesh>,
materials: &mut impl AssetSink<StandardMaterial>,
) {
eprintln!(
"SKATE_SHADOW_BATCHES count={} triangles={}",
self.batches.len(),
self.batches.iter().map(|(_, i)| i.len() / 3).sum::<usize>()
);
let mut handles: [Option<Handle<StandardMaterial>>; 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<u32> = 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),
));
}
}
}
+162 -7
View File
@@ -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<u32>,
retail: Option<crate::retail_render::RetailWorldMaterial>,
}
fn consolidated_groups(
map: &SkateMap,
texture_ids: &[u32],
tuning: &crate::retail_render::MaterialTuning,
texture: &mut impl FnMut(u32, u8) -> Option<Handle<Image>>,
) -> Vec<RenderGroup> {
let ids = render_material_ids(&map.materials, texture_ids);
let mut lookup = HashMap::new();
let mut groups: Vec<RenderGroup> = 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::<std::collections::HashSet<_>>().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<Option<Handle<StandardMaterial>>> =
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<u8> {
fn text(bytes: &mut Vec<u8>, 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::<Assets<Mesh>>();
world.init_resource::<Assets<StandardMaterial>>();
world.init_resource::<Assets<crate::retail_render::RetailWorldMaterial>>();
world.init_resource::<Assets<crate::retail_render::RetailSkyMaterial>>();
world.init_resource::<Assets<Image>>();
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))));
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);
assert_eq!(world.resource::<Assets<StandardMaterial>>().len(), 0);
assert_eq!(world.resource::<Assets<crate::retail_render::RetailWorldMaterial>>().len(), 0);
}
#[test]
fn render_batches_preserve_complete_triangles_and_materials() {
let map = demo();
+84
View File
@@ -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.
@@ -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.