shadow: skin the character caster on the GPU, so a crowd costs no CPU skin

The character shadow lane CPU-skinned every admitted unit into one merged
Mesh asset and re-uploaded it each rebuild — the documented expensive half
of the lane's ~3 ms, and the part that scales with players. Replace it with
one GPU-skinned caster per admitted creature part: the part's own skinned
mesh and MeshTag, drawn through a new SkinnedShadowCasterMaterial whose
prepass vertex skins from the shared rig palette (the same rows wow_model
reads). Pose changes are free; only membership is reconciled per frame.

Palette region offsets are pushed as shader defs, so the caster shader reads
the shared light buffer's tail as a flat array and needs no copy of the
WowLight layout.
This commit is contained in:
chairmanpow
2026-10-03 16:37:38 +01:00
parent 6eaba32006
commit 4c15ab4bdd
5 changed files with 286 additions and 115 deletions
+101 -107
View File
@@ -1,24 +1,27 @@
//! The CHARACTER shadow lane — a thin plug-in on top of [`super::shadow_core`].
//!
//! It owns nothing but its own per-frame caster (players, NPCs, creatures, mounts). When
//! `characterShadows` is on it declares demand to the shared rig ([`ShadowDemand`]); while the rig
//! is live it collects the CREATURE entity parts into its caster using the rig's shared material and
//! the per-frame facts the rig publishes ([`ShadowFrame`]). It does not know [`super::world_shadow`]
//! exists — remove either lane and the other is untouched.
//! It owns the per-unit caster for players, NPCs, creatures and mounts. When `characterShadows` is
//! on it declares demand to the shared rig ([`ShadowDemand`]); while the rig is live it keeps one
//! GPU-skinned caster per admitted creature part, drawn through [`SkinnedShadowCasterMaterial`].
//!
//! The caster carries the part's own skinned mesh and `MeshTag`, so it skins in the shadow pass's
//! VERTEX stage from the shared rig palette the forward model already uploads — no per-frame CPU
//! skinning, no merged mesh, no re-upload. Pose changes are free: only membership (spawn/despawn,
//! range, occluder) is reconciled here. It does not know [`super::world_shadow`] exists.
use bevy::ecs::entity::EntityHashSet;
use bevy::pbr::MeshMaterial3d;
use bevy::mesh::MeshTag;
use bevy::platform::collections::HashMap;
use bevy::prelude::*;
use benilla_assets::materials::WowModelMaterial;
use benilla_world::billboard::BillboardCard;
use benilla_world::interact::PickMesh;
use benilla_world::lighting::SharedLightBuffer;
use benilla_world::model_render::{ModelPart, ShadowOccluder};
use benilla_world::rig_palette::{RigPalettes, RigPart, RigSkin};
use benilla_world::rig_palette::RigPart;
use crate::shadow_core::{
collect_entity_geometry, empty_shadow_mesh, restore_mesh_buffers, shadow_trace,
spawn_solid_caster, take_mesh_buffers, RebuildRate, ShadowDemand, ShadowFrame, ShadowSet,
casts_realtime_shadow, shadow_trace, spawn_skinned_caster, ShadowDemand, ShadowFrame,
ShadowSet, SkinnedShadowCasterMaterial,
};
use crate::video::VideoConfig;
@@ -32,141 +35,132 @@ impl Plugin for CharacterShadowPlugin {
}
}
/// MONKEY (moon shadows): roots/ancestors with triangles in the LAST completed caster build.
/// Blobs consume this in PostUpdate, before this frame's Last rebuild: a newly uploaded proxy
/// gets a render frame before its oval yields. Cadence skips retain the same geometry/verdict.
/// MONKEY (moon shadows): roots/ancestors with a live caster in the LAST completed reconciliation.
/// Blobs consume this in PostUpdate, before this frame's Last pass: a newly spawned caster gets a
/// render frame before its oval yields.
#[derive(Resource, Default)]
pub(crate) struct CharacterShadowReady(pub EntityHashSet);
/// The character lane's retained per-frame caster.
/// The character lane's retained casters: part entity → its shadow-caster entity.
#[derive(Resource, Default)]
struct CharacterLane {
caster: Option<Entity>,
mesh: Option<Handle<Mesh>>,
/// [`shadow_trace`]'s change detector: (tris, admitted, rejected).
casters: HashMap<Entity, Entity>,
material: Option<Handle<SkinnedShadowCasterMaterial>>,
/// [`shadow_trace`]'s change detector: (admitted, rejected, casters).
traced: (u32, u32, u32),
/// MONKEY (sun shadow perf): the `characterShadowRate` cadence gate — see the rebuild comment
/// in [`collect_character_shadows`].
rate: RebuildRate,
}
/// Drop every caster and the shared material handle.
fn teardown(commands: &mut Commands, lane: &mut CharacterLane) {
for (_, caster) in lane.casters.drain() {
commands.entity(caster).despawn();
}
lane.material = None;
}
#[allow(clippy::too_many_arguments)]
fn collect_character_shadows(
video: Res<VideoConfig>,
time: Res<Time>,
mut demand: ResMut<ShadowDemand>,
frame: Res<ShadowFrame>,
mut lane: ResMut<CharacterLane>,
mut ready: ResMut<CharacterShadowReady>,
parents: Query<&ChildOf>,
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
light: Option<Res<SharedLightBuffer>>,
mut materials: ResMut<Assets<SkinnedShadowCasterMaterial>>,
parts: Query<
(
Entity,
&PickMesh,
&Mesh3d,
&MeshTag,
&ModelPart,
&ShadowOccluder,
Option<&GlobalTransform>,
Option<&RigPart>,
&ShadowOccluder,
Option<&MeshMaterial3d<WowModelMaterial>>,
),
Without<BillboardCard>,
>,
rigs: Query<&RigSkin>,
palettes: Res<RigPalettes>,
) {
let on = video.character_shadows;
// Declare demand so the shared rig stays up while this lane is on (the rig reads it next frame).
demand.0 = demand.0 || on;
// Rig down or lane off → drop our caster so it stops casting.
if !(frame.active && on) {
// Rig down, lane off, or an off night → drop every caster so none keeps casting.
if !(frame.active && on) || frame.suspended {
ready.0.clear();
if let Some(entity) = lane.caster.take() {
commands.entity(entity).despawn();
}
if let Some(handle) = lane.mesh.take() {
meshes.remove(handle.id());
}
// The mesh this gate was pacing is gone — re-arm so a re-enable builds on its first frame.
lane.rate.reset();
teardown(&mut commands, &mut lane);
return;
}
// MONKEY (moon shadows): keep the cache but suspend CPU skin/re-upload on feature-off nights.
if frame.suspended {
ready.0.clear();
lane.rate.reset();
return;
}
let Some(material) = frame.material.clone() else {
return;
let Some(light) = light else {
return; // the shared buffer is not up yet; the rig will still be live next frame
};
if lane.material.is_none() {
lane.material = Some(materials.add(SkinnedShadowCasterMaterial {
light_buf: light.0.clone(),
}));
}
let material = lane
.material
.clone()
.expect("the material was inserted just above");
// Animated rigs re-skin every frame and creatures spawn/despawn under lifecycles this lane
// can't cheaply observe — so the caster is collected fresh, at a CAPPED cadence.
if lane.caster.is_none() {
let mesh = meshes.add(empty_shadow_mesh());
let entity = spawn_solid_caster(&mut commands, mesh.clone(), material);
lane.caster = Some(entity);
lane.mesh = Some(mesh);
// A brand-new caster is an EMPTY mesh; make sure this frame fills it.
lane.rate.reset();
ready.0.clear();
let mut wanted = EntityHashSet::default();
let (reach, light_pos) = (frame.entity_reach, frame.light_position);
let (mut admitted, mut rejected) = (0u32, 0u32);
for (part, mesh, tag, model_part, occluder, global, rig_part) in &parts {
if !casts_realtime_shadow(model_part.kind, model_part.blend, true, false) {
continue;
}
// The camera-independent content gate (toggles, far clip, distance fade, material alpha).
if !occluder.0 {
rejected += 1;
continue;
}
// GPU skinning needs the part's rig; a part with no palette would render in bind pose.
if rig_part.is_none() {
rejected += 1;
continue;
}
let anchor = global.map(GlobalTransform::translation).unwrap_or(Vec3::ZERO);
if anchor.distance_squared(light_pos) > reach * reach {
continue;
}
admitted += 1;
wanted.insert(part);
ready.0.insert(part);
// The unit root owns its blob; mounts and equipment can be nested below it.
ready.0.extend(parents.iter_ancestors(part));
if !lane.casters.contains_key(&part) {
let caster =
spawn_skinned_caster(&mut commands, mesh.0.clone(), material.clone(), tag.0);
lane.casters.insert(part, caster);
}
}
// MONKEY (sun shadow perf): the `characterShadowRate` gate. This rebuild is the expensive half
// of the ~3 ms the character lane costs — it CPU-skins every admitted unit and then mutates the
// `Mesh` asset, and a mutated mesh is re-extracted and re-uploaded (vertices AND indices) to the
// GPU. Skipping it simply leaves the previous proxy in place: the shadow MAP is still rendered
// from that proxy every frame, so nothing flickers or disappears — a moving unit's silhouette
// merely lags by up to 1/rate second.
//
// Why a rate cap and not change detection: the obvious `Changed<GlobalTransform>` gate does not
// work here. A unit's shadow geometry comes from the RIG PALETTE (`append_skinned` reads
// `RigPalettes`, the per-frame skinning pose), not from the part's transform — so a standing NPC
// whose idle animation is breathing has an unchanged `GlobalTransform` and changed VERTICES.
// Gating on the transform would freeze exactly the poses this lane exists to draw. The rate cap
// is the honest saving, and it is bounded work rather than a guess about content.
if !lane
.rate
.due(time.elapsed_secs(), video.character_shadow_rate)
{
return;
// A part that left the admit set (destroyed, out of range, occluder off) loses its caster.
let stale: Vec<Entity> = lane
.casters
.keys()
.copied()
.filter(|part| !wanted.contains(part))
.collect();
for part in stale {
if let Some(caster) = lane.casters.remove(&part) {
commands.entity(caster).despawn();
}
}
if let Some(handle) = lane.mesh.clone() {
if let Some(mesh) = meshes.get_mut(&handle) {
let (mut positions, mut indices) = take_mesh_buffers(mesh);
let mut built_parts = EntityHashSet::default();
let (admitted, rejected) = collect_entity_geometry(
&parts,
&rigs,
&palettes,
true, // want_creatures
false, // want_environment (the world lane's job)
frame.light_position,
frame.entity_reach,
frame.tall_reach,
&mut positions,
&mut indices,
Some(&mut built_parts),
if shadow_trace() {
let now = (admitted, rejected, lane.casters.len() as u32);
if lane.traced != now {
info!(
"shadow-trace: character gpu {} casters | creatures admitted {} / rejected {} (was {:?})",
lane.casters.len(),
admitted,
rejected,
lane.traced
);
ready.0.clear();
for part in built_parts {
ready.0.insert(part);
// The unit root owns its blob; mounts and equipment can be nested below it.
ready.0.extend(parents.iter_ancestors(part));
}
let tris = (indices.len() / 3) as u32;
restore_mesh_buffers(mesh, positions, indices);
if shadow_trace() {
let now = (tris, admitted, rejected);
if lane.traced != now {
info!(
"shadow-trace: character {} tris | creatures admitted {} / rejected {} (was {:?})",
tris, admitted, rejected, lane.traced
);
lane.traced = now;
}
}
lane.traced = now;
}
}
}
+2 -1
View File
@@ -14,6 +14,7 @@ pub(crate) fn plugin(app: &mut App) {
bevy::asset::embedded_asset!(app, "shaders/ui_slice_gamma.wgsl");
bevy::asset::embedded_asset!(app, "shaders/shadow_caster.wgsl");
bevy::asset::embedded_asset!(app, "shaders/shadow_caster_cutout_prepass.wgsl");
bevy::asset::embedded_asset!(app, "shaders/shadow_caster_skinned.wgsl");
// MONKEY (ao): the contact-shadow pass.
bevy::asset::embedded_asset!(app, "shaders/ssao.wgsl");
}
@@ -50,6 +51,6 @@ mod tests {
`shaders::plugin`, or was registered from a file that is not directly under src/"
);
}
assert_eq!(found, 8, "the game's shader set changed size");
assert_eq!(found, 9, "the game's shader set changed size");
}
}
@@ -0,0 +1,84 @@
// GPU-skinned shadow caster: one per animated unit part. It carries the part's own skinned mesh and
// MeshTag and skins in the VERTEX stage from the shared light buffer's rig palette — the same rows
// `wow_model.wgsl` reads — so the character shadow lane spends no per-frame CPU skinning and uploads
// no merged mesh. The forward pass discards it (invisible to the camera); the prepass, which is the
// directional-light shadow pass, writes depth from the skinned position.
//
// The palette region offsets are pushed as shader defs by
// `shadow_core::SkinnedShadowCasterMaterial::specialize` from `benilla_world::rig_palette`, so this
// file needs no copy of the huge `WowLight` layout: it reads the tail as a flat `array<f32>`.
#import bevy_pbr::{
mesh_functions,
mesh_view_bindings::view,
prepass_io::{VertexOutput},
view_transformations::position_world_to_clip,
}
// Bevy's standard vertex locations (position @0) plus the owned-palette joints at 10/11, exactly
// the layout `SkinnedShadowCasterMaterial::specialize` binds.
struct CasterVertex {
@builtin(instance_index) instance_index: u32,
@location(0) position: vec3<f32>,
@location(10) joint_indices: vec4<u32>,
@location(11) joint_weights: vec4<f32>,
}
@group(#{MATERIAL_BIND_GROUP}) @binding(90) var<storage, read> rig_buf: array<f32>;
// Byte offsets of the slot-keyed regions, divided by 4 to index the flat f32 array.
const RIG_TABLE_F: u32 = #{WOW_RIG_TABLE_OFF} / 4u;
const RIG_ORIGIN_F: u32 = #{WOW_RIG_ORIGIN_OFF} / 4u;
const PALETTE_F: u32 = #{WOW_PALETTE_OFF} / 4u;
fn rig_slot(instance_index: u32) -> u32 {
return (mesh_functions::get_tag(instance_index) >> 19u) & 0x7ffu;
}
// Palette row `row` (0..2) of bone `bone`, as the model shader's `palettes[3*bone + row]`.
fn palette_row(bone: u32, row: u32) -> vec4<f32> {
let o = PALETTE_F + 4u * (3u * bone + row);
return vec4<f32>(rig_buf[o], rig_buf[o + 1u], rig_buf[o + 2u], rig_buf[o + 3u]);
}
@vertex
fn vertex(v: CasterVertex) -> VertexOutput {
var out: VertexOutput;
let slot = rig_slot(v.instance_index);
let base = u32(rig_buf[RIG_TABLE_F + slot]);
let w = v.joint_weights;
let b0 = base + v.joint_indices.x;
let b1 = base + v.joint_indices.y;
let b2 = base + v.joint_indices.z;
let b3 = base + v.joint_indices.w;
let r0 = w.x * palette_row(b0, 0u) + w.y * palette_row(b1, 0u)
+ w.z * palette_row(b2, 0u) + w.w * palette_row(b3, 0u);
let r1 = w.x * palette_row(b0, 1u) + w.y * palette_row(b1, 1u)
+ w.z * palette_row(b2, 1u) + w.w * palette_row(b3, 1u);
let r2 = w.x * palette_row(b0, 2u) + w.y * palette_row(b1, 2u)
+ w.z * palette_row(b2, 2u) + w.w * palette_row(b3, 2u);
// r0/r1/r2 are the affine's rows; a WGSL matrix is column-major (as in `wow_skin_model`).
let frame = mat4x4<f32>(
vec4<f32>(r0.x, r1.x, r2.x, 0.0),
vec4<f32>(r0.y, r1.y, r2.y, 0.0),
vec4<f32>(r0.z, r1.z, r2.z, 0.0),
vec4<f32>(r0.w, r1.w, r2.w, 1.0),
);
let origin = vec3<f32>(
rig_buf[RIG_ORIGIN_F + 4u * slot],
rig_buf[RIG_ORIGIN_F + 4u * slot + 1u],
rig_buf[RIG_ORIGIN_F + 4u * slot + 2u],
);
// Camera-relative like `wow_model.wgsl`, so no f32 product mixes the ~9 k yd origin with a
// local position.
let p_cam = (frame * vec4<f32>(v.position, 1.0)).xyz + (origin - view.world_position);
out.world_position = vec4<f32>(p_cam + view.world_position, 1.0);
out.position = position_world_to_clip(out.world_position.xyz);
return out;
}
// Invisible to the camera's forward pass; the shadow map renders through the prepass instead.
@fragment
fn fragment() -> @location(0) vec4<f32> {
discard;
return vec4<f32>(0.0);
}
+96 -4
View File
@@ -32,17 +32,18 @@ use bevy::light::{
CascadeShadowConfigBuilder, DirectionalLight, DirectionalLightShadowMap, NotShadowReceiver,
ShadowFilteringMethod,
};
use bevy::mesh::{Indices, PrimitiveTopology};
use bevy::mesh::{Indices, Mesh, PrimitiveTopology};
use bevy::pbr::{Material, MaterialPipeline, MaterialPlugin, MeshMaterial3d};
use bevy::prelude::*;
use bevy::reflect::TypePath;
use bevy::render::render_resource::{
AsBindGroup, RenderPipelineDescriptor, SpecializedMeshPipelineError,
AsBindGroup, Buffer, RenderPipelineDescriptor, SpecializedMeshPipelineError,
};
use bevy::shader::ShaderRef;
use bevy::shader::{ShaderDefVal, ShaderRef};
use benilla_assets::coords::wow_to_bevy;
use benilla_assets::materials::WowModelMaterial;
use benilla_assets::{ATTRIBUTE_WOW_JOINT_INDEX, ATTRIBUTE_WOW_JOINT_WEIGHT};
use benilla_formats::ModelBlend;
use benilla_world::billboard::BillboardCard;
use benilla_world::interact::PickMesh;
@@ -354,6 +355,72 @@ impl Material for ShadowCasterMaterial {
}
}
/// The per-unit GPU-skinned shadow caster: the character lane's replacement for the CPU-skinned
/// merged proxy. It carries a part's own skinned `Mesh3d` and `MeshTag` and skins in the vertex
/// stage from the shared light buffer's rig palette (the same rows `wow_model.wgsl` reads), so a
/// crowd costs no per-frame CPU skin and no full-mesh re-upload. The forward pass discards it; the
/// prepass (the directional shadow pass) writes depth from the skinned position.
#[derive(Asset, AsBindGroup, TypePath, Debug, Clone)]
pub(crate) struct SkinnedShadowCasterMaterial {
/// The shared light buffer (`lighting::SharedLightBuffer`), whose slot-keyed tail holds
/// `rig_table`, `rig_origin` and `palettes`. Bound at the same binding the model material uses.
#[storage(90, read_only, buffer, visibility(vertex))]
pub light_buf: Buffer,
}
impl Material for SkinnedShadowCasterMaterial {
fn vertex_shader() -> ShaderRef {
"embedded://benilla_app/shaders/shadow_caster_skinned.wgsl".into()
}
fn fragment_shader() -> ShaderRef {
"embedded://benilla_app/shaders/shadow_caster_skinned.wgsl".into()
}
fn prepass_vertex_shader() -> ShaderRef {
"embedded://benilla_app/shaders/shadow_caster_skinned.wgsl".into()
}
fn specialize(
_pipeline: &MaterialPipeline,
descriptor: &mut RenderPipelineDescriptor,
layout: &bevy::mesh::MeshVertexBufferLayoutRef,
_key: bevy::pbr::MaterialPipelineKey<Self>,
) -> Result<(), SpecializedMeshPipelineError> {
// Two-sided, like the solid proxy: a mixed-winding batch's silhouette must have no holes.
descriptor.primitive.cull_mode = None;
// The palette region byte offsets, substituted into the shader's flat `array<f32>` reads.
// (`benilla_world::rig_palette` owns them; the buffer's prefix is irrelevant to the tail.)
for (name, offset) in [
(
"WOW_RIG_TABLE_OFF",
benilla_world::rig_palette::rig_table_region_offset(),
),
(
"WOW_RIG_ORIGIN_OFF",
benilla_world::rig_palette::rig_origin_region_offset(),
),
(
"WOW_PALETTE_OFF",
benilla_world::rig_palette::palette_region_offset(),
),
] {
descriptor
.vertex
.shader_defs
.push(ShaderDefVal::UInt(name.into(), offset as u32));
}
// The owned-palette joint attributes at 10/11, matching `WowModelExt::specialize`.
let attrs = [
Mesh::ATTRIBUTE_POSITION.at_shader_location(0),
ATTRIBUTE_WOW_JOINT_INDEX.at_shader_location(10),
ATTRIBUTE_WOW_JOINT_WEIGHT.at_shader_location(11),
];
descriptor.vertex.buffers = vec![layout.0.get_layout(&attrs)?];
Ok(())
}
}
impl Plugin for ShadowCorePlugin {
fn build(&self, app: &mut App) {
// MONKEY (sun shadow perf): the shipped edge is now 2048, and it is a LIVE cvar
@@ -364,6 +431,7 @@ impl Plugin for ShadowCorePlugin {
size: DEFAULT_SHADOW_MAP_SIZE as usize,
})
.add_plugins(MaterialPlugin::<ShadowCasterMaterial>::default())
.add_plugins(MaterialPlugin::<SkinnedShadowCasterMaterial>::default())
.init_resource::<ShadowRigState>()
.init_resource::<ShadowDemand>()
.init_resource::<ShadowFrame>()
@@ -687,6 +755,30 @@ pub(crate) fn spawn_solid_caster(
.id()
}
/// Spawn a GPU-skinned caster: a part's own skinned mesh and `MeshTag`, drawn through
/// [`SkinnedShadowCasterMaterial`] on the private shadow layer. Its `Transform` is unused (the
/// palette carries the world pose), so it is the identity.
pub(crate) fn spawn_skinned_caster(
commands: &mut Commands,
mesh: Handle<Mesh>,
material: Handle<SkinnedShadowCasterMaterial>,
tag: u32,
) -> Entity {
commands
.spawn((
Mesh3d(mesh),
MeshMaterial3d(material),
bevy::mesh::MeshTag(tag),
Transform::IDENTITY,
Visibility::Visible,
RenderLayers::layer(PLAYER_SHADOW_LAYER),
NoFrustumCulling,
NotShadowReceiver,
ShadowCaster,
))
.id()
}
/// Append a triangle list, dropping WHOLE triangles whose indices exceed the vertex range (filtering
/// single indices out of a `TriangleList` rewires the rest of the submesh into garbage).
pub(crate) fn append_triangles(
@@ -835,7 +927,7 @@ pub(crate) fn collect_entity_geometry(
/// ENVIRONMENT (gameobjects, faded doodads, WMO props) is OPAQUE only — a foliage leaf-card cast
/// solid becomes a box; retained-world cutout is cast leaf-shaped by the world lane's alpha-tested
/// material instead. Additive/transparent geometry never casts a solid shadow.
fn casts_realtime_shadow(
pub(crate) fn casts_realtime_shadow(
kind: ModelKind,
blend: ModelBlend,
want_creatures: bool,
+3 -3
View File
@@ -27,12 +27,12 @@ pub(crate) const MAX_PALETTE_BONES: usize = 131_072;
const BONE_BYTES: u64 = 48;
/// Byte offset of the rig slot table (a base bone index a slot), after the prop-probe region.
pub(crate) fn rig_table_region_offset() -> u64 {
pub fn rig_table_region_offset() -> u64 {
crate::lighting::prop_probe_region_offset() + (7 * crate::lighting::MAX_PROP_PROBES * 16) as u64
}
/// Byte offset of the rig origin table (a `vec4` a slot), after the tint table, as in the shader.
pub(crate) fn rig_origin_region_offset() -> u64 {
pub fn rig_origin_region_offset() -> u64 {
crate::instance_tint::region_offset() + crate::instance_tint::region_bytes()
}
@@ -43,7 +43,7 @@ pub(crate) fn rig_origin_region_bytes() -> u64 {
/// Byte offset of the palette rows, after the slot, tint, origin, mat-anim and straddle-clip
/// tables: last, because `wow_model.wgsl` declares them as the struct's one runtime-sized array.
pub(crate) fn palette_region_offset() -> u64 {
pub fn palette_region_offset() -> u64 {
crate::straddle::region_offset() + crate::straddle::region_bytes()
}