Apply reference real-character-shadows patch (Bevy shadow-map approach)

Character-only realtime shadows via a DirectionalLight + a private-layer
CPU-skinned caster proxy; terrain/models/static_gx receive through Bevy's
shadows::fetch_directional_shadow. Opt-in cvar `worldShadows` (default off);
hides the blob oval when on. Branched from 7f4db0e (clean pre-shadow base).

Conflicts resolved: cvars.rs (our `same()` style + 45-entry session array),
options_tests.rs (our row counts +1 for the World Shadows toggle).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
pkuzic
2026-09-02 23:49:48 +01:00
co-authored by Claude Opus 4.8
parent 5de488534b
commit 2fb942b661
18 changed files with 1021 additions and 52 deletions
+3
View File
@@ -19,3 +19,6 @@ trace-*.json
.DS_Store
*.swp
/.idea/
# MONKEY: per-run client state written by benilla (saved variables, camera) — not source.
/benilla-config/
+17 -2
View File
@@ -677,6 +677,11 @@
.. "There is no exclusive fullscreen mode. WoW itself removed one in 8.0.1, and "
.. "modern Classic offers these same two."
BENILLA_TOOLTIP_WORLD_SHADOWS =
"Renders real dynamic silhouettes for players, NPCs, creatures, and mounts. Terrain, "
.. "buildings, and trees keep their authored shading, matching Wrath's character-only "
.. "shadow tier. This uses additional GPU time and memory."
OPTIONS_PAGE_ROWS = {
Controls = {
"RowStickyTarget", "RowBlockTrades", "RowAutoLoot", "RowInvertMouse",
@@ -689,7 +694,7 @@
},
Graphics = {
"RowUiScale", "RowFarclip", "RowWorldDetail",
"RowDisplayMode", "RowVerticalSync", "RowRenderScale",
"RowDisplayMode", "RowVerticalSync", "RowWorldShadows", "RowRenderScale",
},
Nameplates = {
"RowEnemyPlates", "RowFriendlyPlates",
@@ -2517,11 +2522,21 @@
"OPTION_TOOLTIP_VERTICAL_SYNC")
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowRenderScale" inherits="OptionsSliderRowTemplate">
<Frame name="$parentRowWorldShadows" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowVerticalSync" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowVerticalSync" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "worldShadows", "Real Character Shadows",
"BENILLA_TOOLTIP_WORLD_SHADOWS")
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowRenderScale" inherits="OptionsSliderRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowWorldShadows" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowWorldShadows" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
-- Render Scale (decision 1639) -- benilla's own
-- row, no 1.12 counterpart: the reference renders
+14
View File
@@ -182,6 +182,7 @@ fn sync_shadows(
/// reaches zero, or no receiving surface is in the box (the reference's no-ground gate).
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
fn update_shadows(
video: Res<crate::video::VideoConfig>,
time: Res<Time>,
catalog: Option<Res<AnimData>>,
rig: Option<Res<CameraControl>>,
@@ -219,6 +220,19 @@ fn update_shadows(
// report, answerable from a log instead of a debugger.
mut census_at: Local<f32>,
) {
// `worldShadows 1`: the realtime shadow-map path owns unit shadows — every record hides so a
// unit never wears the oval underneath its cast shadow. The records themselves stay (spawned
// by `sync_shadows` as usual) and `hide` drops each cache key, so flipping the cvar back
// rebuilds every projection on the next frame. With the cvar off — the shipped default —
// this gate is a single false branch and the lane below is the untouched reference path.
if video.world_shadows {
for (_, mut key, mut verts) in &mut shadows {
if key.shown || !verts.0.is_empty() {
hide(&mut key, &mut verts);
}
}
return;
}
let now = time.elapsed_secs();
let census = now >= *census_at;
if census {
+10 -1
View File
@@ -585,6 +585,9 @@ pub(crate) const REGISTERED: &[Registered] = &[
// `gxRestart = 1` does not apply (wgpu swaps the presentation interval live, so the box takes
// effect on click), and `$WOW_NOVSYNC=1` overrides it session-only, below.
same("gxVSync", "1"),
// Benilla's opt-in Wrath-style character shadow-map path. Static world assets keep their
// authored shading; players, NPCs, creatures and mounts cast realtime silhouettes.
same("worldShadows", "0"),
// **Display mode** (decisions 1627, 1650) — 1.12's own `gxWindow`, worn since 1650 as modern
// Classic's two-entry *Display Mode* dropdown rather than 1.12's *Windowed Mode* checkbox: the
// two states 1627 settled on ARE that client's two (its own `Graphics.lua` builds the list from
@@ -1064,6 +1067,7 @@ fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool {
// Vertical Sync — a flag like every other checkbox here. `video::apply_present_mode`
// watches the value and pushes it to the window; nothing else reads it.
"gxvsync" => knobs.video.vsync = v != 0.0,
"worldshadows" => knobs.video.world_shadows = v != 0.0,
// Display mode (1627) — a flag like every other checkbox here, and the reference's own
// polarity: `1` is WINDOWED (the row is "Windowed Mode"). `video::apply_window_mode`
// watches the value and pushes it to the window; nothing else reads it.
@@ -1366,7 +1370,7 @@ fn sync_cvars(
.collect(),
);
let flag = |b: bool| if b { "1" } else { "0" }.to_string();
let session: [(&str, String); 44] = [
let session: [(&str, String); 45] = [
("MasterVolume", sound.master.to_string()),
("SoundVolume", sound.sfx.to_string()),
("MusicVolume", sound.music.to_string()),
@@ -1412,6 +1416,7 @@ fn sync_cvars(
("minimapZoom", minimap.outdoor.to_string()),
("minimapInsideZoom", minimap.inside.to_string()),
("gxVSync", flag(video.vsync)),
("worldShadows", flag(video.world_shadows)),
// The reference's polarity: the CVar is `gxWindow`, so `1` is the WINDOWED state.
(
"gxWindow",
@@ -1821,6 +1826,10 @@ mod tests {
// VSync welds to the video knob, which in turn welds to the window literal's boot
// mode (`video::tests`) — so the registered "1" cannot drift from what we ship.
assert_eq!(d["gxVSync"] != 0.0, VideoConfig::default().vsync);
assert_eq!(
d["worldShadows"] != 0.0,
VideoConfig::default().world_shadows
);
// The pane half-rate (1444) welds to the portrait knob's shipped default.
assert_eq!(d["boothHalfRate"] != 0.0, PaneRate::default().half);
// Render scale (1639) welds to OFF. Not a taste default: the whole tree of visual
+6 -1
View File
@@ -93,6 +93,7 @@ mod pending_item_ops;
mod perf;
mod pipe_warm;
mod player;
mod player_shadow;
mod poi_marker;
mod portrait;
#[cfg(feature = "dev")]
@@ -525,7 +526,10 @@ pub fn run(build: BuildId) -> AppExit {
.add_plugins(CreatureAnimPlugin)
// The unit blob shadow: the dark ground oval under every unit, sized from the playing
// animation's box (the byte-verified law — wow-re unit-blob-shadow RE), on the same
// surface-decal projector as the selection ring.
// surface-decal projector as the selection ring. `worldShadows 0` — the shipped default,
// the faithful 1.12 frame — draws it exactly as the reference does; while `worldShadows 1`
// the realtime shadow-map path owns unit shadows and this lane hides (the gate inside
// `update_shadows`), so a unit never wears the oval underneath its cast shadow.
.add_plugins(BlobShadowPlugin)
// Footprint decals (B212, decision 1006): the prints a walking unit leaves on snow/sand,
// spawn-once projections on the same decal projector, fading off the effect stream.
@@ -612,6 +616,7 @@ pub fn run(build: BuildId) -> AppExit {
// The video knobs the CVar host writes into (today: `gxVSync`). Before CvarPlugin so the
// resource exists when `load_config` applies the saved value at Startup.
.add_plugins(video::VideoPlugin)
.add_plugins(player_shadow::WorldShadowPlugin)
// The realmlist (decision 1667) — the logon address the login screen edits. Same reason as
// VideoPlugin above: it is a CVar knob, so its resource has to exist before `load_config`.
.add_plugins(realmlist::RealmlistPlugin)
+653
View File
@@ -0,0 +1,653 @@
//! Isolated real shadow-map support for the world.
//!
//! The old unit shadow is a projected oval. This path deliberately does not put a directional
//! light on the normal world layer: that would make every existing custom WoW material enter
//! Bevy's shadow-prepass path, which is the source of the pipeline validation/corruption failure
//! seen during the first experiment. Instead, a private layer contains one CPU-built copy of the
//! current world model triangles. Only that copy casts; the normal world shaders only receive the
//! resulting shadow lookup when the option is enabled.
use bevy::asset::RenderAssetUsages;
use bevy::camera::visibility::{NoFrustumCulling, RenderLayers};
use bevy::light::{
CascadeShadowConfigBuilder, DirectionalLight, DirectionalLightShadowMap, NotShadowReceiver,
};
use bevy::mesh::{Indices, PrimitiveTopology};
use bevy::pbr::{Material, MaterialPipeline, MaterialPlugin, MeshMaterial3d};
use bevy::prelude::*;
use bevy::reflect::TypePath;
use bevy::render::render_resource::{
AsBindGroup, RenderPipelineDescriptor, SpecializedMeshPipelineError,
};
use bevy::shader::ShaderRef;
use benilla_assets::coords::wow_to_bevy;
use benilla_assets::materials::WowModelMaterial;
use benilla_formats::ModelBlend;
use benilla_world::billboard::BillboardCard;
use benilla_world::interact::PickMesh;
use benilla_world::lighting::WowLighting;
use benilla_world::model_render::{ModelKind, ModelPart, ShadowOccluder};
use benilla_world::rig_palette::{RigPalettes, RigPart, RigSkin};
use benilla_world::view::WorldCamera;
use crate::char_select::ClientState;
use crate::video::VideoConfig;
/// Layer 31 is deliberately private to this feature. Bevy supports 32 render layers; the normal
/// world is layer 0 and the UI/portrait layers occupy the low numbered slots.
pub(crate) const PLAYER_SHADOW_LAYER: usize = 31;
/// How far from the camera the shadow map RESOLVES (the single cascade's `maximum_distance`).
/// Collection reaches farther than this: a caster's own body may stand outside the cascade
/// while its shadow lands inside it — in light space a caster's footprint sits exactly where
/// its shadow falls, so an out-of-frustum tree still darkens ground the player is looking at.
/// See [`static_collection_reach`] for the collection law.
const CASTER_RANGE: f32 = 80.0;
/// How far the camera may drift before a caster-collection pass must refresh. The collection
/// reach carries this as margin so coverage holds everywhere between refreshes.
const STATIC_REBUILD_STEP: f32 = 16.0;
/// The tallest COMMON caster the reach law budgets for (the big Elwynn/Duskwood tree class,
/// in world units). Not a clamp on what casts — only on how far past the resolve range the
/// collection hunts for shadow SOURCES.
const MAX_CASTER_HEIGHT: f32 = 35.0;
/// Ceiling on the shadow-reach extension: the lighting sun's elevation only spans ~20°–37°
/// over the whole day, but a degenerate near-horizontal direction must not explode the
/// collection radius (and with it the rebuild cost) unbounded.
const SHADOW_REACH_CAP: f32 = 120.0;
/// How far past a caster's own position its shadow can land: `height × horizontal/vertical`
/// of the lighting sun's travel direction. At the night elevation (~20°) a 35-unit tree throws
/// a ~96-unit shadow — collecting only to `CASTER_RANGE + STATIC_REBUILD_STEP` made exactly
/// those trees pop in as the camera drifted a rebuild step, their long shadows sweeping across
/// ground the player was already looking at.
fn shadow_reach_extension(sun_travel: Vec3) -> f32 {
// `sun_travel` is the direction the light TRAVELS (downward): -y is the vertical drop.
let down = -sun_travel.y;
if down <= 1e-3 {
return SHADOW_REACH_CAP;
}
let horizontal = Vec3::new(sun_travel.x, 0.0, sun_travel.z).length();
(MAX_CASTER_HEIGHT * horizontal / down).min(SHADOW_REACH_CAP)
}
/// The one collection law for a lane that can contribute a TALL caster (a doodad/WMO entity
/// exile, were the character-only tier ever to admit one): resolve range + rebuild margin +
/// the sun-dependent shadow reach.
fn static_collection_reach(sun_travel: Vec3) -> f32 {
CASTER_RANGE + STATIC_REBUILD_STEP + shadow_reach_extension(sun_travel)
}
/// `WOW_SHADOW_TRACE=1` — log the caster population whenever it CHANGES, so "the shadow
/// vanished when I turned" can be attributed to a lane: a dynamic-mesh count that dips with
/// camera rotation means the entity lane is still being view-culled somewhere; a static
/// rebuild logged at the same moment means the cached half swapped content; neither changing
/// while the shadow still pops on screen exonerates the caster and indicts the map/receiver.
fn shadow_trace() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| std::env::var("WOW_SHADOW_TRACE").is_ok_and(|v| v != "0"))
}
/// How far the LIVE sun may drift from the direction the shadow light was last given before
/// the light snaps to it — about one game minute of lighting-sun travel (the DayNight phi
/// table sweeps ~0.297 rad over 360 game minutes ≈ 8.25e-4 rad/min).
///
/// The shadow basis is quantised because a light that rotates EVERY frame defeats the cascade
/// fit's texel snap: `calculate_cascade` (bevy_light) snaps the ortho origin to texel
/// multiples in LIGHT space, which pins shadow edges to world positions under camera
/// translation — but rotating the basis re-maps every world position, and at WoW map
/// coordinates (Elwynn sits ~9,300 units from the world origin) even the lighting sun's tiny
/// ~1.4e-5 rad/s drift slides light-space positions ~0.13 units/s ≈ 1.6 shadow texels/s: a
/// continuous sub-texel re-rasterisation crawl on every shadow edge (the inn-roof field
/// report). Held between snaps, the basis is bit-stable and the map only re-rasterises when
/// the sun has honestly moved — a discrete 1–2 texel tick roughly once a game minute, the
/// same physical reasoning that lets MCSH ship as a static bake. LIGHTING stays continuous;
/// only the shadow basis steps.
const SUN_SNAP_RADIANS: f32 = 8.0e-4;
/// Whether the live sun direction has drifted far enough from the held (last-written) one to
/// re-aim the shadow light. `None` = never written (first frame after spawn, or re-enable
/// after teardown): always due.
fn sun_snap_due(held: Option<Vec3>, live: Vec3) -> bool {
match held {
None => true,
Some(held) => held.angle_between(live) > SUN_SNAP_RADIANS,
}
}
#[derive(Component)]
struct WorldShadowCaster;
#[derive(Component)]
struct WorldShadowSun;
#[derive(Component)]
struct WorldShadowCameraLayer {
had_layers: bool,
}
#[derive(Resource, Default)]
struct WorldShadowRuntime {
/// The PER-FRAME caster: entity parts (animated rigs, creatures) collected fresh every
/// frame — the character-only tier's whole caster population.
dynamic_caster: Option<Entity>,
sun: Option<Entity>,
/// The direction the sun entity was LAST GIVEN — the held shadow basis. The live
/// direction is compared against this each frame and written through only past
/// [`SUN_SNAP_RADIANS`] (see there for why); `None` until the first write, and again
/// after teardown, so spawn and re-enable always aim the light immediately.
sun_written: Option<Vec3>,
dynamic_mesh: Option<Handle<Mesh>>,
material: Option<Handle<ShadowCasterMaterial>>,
/// [`shadow_trace`]'s change detector: (dynamic tris, admitted parts, rejected parts).
/// Triangle counts only change when the SET changes (animation moves vertices, not
/// counts), so any change here is a population event worth one log line.
traced: (u32, u32, u32),
}
pub(crate) struct WorldShadowPlugin;
/// Opaque to Bevy's shadow pass, but discarded by the normal forward pass. This lets the
/// private-layer proxy participate in the real shadow map without drawing a second copy of the
/// world in the main camera.
#[derive(Asset, AsBindGroup, TypePath, Debug, Clone, Default)]
struct ShadowCasterMaterial {}
impl Material for ShadowCasterMaterial {
fn fragment_shader() -> ShaderRef {
"embedded://benilla_app/shaders/shadow_caster.wgsl".into()
}
fn specialize(
_pipeline: &MaterialPipeline,
descriptor: &mut RenderPipelineDescriptor,
_layout: &bevy::mesh::MeshVertexBufferLayoutRef,
_key: bevy::pbr::MaterialPipelineKey<Self>,
) -> Result<(), SpecializedMeshPipelineError> {
// WoW's model batches are frequently authored as two-sided geometry. Keeping both faces
// in the caster avoids holes in the projected silhouette when a batch has mixed winding.
descriptor.primitive.cull_mode = None;
Ok(())
}
}
impl Plugin for WorldShadowPlugin {
fn build(&self, app: &mut App) {
app.insert_resource(DirectionalLightShadowMap { size: 2048 })
.add_plugins(MaterialPlugin::<ShadowCasterMaterial>::default())
.init_resource::<WorldShadowRuntime>()
.add_systems(Last, update_world_shadow);
}
}
fn update_world_shadow(
state: Res<State<ClientState>>,
video: Res<VideoConfig>,
lighting: Res<WowLighting>,
mut runtime: ResMut<WorldShadowRuntime>,
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<ShadowCasterMaterial>>,
parts: Query<
(
&PickMesh,
&ModelPart,
Option<&GlobalTransform>,
Option<&RigPart>,
&ShadowOccluder,
Option<&MeshMaterial3d<WowModelMaterial>>,
),
Without<BillboardCard>,
>,
rigs: Query<&RigSkin>,
palettes: Res<RigPalettes>,
mut cameras: Query<
(
Entity,
Option<&mut RenderLayers>,
Option<&WorldShadowCameraLayer>,
),
With<WorldCamera>,
>,
mut suns: Query<&mut Transform, With<WorldShadowSun>>,
cameras_for_position: Query<&GlobalTransform, With<WorldCamera>>,
) {
let wanted = video.world_shadows && *state.get() == ClientState::InWorld;
if !wanted {
if let Some(entity) = runtime.dynamic_caster.take() {
commands.entity(entity).despawn();
}
if let Some(entity) = runtime.sun.take() {
commands.entity(entity).despawn();
}
// Forget the held shadow basis with its light, so a re-enable aims the fresh sun on
// its first frame rather than waiting out a snap threshold.
runtime.sun_written = None;
if let Some(handle) = runtime.dynamic_mesh.take() {
meshes.remove(handle.id());
}
if let Some(handle) = runtime.material.take() {
materials.remove(handle.id());
}
for (entity, layers, marker) in &mut cameras {
let Some(marker) = marker else { continue };
if marker.had_layers {
if let Some(mut layers) = layers {
*layers = layers.clone().without(PLAYER_SHADOW_LAYER);
}
} else {
commands.entity(entity).remove::<RenderLayers>();
}
commands.entity(entity).remove::<WorldShadowCameraLayer>();
}
return;
}
// The proxies are opaque to the shadow pass and their custom forward fragment discards
// every fragment. They have no texture lookup or custom WoW shader, so their shadow
// pipeline cannot perturb the login/character/world material pipelines.
if runtime.dynamic_caster.is_none() {
let dynamic_mesh = meshes.add(empty_shadow_mesh());
let material = materials.add(ShadowCasterMaterial {});
let spawn_caster = |commands: &mut Commands, mesh: &Handle<Mesh>| {
commands
.spawn((
Mesh3d(mesh.clone()),
MeshMaterial3d(material.clone()),
Transform::IDENTITY,
Visibility::Visible,
RenderLayers::layer(PLAYER_SHADOW_LAYER),
NoFrustumCulling,
NotShadowReceiver,
WorldShadowCaster,
))
.id()
};
let dynamic_caster = spawn_caster(&mut commands, &dynamic_mesh);
let sun = commands
.spawn((
DirectionalLight {
// The light exists to populate a shadow map. WoW's existing shader lighting
// remains authoritative, so this light must not add a second diffuse term.
illuminance: 0.0,
shadows_enabled: true,
// Keep enough separation from the receiver to avoid shadow-map acne on WMO
// floors, while staying small enough that the silhouette remains attached.
// The old 0.003/0.15 pair was below the useful texel-scale bias for Stormwind
// and let the floor shadow itself, producing the broken dark patches.
shadow_depth_bias: 0.02,
shadow_normal_bias: 0.8,
..default()
},
CascadeShadowConfigBuilder {
// This is a short-range shadow feature, not a whole-map sun-shadow system.
// Independent cascade grids were showing up as square light patches on the
// cobbles when the custom receiver sampled their transition. With ONE
// cascade, Bevy ignores `first_cascade_far_bound`/`overlap_proportion` and
// the single 2048 map spans the full `maximum_distance`.
num_cascades: 1,
minimum_distance: 0.1,
maximum_distance: CASTER_RANGE,
..default()
}
.build(),
Transform::IDENTITY,
Visibility::Visible,
RenderLayers::layer(PLAYER_SHADOW_LAYER),
WorldShadowSun,
))
.id();
runtime.dynamic_caster = Some(dynamic_caster);
runtime.sun = Some(sun);
runtime.dynamic_mesh = Some(dynamic_mesh);
runtime.material = Some(material);
}
for (entity, layers, marker) in &mut cameras {
if marker.is_some() {
if let Some(mut layers) = layers {
*layers = layers.clone().with(PLAYER_SHADOW_LAYER);
}
} else if let Some(mut layers) = layers {
*layers = layers.clone().with(PLAYER_SHADOW_LAYER);
commands
.entity(entity)
.insert(WorldShadowCameraLayer { had_layers: true });
} else {
commands.entity(entity).insert((
RenderLayers::default().with(PLAYER_SHADOW_LAYER),
WorldShadowCameraLayer { had_layers: false },
));
}
}
let light_position = cameras_for_position
.iter()
.next()
.map(GlobalTransform::translation)
.unwrap_or(Vec3::ZERO);
let sun_direction = if lighting.sun_dir.length_squared() > 1e-6 {
lighting.sun_dir.normalize()
} else {
Vec3::NEG_Z
};
// The two gating radii: short casters (creatures/gameobjects — at most a body length
// tall) stop mattering just past the resolve range; a tall-caster lane (doodads/WMO)
// would carry the sun-dependent shadow reach on top, so a tree whose shadow lands in
// view casts before its trunk enters the resolve range. The character-only tier never
// admits that lane (`casts_realtime_shadow`), but `collect_world_geometry`'s signature
// still takes both reaches.
let entity_reach = CASTER_RANGE + STATIC_REBUILD_STEP;
let tall_reach = static_collection_reach(sun_direction);
// Animated rigs re-skin every frame, and entity doodads, creatures and mounts spawn and
// despawn under entity lifecycles this module cannot cheaply observe — the whole caster
// population is collected fresh every frame.
if let Some(handle) = runtime.dynamic_mesh.clone() {
if let Some(mesh) = meshes.get_mut(&handle) {
let (mut positions, mut indices) = take_mesh_buffers(mesh);
let (admitted, rejected) = collect_world_geometry(
&parts,
&rigs,
&palettes,
light_position,
entity_reach,
tall_reach,
&mut positions,
&mut indices,
);
let dynamic_tris = (indices.len() / 3) as u32;
restore_mesh_buffers(mesh, positions, indices);
if shadow_trace() {
let now = (dynamic_tris, admitted, rejected);
if runtime.traced != now {
info!(
"shadow-trace: dynamic {} tris | parts admitted {} / occluder-rejected {} (was {:?})",
dynamic_tris, admitted, rejected, runtime.traced
);
runtime.traced = now;
}
}
}
}
if let Some(sun) = runtime.sun {
// Only the rotation matters: Bevy fits directional-light cascades to each view's
// frustum and ignores the light's translation entirely. The rotation is QUANTISED —
// written only when the live sun has drifted [`SUN_SNAP_RADIANS`] past the held
// basis — because a per-frame rotation defeats the cascade texel snap and crawls
// every shadow edge (see the constant's doc for the arithmetic). Skipping the write
// also keeps the light's `Transform` change detection quiet between snaps.
if sun_snap_due(runtime.sun_written, sun_direction) {
if let Ok(mut current) = suns.get_mut(sun) {
*current = Transform::IDENTITY.looking_to(sun_direction, Vec3::Y);
runtime.sun_written = Some(sun_direction);
}
}
}
}
/// Append a triangle list, dropping WHOLE triangles whose indices exceed the vertex range.
/// Filtering single indices out of a `TriangleList` shifts every subsequent index by one and
/// rewires the rest of the submesh into garbage triangles.
fn append_triangles(source: &[u32], vertex_count: u32, base: u32, indices: &mut Vec<u32>) {
for triangle in source.chunks_exact(3) {
if triangle.iter().all(|index| *index < vertex_count) {
indices.extend(triangle.iter().map(|index| base + *index));
}
}
}
/// Take a caster mesh's own buffers so a rebuild reuses their allocations rather than paying
/// a fresh `Vec` growth curve every time. The mesh is left attribute-less until
/// [`restore_mesh_buffers`] puts them back.
fn take_mesh_buffers(mesh: &mut Mesh) -> (Vec<[f32; 3]>, Vec<u32>) {
let mut positions = match mesh.remove_attribute(Mesh::ATTRIBUTE_POSITION) {
Some(bevy::mesh::VertexAttributeValues::Float32x3(values)) => values,
_ => Vec::new(),
};
let mut indices = match mesh.remove_indices() {
Some(Indices::U32(values)) => values,
_ => Vec::new(),
};
positions.clear();
indices.clear();
(positions, indices)
}
fn restore_mesh_buffers(mesh: &mut Mesh, positions: Vec<[f32; 3]>, indices: Vec<u32>) {
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
mesh.insert_indices(Indices::U32(indices));
}
fn empty_shadow_mesh() -> Mesh {
let mut mesh = Mesh::new(
PrimitiveTopology::TriangleList,
RenderAssetUsages::default(),
);
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, Vec::<[f32; 3]>::new());
mesh.insert_indices(Indices::U32(Vec::new()));
mesh
}
fn collect_world_geometry(
parts: &Query<
(
&PickMesh,
&ModelPart,
Option<&GlobalTransform>,
Option<&RigPart>,
&ShadowOccluder,
Option<&MeshMaterial3d<WowModelMaterial>>,
),
Without<BillboardCard>,
>,
rigs: &Query<&RigSkin>,
palettes: &RigPalettes,
light_position: Vec3,
creature_reach: f32,
doodad_reach: f32,
positions: &mut Vec<[f32; 3]>,
indices: &mut Vec<u32>,
) -> (u32, u32) {
let (mut admitted, mut rejected) = (0u32, 0u32);
for (pick, part, global, rig_part, occluder, _material) in parts.iter() {
if !casts_realtime_shadow(part.kind, part.blend) {
continue;
}
// NOT `InheritedVisibility`: that folds the exterior window gate, the portal PVS and
// the exterior-scene cull — all camera-orientation verdicts — and a tree behind the
// camera still blocks the sun. `ShadowOccluder` is the content half of the same law
// (toggles, far clip, distance fade, material alpha), written by the one visibility
// authority.
if !occluder.0 {
rejected += 1;
continue;
}
// The distance gate, BEFORE any vertex work: the cascade resolves to CASTER_RANGE, so
// a part beyond its lane's reach contributes nothing — skinning it first would be the
// per-frame cost this gate exists to remove (every resident creature within far clip
// was CPU-skinned per vertex, per frame). Creatures/gameobjects are at most a body
// tall; doodad/WMO exiles can be whole trees and take the tall-caster reach.
let reach = match part.kind {
ModelKind::Creature | ModelKind::GameObject => creature_reach,
ModelKind::Doodad | ModelKind::Wmo => doodad_reach,
};
// A rig part's placement lives in its palette (world-space rows), not necessarily a
// propagated `GlobalTransform`; prefer the transform when present, else the rig's
// slot origin — both are one lookup, never a skin.
let anchor = global.map(GlobalTransform::translation).or_else(|| {
rig_part
.and_then(|rig_part| rigs.get(rig_part.0).ok())
.and_then(|rig| palettes.slot_origin(rig.slot))
});
let Some(anchor) = anchor else { continue };
if anchor.distance_squared(light_position) > reach * reach {
continue;
}
admitted += 1;
let base = positions.len() as u32;
if let Some(rig_part) = rig_part {
append_skinned(pick, rig_part, rigs, palettes, positions);
} else if let Some(global) = global {
positions.extend(
pick.0
.positions
.iter()
.map(|p| global.transform_point(wow_to_bevy(*p)).to_array()),
);
}
let added = positions.len() as u32 - base;
if added != 0 {
append_triangles(&pick.0.indices, added, base, indices);
}
}
(admitted, rejected)
}
/// Wrath's character-only shadow tier. Player bodies, equipment, NPCs, creatures and mounts all
/// use the `Creature` model lane. Static gameobjects, doodads and WMO batches keep their authored
/// world shading and must never enter this camera-fitted realtime map.
fn casts_realtime_shadow(kind: ModelKind, blend: ModelBlend) -> bool {
matches!(kind, ModelKind::Creature)
&& matches!(blend, ModelBlend::Opaque | ModelBlend::AlphaTest)
}
fn append_skinned(
pick: &PickMesh,
rig_part: &RigPart,
rigs: &Query<&RigSkin>,
palettes: &RigPalettes,
positions: &mut Vec<[f32; 3]>,
) {
let Ok(rig) = rigs.get(rig_part.0) else {
return;
};
let Some(palette) = palettes.world_palette(rig.slot, rig.bones() as usize) else {
return;
};
for (index, position) in pick.0.positions.iter().enumerate() {
// A vertex without skinning data still gets an entry: skipping it would shorten
// `positions` against the index buffer and rewire every later triangle in the
// submesh. Seat it on the root matrix so it at least stays near the model.
let (Some(joints), Some(weights)) = (pick.0.joints.get(index), pick.0.weights.get(index))
else {
let fallback = palette
.get(0)
.map(|matrix| matrix.transform_point3(wow_to_bevy(*position)))
.unwrap_or(Vec3::ZERO);
positions.push(fallback.to_array());
continue;
};
let mut skinned = Vec3::ZERO;
for lane in 0..4 {
if weights[lane] > 0.0 {
if let Some(matrix) = palette.get(joints[lane] as usize) {
skinned += matrix.transform_point3(wow_to_bevy(*position)) * weights[lane];
}
}
}
positions.push(skinned.to_array());
}
}
#[cfg(test)]
mod tests {
use super::{
append_triangles, casts_realtime_shadow, shadow_reach_extension, static_collection_reach,
sun_snap_due, CASTER_RANGE, MAX_CASTER_HEIGHT, SHADOW_REACH_CAP, STATIC_REBUILD_STEP,
SUN_SNAP_RADIANS,
};
use benilla_formats::ModelBlend;
use benilla_world::model_render::ModelKind;
use bevy::prelude::Vec3;
#[test]
fn character_only_mode_never_admits_environment_casters() {
assert!(casts_realtime_shadow(
ModelKind::Creature,
ModelBlend::Opaque
));
assert!(casts_realtime_shadow(
ModelKind::Creature,
ModelBlend::AlphaTest
));
assert!(!casts_realtime_shadow(
ModelKind::Creature,
ModelBlend::Blend
));
for kind in [ModelKind::GameObject, ModelKind::Doodad, ModelKind::Wmo] {
assert!(!casts_realtime_shadow(kind, ModelBlend::Opaque));
assert!(!casts_realtime_shadow(kind, ModelBlend::AlphaTest));
}
}
#[test]
fn the_shadow_sun_snaps_on_first_write_holds_under_drift_and_rearms() {
let held = Vec3::new(0.3, -0.8, 0.52).normalize();
// Never written (spawn, or re-enable after teardown): always due.
assert!(sun_snap_due(None, held), "first write always fires");
// Sub-threshold drift holds the basis bit-stable — this is the whole point.
let axis = Vec3::Y;
let small = bevy::prelude::Quat::from_axis_angle(axis, SUN_SNAP_RADIANS * 0.5) * held;
assert!(
!sun_snap_due(Some(held), small),
"sub-threshold drift holds"
);
// Past the threshold the light snaps…
let large = bevy::prelude::Quat::from_axis_angle(axis, SUN_SNAP_RADIANS * 2.0) * held;
assert!(sun_snap_due(Some(held), large), "over-threshold snaps");
// …and the new held value re-arms the window around the NEW direction.
assert!(
!sun_snap_due(Some(large), large),
"identical direction never rewrites"
);
let drift_from_new =
bevy::prelude::Quat::from_axis_angle(axis, SUN_SNAP_RADIANS * 0.5) * large;
assert!(
!sun_snap_due(Some(large), drift_from_new),
"window re-arms around the snapped direction"
);
}
#[test]
fn shadow_reach_follows_the_sun_and_is_capped() {
// 45° sun: horizontal == vertical, a shadow reaches exactly one caster height.
let diagonal = Vec3::new(1.0, -1.0, 0.0).normalize();
assert!((shadow_reach_extension(diagonal) - MAX_CASTER_HEIGHT).abs() < 1e-3);
// The night lighting sun (~20° elevation): ~2.7× height, still under the cap — the
// Goldshire/Duskwood long-shadow case the reach law exists for.
let e = 20.0f32.to_radians();
let night = Vec3::new(e.cos(), -e.sin(), 0.0);
let expected = MAX_CASTER_HEIGHT * e.cos() / e.sin();
assert!((shadow_reach_extension(night) - expected).abs() < 1e-2);
assert!(expected < SHADOW_REACH_CAP);
// Straight down sheds no shadow beyond the caster; a degenerate horizontal
// direction (the NEG_Z fallback for a zeroed sun) takes the cap, never unbounded.
assert_eq!(shadow_reach_extension(Vec3::NEG_Y), 0.0);
assert_eq!(shadow_reach_extension(Vec3::NEG_Z), SHADOW_REACH_CAP);
// The collection law always contains the resolve range plus the rebuild margin.
assert!(static_collection_reach(Vec3::NEG_Y) >= CASTER_RANGE + STATIC_REBUILD_STEP);
}
#[test]
fn an_out_of_range_index_drops_its_whole_triangle_and_no_others() {
let mut out = Vec::new();
// Middle triangle references vertex 9 of 3 — the whole triple must go, and the last
// triangle must keep its own vertices (a single dropped index would shift it).
append_triangles(&[0, 1, 2, 1, 2, 9, 2, 0, 1], 3, 10, &mut out);
assert_eq!(out, vec![10, 11, 12, 12, 10, 11]);
}
#[test]
fn a_trailing_partial_triple_is_ignored() {
let mut out = Vec::new();
append_triangles(&[0, 1, 2, 0, 1], 3, 0, &mut out);
assert_eq!(out, vec![0, 1, 2]);
}
}
+2 -1
View File
@@ -24,6 +24,7 @@ pub(crate) fn plugin(app: &mut App) {
bevy::asset::embedded_asset!(app, "shaders/ui_gamma.wgsl");
bevy::asset::embedded_asset!(app, "shaders/ui_node_gamma.wgsl");
bevy::asset::embedded_asset!(app, "shaders/ui_slice_gamma.wgsl");
bevy::asset::embedded_asset!(app, "shaders/shadow_caster.wgsl");
}
#[cfg(test)]
@@ -59,6 +60,6 @@ mod tests {
`shaders::plugin`, or was registered from a file that is not directly under src/"
);
}
assert_eq!(found, 5, "the game's shader set changed size");
assert_eq!(found, 6, "the game's shader set changed size");
}
}
@@ -0,0 +1,7 @@
// The proxy is deliberately invisible in the camera's forward pass. Its material remains
// opaque, so Bevy still queues it for the depth-only directional-light shadow pass.
@fragment
fn fragment() -> @location(0) vec4<f32> {
discard;
return vec4<f32>(0.0);
}
@@ -2184,6 +2184,7 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
"BENILLA_TOOLTIP_BACKGROUND_SOUND",
"AudioRowBackgroundSound",
),
("BENILLA_TOOLTIP_WORLD_SHADOWS", "GraphicsRowWorldShadows"),
];
if let Some((_, want_row)) = BENILLA_OWNED.iter().find(|(k, _)| *k == key) {
assert_eq!(row, *want_row, "{row}: not this row's string");
@@ -2228,7 +2229,7 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
// 29th is Enable Error Speech (1815), 1.12's own fourth Sound checkbox, ditto; the 30th is
// Enable Sound in Background (1847), the Audio page's fifth checkbox and the one row on that
// page the reference never made settable.
assert_eq!(checked, 58, "every tipped row carries a live key");
assert_eq!(checked, 59, "every tipped row carries a live key");
assert_eq!(
untipped,
vec![
@@ -2338,7 +2339,7 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
// rather than 1.12 GlobalStrings — see the guard above.
// …and Block Trades (1764), the Controls page's 28th CVar row, and Enable Error Speech
// (1815), the Audio page's fourth checkbox and 1.12's own.
assert_eq!(raised, 58, "every row but Auto Loot raises a description");
assert_eq!(raised, 59, "every row but Auto Loot raises a description");
}
/// The **Combat page** (decision 1134) — the first rows in this window whose store is a
+4
View File
@@ -215,6 +215,8 @@ pub(crate) fn boot_windowed_size() -> UVec2 {
#[derive(Resource, Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) struct VideoConfig {
pub(crate) vsync: bool,
/// Whether characters use the isolated real shadow-map path.
pub(crate) world_shadows: bool,
pub(crate) display: DisplayMode,
/// The windowed size, `gxResolution`. Kept while fullscreen so leaving it can restore it.
pub(crate) windowed: UVec2,
@@ -224,6 +226,7 @@ impl Default for VideoConfig {
fn default() -> Self {
Self {
vsync: !novsync_env(),
world_shadows: false,
display: if windowed_env() {
DisplayMode::Windowed
} else {
@@ -534,6 +537,7 @@ mod tests {
let mut app = App::new();
app.insert_resource(VideoConfig {
vsync: true,
world_shadows: false,
display: DisplayMode::Fullscreen,
windowed: UVec2::new(1024, 768),
})
+6
View File
@@ -253,6 +253,12 @@ impl MaterialExtension for WowModelExt {
// at 10/11. Bevy's own SKINNED branch never fires for these meshes — that's the point.
if layout.0.contains(crate::ATTRIBUTE_WOW_JOINT_INDEX) {
descriptor.vertex.shader_defs.push("WOW_RIG_SKIN".into());
// The FRAGMENT stage keys on the same def: a skinned unit takes the whole-unit
// ground-point shadow sample (one fetch at its rig origin) instead of the
// per-fragment fetch, which would read the unit's own caster copy as an occluder.
if let Some(fragment) = descriptor.fragment.as_mut() {
fragment.shader_defs.push("WOW_RIG_SKIN".into());
}
let mut attrs = Vec::with_capacity(7);
for (attr, loc) in [
(Mesh::ATTRIBUTE_POSITION, 0),
+41 -4
View File
@@ -29,7 +29,8 @@
mesh_functions,
forward_io::Vertex,
view_transformations::position_world_to_clip,
mesh_view_bindings::view,
mesh_view_bindings::{lights, view},
shadows,
}
@group(#{MATERIAL_BIND_GROUP}) @binding(100) var layer_array: texture_2d_array<f32>;
@@ -37,6 +38,9 @@
@group(#{MATERIAL_BIND_GROUP}) @binding(105) var splat_samp: sampler;
@group(#{MATERIAL_BIND_GROUP}) @binding(110) var shadow_array: texture_2d_array<f32>;
// A fully covered character shadow retains 45% of the authored terrain colour.
const SHADOW_SUN_FLOOR: f32 = 0.45;
// Per-tile Vec4 uniforms packed into ONE buffer (binding 106) — the field order here MUST match the
// Rust `TerrainExtension` struct. Light + fog live in the shared global-light storage buffer (below);
// what's left is just the per-tile layer tiling factor.
@@ -89,6 +93,9 @@ struct TerrainVsOut {
// hard knee, the director's "hard square". Sun N·L moving per-pixel → per-vertex is the
// faithful direction (Step 3's own note: indistinguishable on the smooth term).
@location(6) primary: vec3<f32>,
// The receiver normal is carried so the real cascaded shadow lookup can apply a slope-aware
// bias. This is forward-pass data only; the terrain shadow caster is a stock Bevy proxy.
@location(7) world_normal: vec3<f32>,
}
// Terrain's point-light **candidacy half-width** (yd): the reference's guaranteed covered box is
@@ -196,6 +203,7 @@ fn vertex(in: Vertex) -> TerrainVsOut {
mesh_functions::mesh_position_local_to_world(world_from_local, vec4<f32>(in.position, 1.0));
out.clip_position = position_world_to_clip(out.world_position.xyz);
let world_normal = mesh_functions::mesh_normal_local_to_world(in.normal, in.instance_index);
out.world_normal = world_normal;
out.uv = in.uv;
out.uv_b = in.uv_b;
out.color = in.color;
@@ -216,8 +224,9 @@ fn vertex(in: Vertex) -> TerrainVsOut {
// byte-verified `1/(0.7d + 0.03d²)`, raw over-gamut colours in.
let ndotl = max(dot(n, l), 0.0);
let points = point_light_sum(out.world_position.xyz, n, mcnk_cell_anchor(out.world_position.xyz));
let sun_lighting = wow_light.light_diffuse.rgb * ndotl;
out.primary = clamp(
wow_light.light_ambient.rgb + wow_light.light_diffuse.rgb * ndotl + points,
wow_light.light_ambient.rgb + sun_lighting + points,
vec3<f32>(0.0),
vec3<f32>(1.0),
);
@@ -281,6 +290,28 @@ fn fragment(in: TerrainVsOut) -> @location(0) vec4<f32> {
// colour, byte-faithful to GL T&L (see the `primary` struct note; the MCNR normal and the
// DayNight sun share a space per the Phase-0 validation). The MCSH ×(0.3·s + 0.7) factor below
// still scales the whole modulate, as the traced combine does.
// Fetch the character coverage here, but apply it as a NEUTRAL scalar after the terrain's
// authored lighting below. Subtracting the warm sun RGB left only Elwynn's green ambient and
// produced a green silhouette on yellow morning ground. The era-style projected shadow is a
// greyscale attenuation: it darkens the existing hue instead of changing it.
// Keep the raw fetch for the specular gate below. The realtime map now contains CHARACTERS
// only; MCSH contains the static world's baked blockers. They are independent occluders, so
// the character map must never replace (and thereby brighten) the authored terrain bake.
var world_shadow = 1.0;
if (lights.n_directional_lights > 0u) {
let view_z = (view.view_from_world * in.world_position).z;
for (var light_id = 0u; light_id < lights.n_directional_lights; light_id = light_id + 1u) {
if ((lights.directional_lights[light_id].flags & 1u) != 0u) {
world_shadow = shadows::fetch_directional_shadow(
light_id,
in.world_position,
normalize(in.world_normal),
view_z,
);
break;
}
}
}
let primary = in.primary;
// STEP 4: MCSH baked shadow. On the reference path (pixelShaders+specular) terrain is ONE pass
@@ -294,14 +325,20 @@ fn fragment(in: TerrainVsOut) -> @location(0) vec4<f32> {
let mcsh = textureSample(shadow_array, splat_samp, auv, i32(round(si))).r;
shadow_lit = 1.0 - mcsh;
}
// MCSH always remains the authored base. Realtime character coverage applies a neutral
// attenuation and closes the sheen gate. This makes the result monotonic and hue-preserving:
// a character can only darken the terrain already underneath it.
let shadow_lit_eff = shadow_lit;
let spec_gate = min(shadow_lit, world_shadow);
let character_shadow_term = mix(SHADOW_SUN_FLOOR, 1.0, world_shadow);
// The faithful `terrainp_s` combine (Q15):
// diffuse = tex · primary · (0.3·shadow + 0.7) → a flat −30% in shadow, NO colour tint
// specular = per-vertex sheen · gloss_mask · shadow → gated to ZERO in shadow (no sheen in shade)
// (`tex·primary` is the MODULATE-1× diffuse; the sheen is added after, separate-specular.) Then
// LDR-clamp; gamma/byte throughout; raw gamma out (GAMMA LANE, 0161).
let diffuse_term = color * primary * (0.3 * shadow_lit + 0.7);
let spec_term = in.specular * specmask * shadow_lit;
let diffuse_term = color * primary * (0.3 * shadow_lit_eff + 0.7) * character_shadow_term;
let spec_term = in.specular * specmask * spec_gate;
var tuned = clamp(diffuse_term + spec_term, vec3<f32>(0.0), vec3<f32>(1.0));
// STEP 5: gamma-space linear fog (q6). Applied AFTER tone/curve (so the diagnostic knobs see
@@ -47,7 +47,8 @@
pbr_functions::alpha_discard,
pbr_bindings,
forward_io::VertexOutput,
mesh_view_bindings::view,
mesh_view_bindings::{lights, view},
shadows,
mesh_functions,
}
@@ -111,6 +112,9 @@ struct ModelParams {
};
@group(#{MATERIAL_BIND_GROUP}) @binding(100) var<uniform> m: ModelParams;
// A fully covered character shadow retains 45% of the authored model lighting.
const SHADOW_SUN_FLOOR: f32 = 0.45;
// The shared global light (lighting::global_light): ONE storage buffer every material reads, updated
// once/frame in place — replaces the per-material light/fog uniforms the old apply_wow_lighting re-pushed
// each frame. The model reads rows 0-2 (ambient/diffuse/sun) + fog, plus rows 6-12: the disassembled
@@ -972,6 +976,63 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
// through `point_diffuse` below like any exterior entity.
let is_rig = m.sun_scale.x >= 1.5;
let lit = select(select(lit_exterior, lit_interior, is_interior), lit_m2_interior, is_rig);
var player_shadow = 1.0;
if (lights.n_directional_lights > 0u) {
let view_z = (view.view_from_world * in.world_position).z;
for (var light_id = 0u; light_id < lights.n_directional_lights; light_id = light_id + 1u) {
if ((lights.directional_lights[light_id].flags & 1u) != 0u) {
#ifdef WOW_RIG_SKIN
// A skinned UNIT samples the map ONCE at its rig origin (between the feet),
// nudged 2.5 units TOWARD the sun, and the whole unit dims uniformly — the
// reference's own per-unit response. Per-fragment sampling is wrong here: the
// caster proxy holds a CPU-skinned copy of this very body, so a body fragment
// reads its own limbs/weapon as occluders (self-shadow speckle no bias fixes on
// animated silhouettes). Points along the sun ray share a shadow-map texel, so
// the nudge only excludes occluders within ~2.5 units of the feet ALONG the ray
// (a fence hugging the unit) — a building or canopy still lands. Gated on the
// directional-enable flag so an interior-lit unit never takes an exterior dim.
if (wow_light.light_sun.w > 0.5) {
let sun_ray = normalize(wow_light.light_sun.xyz);
let anchor = wow_light.rig_origin[(fade_tag >> 19u) & 0x7ffu].xyz
- 2.5 * sun_ray;
player_shadow = shadows::fetch_directional_shadow(
light_id,
vec4<f32>(anchor, 1.0),
vec3<f32>(0.0, 1.0, 0.0),
view_z,
);
}
#else
player_shadow = shadows::fetch_directional_shadow(
light_id,
in.world_position,
wow_normalize(in.world_normal),
view_z,
);
#endif
break;
}
}
}
// The realtime map blocks only the directional sun. Preserve the authored ambient/probe
// contribution instead of multiplying the whole lighting result; the latter makes interiors,
// point-lit props, and shadow-side characters globally too dark.
//
// `is_rig` (the glue create-booth lane) is excluded DELIBERATELY: a booth scene's light is
// entirely its authored rig — there is no world sun to block. World units are `ShadeSel::Lit`
// and receive through the WOW_RIG_SKIN ground sample above. The `player_shadow < 0.999`
// arm keeps `worldShadows 0` (and a fully sunlit fragment) byte-identical: `ambient +
// (lit − ambient) × 1.0` is not guaranteed to round back to `lit`.
let shadow_term = mix(SHADOW_SUN_FLOOR, 1.0, player_shadow);
let lit_with_shadow = select(
lit,
clamp(
wow_light.light_ambient.rgb + (lit - wow_light.light_ambient.rgb) * shadow_term,
vec3<f32>(0.0),
vec3<f32>(1.0),
),
!is_interior && !is_rig && player_shadow < 0.999,
);
// Gamma-space albedo — the lane (0161): the buffer holds bytes, lighting math runs on the
// authored values. (The old fog_params.z linear-space A/B is dead — settled by the lane.)
// `m.tint` is the animated M2Color RGB (identity 1 for static batches) — the same per-batch
@@ -1028,7 +1089,7 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
// MOCV multiplies the lit terms (GL_COLOR_MATERIAL) but NOT the emission terms — SIDN and
// the highlight add alongside, exactly the FFP's material-emission placement.
let primary = clamp(
vc * (lit + point_diffuse) + sidn_e + vec3<f32>(highlight),
vc * (lit_with_shadow + point_diffuse) + sidn_e + vec3<f32>(highlight),
vec3<f32>(0.0),
vec3<f32>(1.0),
);
@@ -1058,7 +1119,7 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
// writes a colour. (Left off the `is_wmo` branch on purpose: a WMO surface is not a CM2
// instance and has no tint slot of its own.)
let primary = clamp(
inst_tint * (lit + point_diffuse) + sidn_e + vec3<f32>(highlight),
inst_tint * (lit_with_shadow + point_diffuse) + sidn_e + vec3<f32>(highlight),
vec3<f32>(0.0),
vec3<f32>(1.0),
);
@@ -1068,7 +1129,7 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
// (`sidn_e` is zero for every M2 batch; a WMO batch without MOCV lands here too and keeps it —
// harmlessly, since a WMO instance's tint slot is the identity slot 0.)
let primary = clamp(
inst_tint * (lit + point_diffuse) + sidn_e + vec3<f32>(highlight),
inst_tint * (lit_with_shadow + point_diffuse) + sidn_e + vec3<f32>(highlight),
vec3<f32>(0.0),
vec3<f32>(1.0),
);
+18
View File
@@ -1176,11 +1176,29 @@ pub enum ModelKind {
/// Tags every spawned model submesh with the metadata the panel toggles on: its subsystem and its
/// blend mode (the "layer" — opaque trunk vs alpha-cut canopy).
#[derive(Component, Clone, Copy)]
#[require(ShadowOccluder)]
pub struct ModelPart {
pub kind: ModelKind,
pub blend: ModelBlend,
}
/// The camera-INDEPENDENT half of [`visibility::apply_model_visibility`]'s verdict: does this
/// submesh exist as world content this frame (toggles, far clip, distance fade, material alpha)?
///
/// `Visibility` cannot answer that for a shadow caster: it also folds the exterior window gate,
/// the portal PVS and the exterior-scene cull — all functions of where the camera LOOKS — and a
/// wall behind the camera still blocks the sun. Gating the caster proxy on `InheritedVisibility`
/// made every entity-lane shadow swing with view direction. Defaults to occluding so a part casts
/// correctly from its spawn frame; the walk corrects a toggled-off part one frame later.
#[derive(Component, Clone, Copy)]
pub struct ShadowOccluder(pub bool);
impl Default for ShadowOccluder {
fn default() -> Self {
Self(true)
}
}
/// Ordering handle so the one system allowed to *override* the model-`Visibility` authority — the
/// self-avatar first-person hide ([`crate::player`]) — can run **after** it and win the frame.
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
@@ -59,6 +59,7 @@ pub(super) fn apply_model_visibility(
&ModelPart,
&GlobalTransform,
&mut Visibility,
&mut super::ShadowOccluder,
Option<&DoodadFade>,
Option<&mut MeshTag>,
Option<&mut MeshMaterial3d<WowModelMaterial>>,
@@ -106,6 +107,7 @@ pub(super) fn apply_model_visibility(
part,
xf,
mut vis,
mut occluder,
fade,
tag,
mat,
@@ -241,6 +243,15 @@ pub(super) fn apply_model_visibility(
*vis = desired;
}
// The shadow-caster verdict: the CONTENT terms only. `portal_visible`, `exterior_ok`
// and `owner_hidden` are all functions of where the camera looks, and geometry the
// camera cannot see still blocks the sun — folding them in made entity-lane shadows
// swing with view direction. Change-gated like every write in this walk.
let occludes = toggled_on && in_range && fade_alpha > 0.0 && mat_factor > 0.0;
if occluder.0 != occludes {
occluder.0 = occludes;
}
// Push the fade alpha to the shader (per-instance `MeshTag` alpha field — `wow_model.wgsl`
// multiplies the cutout alpha by it) and swap to the blend material variant while feathering,
// back to the cutout once opaque. Write only on change so steady doodads (`fade == 1.0`,
@@ -27,9 +27,14 @@
// `Matte`). Output alpha is pinned 1.0 — every draw here is opaque-intent by admission
// (wow_model.wgsl's bit-3 armor, as a constant).
#import bevy_render::view::View
#import bevy_pbr::{
mesh_view_bindings::{lights, view},
shadows,
}
@group(0) @binding(0) var<uniform> view: View;
// Group 0 is Bevy's standard mesh-view bind group (view matrices, directional-light records and
// the shadow textures the retained pass reads).
const SHADOW_SUN_FLOOR: f32 = 0.45;
// The shared global light's per-frame PREFIX (lighting::global_light; the full layout lives in
// wow_model.wgsl — keep field order in sync with BOTH).
@@ -59,7 +64,7 @@ struct WowLight {
// rig/tint/mat-anim regions beyond it stay unmirrored (statics never read them).
prop_probes: array<vec4<f32>, 57344>,
}
@group(0) @binding(1) var<storage, read> wow_light: WowLight;
@group(2) @binding(0) var<storage, read> wow_light: WowLight;
// Per-cell state (static_gx/render.rs `cell_layout`).
struct GxCell {
@@ -281,6 +286,39 @@ fn fragment(in: GxVsOut) -> @location(0) vec4<f32> {
vec3<f32>(0.0),
vec3<f32>(1.0),
);
// The retained path uses the same Bevy directional shadow map as terrain and entity models.
// `static_gx` used to have no mesh-view shadow bindings, which made every Stormwind WMO act
// as if shadows were disabled even though the caster map was populated.
var world_shadow = 1.0;
if (lights.n_directional_lights > 0u && (in.word & WORD_INTERIOR) == 0u) {
let view_z = (view.view_from_world * in.world_position).z;
for (var light_id = 0u; light_id < lights.n_directional_lights; light_id = light_id + 1u) {
if ((lights.directional_lights[light_id].flags & 1u) != 0u) {
world_shadow = shadows::fetch_directional_shadow(
light_id,
in.world_position,
n_lit,
view_z,
);
break;
}
}
}
let shadow_term = mix(SHADOW_SUN_FLOOR, 1.0, world_shadow);
// Keep ambient energy when the realtime map blocks the sun. The retained pass also carries
// authored fixture/probe lighting, which must not be darkened by a directional shadow. The
// `world_shadow < 0.999` arm keeps `worldShadows 0` (no shadow-mapped light) and a fully
// sunlit fragment byte-identical: `ambient + (lit − ambient) × 1.0` is not guaranteed to
// round back to `lit`.
let lit_nl_shadowed = select(
lit_nl,
clamp(
wow_light.light_ambient.rgb + (lit_nl - wow_light.light_ambient.rgb) * shadow_term,
vec3<f32>(0.0),
vec3<f32>(1.0),
),
world_shadow < 0.999,
);
// The order-2 SH basis products over the fragment normal — shared by the exterior
// doodad lobe and the interior-prop probe lane (wow_model.wgsl computes them once too).
let quad = vec4<f32>(n_lit.x * n_lit.y, n_lit.y * n_lit.z, n_lit.z * n_lit.z, n_lit.x * n_lit.z);
@@ -307,12 +345,14 @@ fn fragment(in: GxVsOut) -> @location(0) vec4<f32> {
// WINDOW (MOMT 0x20) — interior drawer only: GL_LIGHT0 swapped to the brighter
// Direct/Ambient midpoint pair, ambient +16/255 saturating (0x6d37e0).
let window_mid = 0.5 * (wow_light.light_ambient.rgb + wow_light.light_diffuse.rgb);
let lit_window = clamp(
window_mid + vec3<f32>(16.0 / 255.0) + window_mid * ndotl,
// The window's directional half rides the sun, so it takes the shadow term (×1.0 exact
// when the caster is off — no identity guard needed on a pure multiply).
let lit_window_shadowed = clamp(
window_mid + vec3<f32>(16.0 / 255.0) + window_mid * ndotl * shadow_term,
vec3<f32>(0.0),
vec3<f32>(1.0),
);
let lit_int_base = select(lit_nl, lit_window, (in.word & WORD_WINDOW) != 0u);
let lit_int_base = select(lit_nl_shadowed, lit_window_shadowed, (in.word & WORD_WINDOW) != 0u);
// The interior BATCH-CLASS lanes (trace-forensics-abbey-interior-d3d §2): INT = unlit
// (the bake IS the room's light), TRANS = the per-vertex MOCV-alpha lit↔bake lerp,
// EXT = plain lit_nl. Exterior groups take lit_nl at sun-scale 1 (prog 198/VS 151 —
@@ -323,7 +363,11 @@ fn fragment(in: GxVsOut) -> @location(0) vec4<f32> {
} else if (!class_int) {
lit_wmo_interior = lit_int_base;
}
let lit_wmo = select(lit_nl, lit_wmo_interior, interior);
// EXTERIOR surfaces are the ones the realtime map can shadow (the fetch above is
// gated on !interior, so `lit_nl_shadowed` collapses to `lit_nl` for interior
// fragments anyway). Taking plain `lit_nl` here left every Stormwind street and
// wall unshadowed while the caster map was fully populated.
let lit_wmo = select(lit_nl_shadowed, lit_wmo_interior, interior);
// SIDN night glow (MOMT 0x10): the authored emissive × the live night fraction, an
// EMISSION term — inside the clamped sum, never MOCV-multiplied; dead on the unlit
// INT lane, TRANS-weighted by the lit-pass alpha (wmo-interior-night-light §4).
@@ -421,9 +465,26 @@ fn fragment(in: GxVsOut) -> @location(0) vec4<f32> {
* (dot(wow_light.sh_c10_b.xyz, n_lit) + dot(wow_light.sh_c13_b, quad)
+ wow_light.sh_c16.z * x2y2),
);
let lit_doodad = clamp(sun_lobe, vec3<f32>(0.0), vec3<f32>(1.0));
// The SH lobe's own ambient rows (the .w column) are the doodad lane's ambient share:
// subtract-and-restore around the shadow term so only the directional part of the lobe
// dims. Guarded like `lit_nl_shadowed` so the no-shadow state stays byte-identical.
let sun_ambient = vec3<f32>(
wow_light.sh_c10_r.w,
wow_light.sh_c10_g.w,
wow_light.sh_c10_b.w,
);
let lit_doodad_plain = clamp(sun_lobe, vec3<f32>(0.0), vec3<f32>(1.0));
let lit_doodad = select(
lit_doodad_plain,
clamp(
sun_ambient + (sun_lobe - sun_ambient) * shadow_term,
vec3<f32>(0.0),
vec3<f32>(1.0),
),
world_shadow < 0.999,
);
// Sun disabled (light_sun.w) falls back to the FFP matte, like the entity path.
let lit = select(lit_nl, lit_doodad, wow_light.light_sun.w > 0.5);
let lit = select(lit_nl_shadowed, lit_doodad, wow_light.light_sun.w > 0.5);
// FFP combine: the light sum saturates FIRST, the texture (× the baked constant
// tint, when authored) modulates the clamped result. Statics: inst_tint identity,
// no highlight, no SIDN.
+6 -3
View File
@@ -294,8 +294,11 @@ struct GxItem {
owner: (i32, i32),
texture: Option<AssetId<bevy::image::Image>>,
/// Kept as a live handle so the render world's `GpuImage` can never be dropped from under
/// the baked cell (the id alone holds nothing).
_texture_handle: Option<Handle<bevy::image::Image>>,
/// the baked cell (the id alone holds nothing). Never READ (the bake keys everything off
/// [`Self::texture`]'s id) — its whole job is the strong reference held by its own
/// existence, so `dead_code` is a false positive here.
#[allow(dead_code)]
texture_handle: Option<Handle<bevy::image::Image>>,
cutout: bool,
two_sided: bool,
unlit: bool,
@@ -770,7 +773,7 @@ impl StaticGx {
local_aabb: b.aabb,
owner: b.owner,
texture: b.texture.as_ref().map(Handle::id),
_texture_handle: b.texture,
texture_handle: b.texture,
cutout: b.blend == ModelBlend::AlphaTest && !crate::model_render::alphatest_disabled(),
two_sided: b.two_sided,
unlit: b.unlit,
+85 -25
View File
@@ -16,9 +16,15 @@
//! bind groups, never a texture.
use bevy::camera::primitives::Aabb;
use bevy::core_pipeline::oit::OrderIndependentTransparencySettingsOffset;
use bevy::ecs::query::QueryItem;
use bevy::image::Image;
use bevy::mesh::VertexBufferLayout;
use bevy::pbr::{
MeshPipeline, MeshPipelineViewLayoutKey, MeshViewBindGroup, ViewEnvironmentMapUniformOffset,
ViewFogUniformOffset, ViewLightProbesUniformOffset, ViewLightsUniformOffset,
ViewScreenSpaceReflectionsUniformOffset,
};
use bevy::platform::collections::HashMap;
use bevy::prelude::*;
use bevy::render::extract_component::{ExtractComponent, ExtractComponentPlugin};
@@ -30,14 +36,12 @@ use bevy::render::render_graph::{
NodeRunError, RenderGraphContext, RenderGraphExt, RenderLabel, ViewNode, ViewNodeRunner,
};
use bevy::render::render_resource::binding_types::{
sampler, storage_buffer_read_only_sized, texture_2d_array, uniform_buffer, uniform_buffer_sized,
sampler, storage_buffer_read_only_sized, texture_2d_array, uniform_buffer_sized,
};
use bevy::render::render_resource::*;
use bevy::render::renderer::{RenderContext, RenderDevice, RenderQueue};
use bevy::render::texture::GpuImage;
use bevy::render::view::{
ExtractedView, Msaa, ViewDepthTexture, ViewTarget, ViewUniform, ViewUniformOffset, ViewUniforms,
};
use bevy::render::view::{ExtractedView, Msaa, ViewDepthTexture, ViewTarget, ViewUniformOffset};
use bevy::render::{Render, RenderSystems};
use bevy::shader::ShaderDefVal;
use std::ops::Range;
@@ -216,6 +220,7 @@ struct GxGpuCache {
struct GxPipelines {
view_layout: BindGroupLayoutDescriptor,
cell_layout: BindGroupLayoutDescriptor,
light_layout: BindGroupLayoutDescriptor,
sampler: Sampler,
sampler_clamp: Sampler,
/// Keyed `(cutout, two_sided)`; specialized for the world view's (samples, format) pair —
@@ -224,15 +229,25 @@ struct GxPipelines {
specialized_for: Option<(u32, TextureFormat)>,
}
fn init_pipelines(mut commands: Commands, render_device: Res<RenderDevice>) {
let view_layout = BindGroupLayoutDescriptor::new(
"static_gx_view_layout",
fn init_pipelines(
mut commands: Commands,
mesh_pipeline: Res<MeshPipeline>,
render_device: Res<RenderDevice>,
) {
// Use Bevy's real mesh-view layout for the retained pass. Besides the view and light records,
// this carries the directional shadow textures and comparison samplers produced for the
// world camera. The old two-binding layout could render the city, but it had no legal way for
// `static_gx.wgsl` to receive the shadow map.
let view_layout = mesh_pipeline
.get_view_layout(MeshPipelineViewLayoutKey::empty())
.main_layout
.clone();
// The pass's own extra (group 2): the shared light storage.
let light_layout = BindGroupLayoutDescriptor::new(
"static_gx_light_layout",
&BindGroupLayoutEntries::sequential(
ShaderStages::VERTEX_FRAGMENT,
(
uniform_buffer::<ViewUniform>(true),
storage_buffer_read_only_sized(false, None),
),
(storage_buffer_read_only_sized(false, None),),
),
);
let cell_layout = BindGroupLayoutDescriptor::new(
@@ -276,6 +291,7 @@ fn init_pipelines(mut commands: Commands, render_device: Res<RenderDevice>) {
commands.insert_resource(GxPipelines {
view_layout,
cell_layout,
light_layout,
sampler: make("static_gx_repeat", AddressMode::Repeat),
sampler_clamp: make("static_gx_clamp", AddressMode::ClampToEdge),
pipelines: HashMap::default(),
@@ -347,6 +363,7 @@ fn prepare_static_gx(
render_device: Res<RenderDevice>,
render_queue: Res<RenderQueue>,
asset_server: Res<AssetServer>,
mesh_pipeline: Res<MeshPipeline>,
images: Res<RenderAssets<GpuImage>>,
views: Query<GxViewKey>,
) {
@@ -362,12 +379,20 @@ fn prepare_static_gx(
};
let key = (msaa.samples(), format);
if pipes.specialized_for != Some(key) {
pipes.view_layout = mesh_pipeline
.get_view_layout(MeshPipelineViewLayoutKey::from(*msaa))
.main_layout
.clone();
let shader: Handle<Shader> =
asset_server.load("embedded://benilla_world/shaders/static_gx.wgsl");
pipes.pipelines.clear();
for cutout in [false, true] {
for two_sided in [false, true] {
let mut defs = vec![];
// The world camera uses Bevy's default Gaussian shadow filtering. The retained
// pipeline is custom, so it must opt into the same shader branch explicitly;
// otherwise `shadows::fetch_directional_shadow` falls back to a constant 1.0.
defs.push(ShaderDefVal::from("SHADOW_FILTER_METHOD_GAUSSIAN"));
if cutout {
defs.push(ShaderDefVal::from("GX_CUTOUT"));
}
@@ -375,7 +400,11 @@ fn prepare_static_gx(
label: Some(
format!("static_gx c{} t{}", u8::from(cutout), u8::from(two_sided)).into(),
),
layout: vec![pipes.view_layout.clone(), pipes.cell_layout.clone()],
layout: vec![
pipes.view_layout.clone(),
pipes.cell_layout.clone(),
pipes.light_layout.clone(),
],
vertex: VertexState {
shader: shader.clone(),
shader_defs: defs.clone(),
@@ -705,28 +734,28 @@ fn kill_bit(killed: &[u64], i: usize) -> u32 {
)
}
/// The per-frame view bind group (group 0): bevy's view uniform + the shared light buffer —
/// the SAME `wow_shared_light` storage every material binds (1429: identical lighting by
/// construction).
/// The retained pass's extra bind group (group 2): the shared light buffer — the SAME
/// `wow_shared_light` storage every material binds (1429: identical lighting by construction) —
/// Group 0 is Bevy's standard mesh-view bind group, which supplies the view matrices,
/// directional-light records, and shadow textures.
#[derive(Resource)]
struct GxViewBind(BindGroup);
struct GxLightBind(BindGroup);
fn prepare_view_bind(
mut commands: Commands,
pipes: Res<GxPipelines>,
pipeline_cache: Res<PipelineCache>,
render_device: Res<RenderDevice>,
view_uniforms: Res<ViewUniforms>,
light: Option<Res<crate::lighting::SharedLightBuffer>>,
) {
let (Some(view_binding), Some(light)) = (view_uniforms.uniforms.binding(), light) else {
let Some(light) = light else {
return;
};
let layout = pipeline_cache.get_bind_group_layout(&pipes.view_layout);
commands.insert_resource(GxViewBind(render_device.create_bind_group(
"static_gx_view",
let layout = pipeline_cache.get_bind_group_layout(&pipes.light_layout);
commands.insert_resource(GxLightBind(render_device.create_bind_group(
"static_gx_light",
&layout,
&BindGroupEntries::sequential((view_binding, light.0.as_entire_binding())),
&BindGroupEntries::sequential((light.0.as_entire_binding(),)),
)));
}
@@ -741,6 +770,13 @@ impl ViewNode for StaticGxNode {
&'static ViewTarget,
&'static ViewDepthTexture,
&'static ViewUniformOffset,
&'static ViewLightsUniformOffset,
&'static ViewFogUniformOffset,
&'static ViewLightProbesUniformOffset,
&'static ViewScreenSpaceReflectionsUniformOffset,
&'static ViewEnvironmentMapUniformOffset,
Option<&'static OrderIndependentTransparencySettingsOffset>,
&'static MeshViewBindGroup,
// The world camera only — a booth bake must never receive world cells (see the marker).
&'static StaticGxView,
);
@@ -749,7 +785,19 @@ impl ViewNode for StaticGxNode {
&self,
_graph: &mut RenderGraphContext,
render_context: &mut RenderContext<'w>,
(target, depth, view_offset, _marker): QueryItem<'w, '_, Self::ViewQuery>,
(
target,
depth,
view_offset,
view_lights,
view_fog,
view_light_probes,
view_ssr,
view_environment_map,
maybe_oit,
view_bind,
_marker,
): QueryItem<'w, '_, Self::ViewQuery>,
world: &'w World,
) -> Result<(), NodeRunError> {
let _t = super::gx_perf_guard(4);
@@ -760,7 +808,7 @@ impl ViewNode for StaticGxNode {
let cache = world.resource::<GxGpuCache>();
let pipes = world.resource::<GxPipelines>();
let pipeline_cache = world.resource::<PipelineCache>();
let Some(view_bind) = world.get_resource::<GxViewBind>() else {
let Some(light_bind) = world.get_resource::<GxLightBind>() else {
return Ok(());
};
let meshes = world.resource::<RenderAssets<RenderMesh>>();
@@ -818,7 +866,18 @@ impl ViewNode for StaticGxNode {
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_bind_group(0, &view_bind.0, &[view_offset.offset]);
let mut view_offsets = vec![
view_offset.offset,
view_lights.offset,
view_fog.offset,
**view_light_probes,
**view_ssr,
**view_environment_map,
];
if let Some(oit) = maybe_oit {
view_offsets.push(oit.offset);
}
pass.set_bind_group(0, &view_bind.main, &view_offsets);
for (gpu, draw, sel) in &resolved {
let Some(mesh) = meshes.get(draw.mesh.id()) else {
continue;
@@ -847,6 +906,7 @@ impl ViewNode for StaticGxNode {
}
pass.set_render_pipeline(ready[&(run.cutout, run.two_sided)]);
pass.set_bind_group(1, &gpu.bind_groups[run.slot].1, &[]);
pass.set_bind_group(2, &light_bind.0, &[]);
pass.draw_indexed(
(islice.range.start + run.index_range.start)
..(islice.range.start + run.index_range.end),