mirror of
https://github.com/SK8-ENGINE/skate-3-rust-engine.git
synced 2026-10-26 19:47:44 +00:00
Batch opaque retail shadow geometry independently of surface materials
This commit is contained in:
1 parent
632b46a2ef
commit
e109a0a34e
5 files changed
+509
-7
No files matched your search
@@ -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),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user