MONKEY: dynamic WMO interiors + cube-map torch shadows (surfaces + entities)

Interior rooms light per fragment from their authored fixtures instead of
the MOCV bake (direct + fill + ambient, soft rolloff; live cvars
interiorLight/interiorAmbient/interiorFill/interiorExposure/interiorShadows/
interiorDebug), TRANS/EXT batches blend to the day/night reference at the
portal seam, and fixtures admit camera-independently (90 yd).

Torch shadows are a from-scratch depth-map renderer (Bevy point shadows are
dead under ClusterConfig::None): a shared Depth32Float 512^2 x 24-layer Image
+ depth-only pipeline + ViewNode rendering the static caster and a per-frame
entity caster into 6 cube faces for the nearest <=4 fixtures (reverse-Z,
GreaterEqual compare, 4-tap PCF, far fade). WMO surfaces receive through
static_gx group 3; entities receive on the wow_model path through
WowModelExt bindings 91/92/93 (depth array, comparison sampler, 1616-byte
torch table) with a 0.15 yd normal offset against CPU-vs-GPU skinning
self-shadow.

The entity lane is gated on is_interior OR the new MATTE_INDOOR tag bit
(exterior payload bit 14, written by the classifier's Matte law), so a unit
whose verdict flickers Bake<->Matte at one spot keeps its room lighting.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
pkuzic
2026-09-07 20:12:47 +01:00
co-authored by Claude Fable 5.1
parent c4e7660003
commit e254e6dce8
28 changed files with 1774 additions and 133 deletions
+50 -1
View File
@@ -600,6 +600,41 @@ pub(crate) const REGISTERED: &[Registered] = &[
"1900: benilla's own realtime-shadow render-distance slider; the reference bakes MCSH and \
has no cascade to size",
),
// MONKEY (dynamic interiors): WMO interiors + their props light from the room's LIVE fixtures
// instead of the MOCV bake / the baked prop probe (`static_gx.wgsl` `interior_room_light`;
// bridged by `dynamic_interior`). The three numeric knobs are live-tunable from chat —
// `/script SetCVar("interiorExposure", 2)` — which is how their defaults were found.
ours(
"interiorLight",
"1",
"benilla's own: fixture-lit WMO interiors (0 = the reference's baked interior path)",
),
ours(
"interiorAmbient",
"0.15",
"benilla's own: interior base ambient, 0..1",
),
ours(
"interiorFill",
"0.12",
"benilla's own: interior per-fixture bounce gain, 0..2",
),
ours(
"interiorExposure",
"2.5",
"benilla's own: interior light-budget multiplier before the soft rolloff, 0.25..8",
),
ours(
"interiorShadows",
"1",
"benilla's own: interior fixtures cast real shadows (Stage B, the nearest few); needs \
interiorLight",
),
ours(
"interiorDebug",
"0",
"benilla's own: interior diagnostic overlay — 1 classification, 2 shadow, 3 caster count",
),
// **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
@@ -1085,6 +1120,14 @@ fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool {
knobs.video.shadow_distance =
v.clamp(*SHADOW_DISTANCE_RANGE.start(), *SHADOW_DISTANCE_RANGE.end());
}
// MONKEY (dynamic interiors): the interior lane's on/off + knobs, clamped at the edge like
// every other numeric row. `dynamic_interior::bridge` publishes them to benilla-world.
"interiorlight" => knobs.video.interior_light = v != 0.0,
"interiorambient" => knobs.video.interior_ambient = v.clamp(0.0, 1.0),
"interiorfill" => knobs.video.interior_fill = v.clamp(0.0, 2.0),
"interiorexposure" => knobs.video.interior_exposure = v.clamp(0.25, 8.0),
"interiorshadows" => knobs.video.interior_shadows = v != 0.0,
"interiordebug" => knobs.video.interior_debug = (v.max(0.0) as u32).min(3),
// 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.
@@ -1387,7 +1430,7 @@ fn sync_cvars(
.collect(),
);
let flag = |b: bool| if b { "1" } else { "0" }.to_string();
let session: [(&str, String); 47] = [
let session: [(&str, String); 53] = [
("MasterVolume", sound.master.to_string()),
("SoundVolume", sound.sfx.to_string()),
("MusicVolume", sound.music.to_string()),
@@ -1436,6 +1479,12 @@ fn sync_cvars(
("worldShadows", flag(video.world_shadows)),
("characterShadows", flag(video.character_shadows)),
("shadowDistance", video.shadow_distance.to_string()),
("interiorLight", flag(video.interior_light)),
("interiorAmbient", video.interior_ambient.to_string()),
("interiorFill", video.interior_fill.to_string()),
("interiorExposure", video.interior_exposure.to_string()),
("interiorShadows", flag(video.interior_shadows)),
("interiorDebug", video.interior_debug.to_string()),
// The reference's polarity: the CVar is `gxWindow`, so `1` is the WINDOWED state.
(
"gxWindow",
@@ -0,0 +1,37 @@
//! MONKEY (dynamic interiors): the app-side half of the fixture-lit WMO interior lane — a
//! plug-and-play bridge from the `interiorLight` / `interiorAmbient` / `interiorFill` /
//! `interiorExposure` cvars ([`VideoConfig`]) to benilla-world's [`DynamicInteriors`] resource,
//! which `global_light` packs for `static_gx.wgsl`'s `interior_room_light` (WMO interior surfaces
//! AND interior props light from the room's live fixtures instead of the MOCV bake / the baked prop
//! probe). Independent of the shadow lanes: removing this plugin leaves the resource at its
//! shipped defaults; `interiorLight 0` restores the faithful baked interior path.
//!
//! The knobs are LIVE — `/script SetCVar("interiorExposure", 2)` in chat retunes the room without
//! a rebuild, which is how the defaults were found.
use benilla_world::lighting::DynamicInteriors;
use bevy::prelude::*;
use crate::video::VideoConfig;
pub(crate) struct DynamicInteriorPlugin;
impl Plugin for DynamicInteriorPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Update, bridge);
}
}
/// Publish the cvars to benilla-world when they change. Guarded so the resource's change ticks
/// stay quiet on idle frames.
fn bridge(video: Res<VideoConfig>, mut out: ResMut<DynamicInteriors>) {
let want = DynamicInteriors {
enabled: video.interior_light,
ambient: video.interior_ambient,
fill: video.interior_fill,
exposure: video.interior_exposure,
debug: video.interior_debug,
};
if *out != want {
*out = want;
}
}
+4 -2
View File
@@ -193,10 +193,11 @@ pub(super) fn spawn_wmo_gameobject_props(
time: Res<Time>,
mut hosts: Query<(Entity, &GlobalTransform, &mut WmoProps)>,
) {
let Some((mat_cache, materials, light)) = mats.pieces() else {
return; // no shared light buffer yet
let Some((mat_cache, materials, light, torch)) = mats.pieces() else {
return; // no shared light buffer / torch bindings yet
};
let light = &light;
let torch = &torch;
// The animated-prop clock origin (decision 0130) — per-instance phase = spawn time.
let now = time.elapsed_secs();
for (entity, host_gt, mut props) in &mut hosts {
@@ -282,6 +283,7 @@ pub(super) fn spawn_wmo_gameobject_props(
mat_cache,
materials,
light,
torch,
&m.submeshes,
forms.slices(&prop.handle),
prop.local, // doodad-LOCAL — the parent composes the world pose
+5
View File
@@ -70,6 +70,8 @@ mod debug_panel;
/// rule, in one file. Always compiled; what it *holds* is not.
mod dev;
mod doodad_events;
/// MONKEY (dynamic interiors): the cvar → benilla-world bridge for the fixture-lit interior lane.
mod dynamic_interior;
mod entities;
mod fishing_line;
mod footprints;
@@ -625,6 +627,9 @@ pub fn run(build: BuildId) -> AppExit {
character_shadow::CharacterShadowPlugin,
world_shadow::WorldShadowPlugin,
torch_shadow::TorchShadowPlugin,
// MONKEY (dynamic interiors): the fixture-lit interior lane's cvar bridge — independent
// of the shadow lanes, a plain drop-in.
dynamic_interior::DynamicInteriorPlugin,
))
// 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`.
@@ -13,7 +13,7 @@ use bevy::mesh::{Indices, MeshTag, PrimitiveTopology};
use bevy::prelude::*;
use bevy::render::render_resource::Buffer;
use benilla_assets::materials::{LiquidMaterial, WowModelMaterial};
use benilla_assets::materials::{LiquidMaterial, TorchBinds, WowModelMaterial};
use benilla_world::clouds::CloudMaterial;
use benilla_world::model_render::{
far_twin_of, model_material, zfill_material, MaterialCache, ShadeSel,
@@ -100,6 +100,8 @@ pub(super) fn spawn_menagerie(
lanes: &mut WarmLanes,
cache: &mut MaterialCache,
light: &Buffer,
// MONKEY (torch shadows Phase 3A): the shared torch bindings, beside the light buffer.
torch: &TorchBinds,
) -> usize {
// The four vertex layouts the model lane ships (0837 dump: strides 32/48/56/72): static ×
// {plain, vertex-colours} and their skinned twins. Statics are RENDER_WORLD-only, so their
@@ -159,6 +161,7 @@ pub(super) fn spawn_menagerie(
false,
false, // warms the WORLD lane; the sky lane warms with its own model
light,
torch,
// The pipeline menagerie warms the SHARED batch material; the
// per-placement lane (1408) builds an identical pipeline, so a clone
// needs no row of its own here.
@@ -202,6 +205,7 @@ pub(super) fn spawn_menagerie(
false,
false, // warms the WORLD lane; the sky lane warms with its own model
light,
torch,
// The pipeline menagerie warms the SHARED batch material; the
// per-placement lane (1408) builds an identical pipeline, so a clone
// needs no row of its own here.
@@ -213,7 +217,7 @@ pub(super) fn spawn_menagerie(
// The depth-prime twin (colour writes masked off), plain and cutout.
for cutout in [false, true] {
mats.push(zfill_material(
cache, materials, None, two_sided, cutout, light,
cache, materials, None, two_sided, cutout, light, torch,
));
}
// The **WMO-skybox lane** (decision 1264): `sky_depth` is a `WowModelKey` axis — it
@@ -256,6 +260,7 @@ pub(super) fn spawn_menagerie(
false,
true, // the sky lane
light,
torch,
None, // the shared lane — see the note above
));
}
@@ -298,6 +303,7 @@ pub(super) fn spawn_menagerie(
false,
false, // warms the WORLD lane; the sky lane warms with its own model
light,
torch,
None, // the shared lane — see the note above
);
if let Some(m) = materials.get(&plain) {
@@ -347,6 +353,7 @@ pub(super) fn spawn_menagerie(
false,
false, // warms the WORLD lane; the sky lane warms with its own model
light,
torch,
None, // the shared lane — see the note above
);
if let Some(m) = materials.get(&h) {
+6
View File
@@ -412,6 +412,8 @@ fn run_warm_pass(
mut gizmos: Gizmos,
mut cache: Local<MaterialCache>,
shared_light: Option<Res<benilla_world::lighting::SharedLightBuffer>>,
// MONKEY (torch shadows Phase 3A): the shared torch bindings every warmed material takes.
torch: benilla_world::static_gx::TorchShared,
) {
// `EntryCover` already IS "a loading cover is up and we are in world", counted once for the
// whole client (see its doc); this used to spell the pair out for itself.
@@ -447,6 +449,9 @@ fn run_warm_pass(
let Some(light) = shared_light.as_ref() else {
return;
};
let Some(torch) = torch.binds() else {
return;
};
if !cover.presented() {
return;
}
@@ -473,6 +478,7 @@ fn run_warm_pass(
&mut lanes,
&mut cache,
&light.0,
&torch,
);
info!("pipeline warm: menagerie up ({count} variants, {WARM_REVEAL_PER_FRAME}/frame)");
return;
+234 -75
View File
@@ -1,114 +1,273 @@
//! MONKEY (torch shadows, #2 spike): a point-light shadow lane over the shared rig.
//! MONKEY (torch shadows, Phase 1) — the interior point-light shadow lane, a plug-in on
//! [`super::shadow_core`], independent of [`super::world_shadow`]/[`super::character_shadow`].
//!
//! The nearest WMO interior fixture (a `PointLight` with `LightRooms`) is promoted to a PRIVATE-layer
//! (layer 31) shadow-casting `PointLight` PROXY at its position. Bevy renders that proxy's cube
//! shadow map from the layer-31 casters the world lane already builds (walls, columns), so the
//! interior architecture casts into it for free. The receivers sample it via
//! `shadow_hook::torch_shadow` (which scans `clusterable_objects` for the shadow-enabled point light
//! and calls `fetch_point_shadow`). The proxy's own Bevy light is IGNORED by benilla's custom
//! receivers (they light from the `wow_light` Gouraud term, not Bevy clustering) — only its cube map
//! + clusterable entry are used.
//! **Why this is not Bevy's point shadows.** benilla's world camera sets
//! `bevy::light::cluster::ClusterConfig::None`, which disables Bevy's point/spot clustering AND
//! starves the point-light shadow-map prep — so `fetch_point_shadow` / `clusterable_objects` are
//! DEAD here. The old design (promote the nearest fixtures to shadow-casting `PointLight` proxies and
//! sample their cube maps) could never fire, so it is gone. In its place: this lane picks the nearest
//! ≤4 interior fixtures, computes a down-looking reverse-Z `view_proj` for each, builds the retained
//! interior caster geometry, and publishes it all as [`TorchShadowViews`] (an [`ExtractResource`]).
//! The render world then renders a depth map per fixture (`benilla_world::static_gx::torch_depth`)
//! and `static_gx.wgsl`'s interior surface lane samples it so a pillar throws a radial shadow.
//!
//! SPIKE SCOPE: env-gated (`WOW_TORCH_SHADOWS`), ONE torch, reuses the world lane's layer-31 casters
//! — so `worldShadows` must be on for the walls/columns to exist as casters. Milestone: a column
//! throws a shadow on the interior floor that tracks as you move. Not yet a cvar/slider feature.
//! Gated on `interiorLight` + `interiorShadows` ([`VideoConfig`]); declares demand so the shared rig
//! stays up. Remove this module + its plugin + the `interiorShadows` cvar and the other lanes are
//! untouched. (Phase 1 receiver = static_gx WMO surfaces only; wow_model/entities/terrain are later.)
use bevy::camera::visibility::RenderLayers;
use bevy::math::{DMat4, DVec3, DVec4};
use bevy::prelude::*;
use bevy::render::extract_resource::ExtractResourcePlugin;
use bevy::pbr::MeshMaterial3d;
use benilla_assets::materials::WowModelMaterial;
use benilla_world::billboard::BillboardCard;
use benilla_world::interact::PickMesh;
use benilla_world::lighting::LightRooms;
use benilla_world::model_render::{ModelPart, ShadowOccluder};
use benilla_world::rig_palette::{RigPalettes, RigPart, RigSkin};
use benilla_world::static_gx::{StaticGx, TorchShadowViews};
use benilla_world::view::WorldCamera;
use crate::char_select::ClientState;
use crate::shadow_core::{ShadowSet, PLAYER_SHADOW_LAYER};
use crate::shadow_core::{
collect_entity_geometry, empty_shadow_mesh, restore_mesh_buffers, take_mesh_buffers,
ShadowDemand, ShadowFrame, ShadowSet, STATIC_REBUILD_STEP,
};
use crate::video::VideoConfig;
/// How many interior fixtures cast a depth map at once. The nearest N to the camera win, so the room
/// you are in casts. Matches `torch_depth::MAX_TORCH_MAPS`.
const MAX_TORCH_CASTERS: usize = 4;
/// How far from the camera to look for an interior fixture to promote (yd).
const TORCH_SEARCH_RADIUS: f32 = 60.0;
/// The proxy light's shadow-cast range (yd) — matches the WMO fixture range (`fx::POINT_LIGHT_RANGE`).
const TORCH_SEARCH_RADIUS: f32 = 55.0;
/// The map's shadow-cast range / perspective far plane (yd) — matches the WMO fixture range.
const TORCH_RANGE: f32 = 48.0;
/// MONKEY (Phase 3B): how far from the camera (yd) an ENTITY — furniture, NPC, the player — is
/// gathered into the per-frame torch caster. Covers the farthest promoted fixture's range.
const ENTITY_REACH: f32 = 60.0;
/// MONKEY (Phase 5): six cube faces per fixture. Keep in sync with `torch_depth::CUBE_FACES`.
const CUBE_FACES: usize = 6;
/// Each cube face's FOV: 90° plus a hair, so a direction exactly on a face border still lands
/// inside the face the shader picks for it (the projector returns "lit" outside its frustum).
const TORCH_FACE_FOV: f32 = std::f32::consts::FRAC_PI_2 + 0.02;
pub(crate) struct TorchShadowPlugin;
impl Plugin for TorchShadowPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<TorchProxy>()
.add_systems(Last, update_torch_shadow.in_set(ShadowSet::Lanes));
app.init_resource::<TorchLane>()
.init_resource::<TorchShadowViews>()
// The render world reads the published fixtures/caster through this.
.add_plugins(ExtractResourcePlugin::<TorchShadowViews>::default())
.add_systems(Last, update_torch_shadows.in_set(ShadowSet::Lanes));
}
}
/// Marks the proxy point light so it is never itself a promotion candidate.
#[derive(Component)]
struct TorchShadowProxy;
/// The torch lane's retained state: the interior caster mesh (rebuilt on camera drift) and the
/// camera position it was last built at.
#[derive(Resource, Default)]
struct TorchProxy(Option<Entity>);
/// `WOW_TORCH_SHADOWS=1` — the spike gate (no cvar yet).
fn torch_shadows_enabled() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| std::env::var("WOW_TORCH_SHADOWS").is_ok_and(|v| v != "0"))
struct TorchLane {
caster_mesh: Option<Handle<Mesh>>,
caster_at: Option<Vec3>,
/// MONKEY (Phase 3B): the per-frame ENTITY caster (furniture, NPCs, the player) — rebuilt every
/// frame because entities move; the static `caster_mesh` only rebuilds on camera drift.
entity_mesh: Option<Handle<Mesh>>,
}
fn update_torch_shadow(
/// The lane is active when the dynamic-interior lane is on AND its shadow toggle is on.
fn interior_shadows_on(video: &VideoConfig) -> bool {
video.interior_light && video.interior_shadows
}
/// A reverse-Z perspective (near → 1, far → 0; wgpu clip, RH, looking down −Z) — matches the whole
/// engine's reverse-Z convention and the torch depth pipeline's `GreaterEqual` compare. Built in f64.
fn reverse_z_perspective(fov_y: f64, aspect: f64, near: f64, far: f64) -> DMat4 {
let f = 1.0 / (fov_y * 0.5).tan();
// Column-major columns.
DMat4::from_cols(
DVec4::new(f / aspect, 0.0, 0.0, 0.0),
DVec4::new(0.0, f, 0.0, 0.0),
DVec4::new(0.0, 0.0, near / (far - near), -1.0),
DVec4::new(0.0, 0.0, (far * near) / (far - near), 0.0),
)
}
/// MONKEY (Phase 5): the six cube-face `view_proj`s of a fixture — a 90°(+ε) reverse-Z perspective
/// looking down each of ±X, ±Y, ±Z — so the maps cover EVERY direction around it. This replaced the
/// single aimed cone: any single cone has an edge, and both the vertical (walk up to the forge) and
/// lateral (stand beside the player) shadow collapses were that edge being crossed. Face order is
/// the contract with `static_gx.wgsl`'s `torch_face`: 0 +X, 1 −X, 2 +Y, 3 −Y, 4 +Z, 5 −Z. Computed
/// in f64 because fixtures sit at absolute Bevy world coords (~9,300 out) where f32 view-matrix
/// arithmetic loses precision. Nothing here depends on the camera or the player any more.
fn cube_view_projs(fixture: Vec3) -> [Mat4; CUBE_FACES] {
let eye = DVec3::new(fixture.x as f64, fixture.y as f64, fixture.z as f64);
let proj = reverse_z_perspective(TORCH_FACE_FOV as f64, 1.0, 0.1, TORCH_RANGE as f64);
let faces: [(DVec3, DVec3); CUBE_FACES] = [
(DVec3::X, DVec3::Y),
(DVec3::NEG_X, DVec3::Y),
(DVec3::Y, DVec3::NEG_Z),
(DVec3::NEG_Y, DVec3::NEG_Z),
(DVec3::Z, DVec3::Y),
(DVec3::NEG_Z, DVec3::Y),
];
faces.map(|(dir, up)| {
let vp = proj * DMat4::look_to_rh(eye, dir, up);
Mat4::from_cols_array(&vp.to_cols_array().map(|v| v as f32))
})
}
#[allow(clippy::too_many_arguments)]
fn update_torch_shadows(
video: Res<VideoConfig>,
state: Res<State<ClientState>>,
mut proxy: ResMut<TorchProxy>,
mut commands: Commands,
mut demand: ResMut<ShadowDemand>,
frame: Res<ShadowFrame>,
mut lane: ResMut<TorchLane>,
mut meshes: ResMut<Assets<Mesh>>,
mut views: ResMut<TorchShadowViews>,
cameras: Query<&GlobalTransform, With<WorldCamera>>,
// Interior WMO fixtures: a `PointLight` that carries a room claim (`LightRooms`). The proxy has
// no `LightRooms`, so it is naturally excluded from the candidate set.
// Interior WMO fixtures: a `PointLight` carrying a room claim.
torches: Query<&GlobalTransform, (With<PointLight>, With<LightRooms>)>,
mut proxies: Query<&mut Transform, With<TorchShadowProxy>>,
gx: Option<Res<StaticGx>>,
// MONKEY (Phase 3B): the entity caster inputs — the SAME query the world lane passes to
// `collect_entity_geometry` (written inline by that contract so a lane can pass its own).
parts: Query<
(
&PickMesh,
&ModelPart,
Option<&GlobalTransform>,
Option<&RigPart>,
&ShadowOccluder,
Option<&MeshMaterial3d<WowModelMaterial>>,
),
Without<BillboardCard>,
>,
rigs: Query<&RigSkin>,
palettes: Res<RigPalettes>,
time: Res<Time>,
mut last_trace: Local<f64>,
) {
let active = torch_shadows_enabled() && *state.get() == ClientState::InWorld;
if !active {
if let Some(entity) = proxy.0.take() {
commands.entity(entity).despawn();
}
// Declare demand so the shared rig stays up while on.
let on = interior_shadows_on(&video) && *state.get() == ClientState::InWorld;
demand.0 = demand.0 || on;
if !(frame.active && on) {
teardown(&mut lane, &mut meshes, &mut views);
return;
}
let Some(cam) = cameras.iter().next().map(GlobalTransform::translation) else {
return;
};
// The nearest interior fixture within the search radius.
let mut nearest: Option<(f32, Vec3)> = None;
for fixture in torches.iter() {
let pos = fixture.translation();
let d2 = pos.distance_squared(cam);
if d2 <= TORCH_SEARCH_RADIUS * TORCH_SEARCH_RADIUS
&& nearest.is_none_or(|(best, _)| d2 < best)
{
nearest = Some((d2, pos));
// (1) The N nearest interior fixtures to the camera, nearest first.
let mut scored: Vec<(f32, Vec3)> = torches
.iter()
.filter_map(|t| {
let p = t.translation();
let d2 = p.distance_squared(cam);
(d2 <= TORCH_SEARCH_RADIUS * TORCH_SEARCH_RADIUS).then_some((d2, p))
})
.collect();
scored.sort_by(|a, b| a.0.total_cmp(&b.0));
scored.truncate(MAX_TORCH_CASTERS);
let nearest: Vec<Vec3> = scored.into_iter().map(|(_, p)| p).collect();
// (2) Build/refresh the interior caster mesh (ALWAYS when the lane is on — the depth node needs
// it regardless of `worldShadows`). Rebuilt from the same retained `StaticGx` geometry the world
// lane uses, on camera drift.
if let Some(gx) = gx.as_ref() {
if lane.caster_mesh.is_none() {
lane.caster_mesh = Some(meshes.add(empty_shadow_mesh()));
lane.caster_at = None;
}
let due = lane
.caster_at
.is_none_or(|at| at.distance(cam) > STATIC_REBUILD_STEP);
if due {
if let Some(handle) = lane.caster_mesh.clone() {
if let Some(mesh) = meshes.get_mut(&handle) {
let (mut positions, mut indices) = take_mesh_buffers(mesh);
gx.append_shadow_triangles(cam, TORCH_RANGE, &mut positions, &mut indices);
restore_mesh_buffers(mesh, positions, indices);
lane.caster_at = Some(cam);
}
}
}
}
let Some((_, pos)) = nearest else {
if let Some(entity) = proxy.0.take() {
commands.entity(entity).despawn();
// (2b) MONKEY (Phase 3B): the per-frame ENTITY caster — every frame (entities move), the same
// collector the world lane feeds the directional map: creatures + gameobjects AND environment
// (furniture, props) within ENTITY_REACH of the camera, in absolute Bevy world space like the
// static caster, so the same torch `view_proj` projects both. A table or a character now throws
// a torch shadow onto the floor the static_gx lane already receives.
let mut ent_admitted = 0u32;
if lane.entity_mesh.is_none() {
lane.entity_mesh = Some(meshes.add(empty_shadow_mesh()));
}
if let Some(handle) = lane.entity_mesh.clone() {
if let Some(mesh) = meshes.get_mut(&handle) {
let (mut positions, mut indices) = take_mesh_buffers(mesh);
let (admitted, _rejected) = collect_entity_geometry(
&parts,
&rigs,
&palettes,
true, // creatures + gameobjects (NPCs, the player, placed chairs/tables)
true, // environment (doodads / WMO props)
cam,
ENTITY_REACH,
ENTITY_REACH,
&mut positions,
&mut indices,
);
ent_admitted = admitted;
restore_mesh_buffers(mesh, positions, indices);
}
return;
}
// (3) Publish the fixtures + their down-looking matrices + both caster mesh ids for the render world.
let mut published = TorchShadowViews {
count: nearest.len() as u32,
caster_mesh: lane.caster_mesh.as_ref().map(Handle::id),
entity_mesh: lane.entity_mesh.as_ref().map(Handle::id),
..Default::default()
};
if let Some(entity) = proxy.0 {
if let Ok(mut transform) = proxies.get_mut(entity) {
transform.translation = pos;
// Six cube faces per fixture, independent of camera and player: layer = fixture * 6 + face.
for (i, p) in nearest.iter().enumerate() {
published.positions[i] = p.extend(TORCH_RANGE);
for (f, vp) in cube_view_projs(*p).into_iter().enumerate() {
published.view_projs[i * CUBE_FACES + f] = vp;
}
}
*views = published;
// `WOW_TORCH_TRACE=1` — once a second, what the lane published: fixture distances + whether a
// caster mesh exists. The app-side half of the debug (the shader group-3 sample is the GPU half).
if std::env::var_os("WOW_TORCH_TRACE").is_some() {
let now = time.elapsed_secs_f64();
if now - *last_trace >= 1.0 {
*last_trace = now;
let dists: Vec<f32> = nearest.iter().map(|p| p.distance(cam)).collect();
info!(
"torch-trace: {} maps (caster {}, {} entity casters) — fixture dists {:?}",
nearest.len(),
if lane.caster_mesh.is_some() { "built" } else { "none" },
ent_admitted,
dists,
);
}
} else {
let entity = commands
.spawn((
PointLight {
shadows_enabled: true,
range: TORCH_RANGE,
// Ignored by the custom receivers; only Bevy's cube-map render + clustering read
// the light. A nominal value keeps it from being culled.
intensity: 1000.0,
shadow_depth_bias: 0.06,
shadow_normal_bias: 0.8,
..default()
},
Transform::from_translation(pos),
Visibility::Visible,
RenderLayers::layer(PLAYER_SHADOW_LAYER),
TorchShadowProxy,
))
.id();
proxy.0 = Some(entity);
}
}
/// Tear down the caster mesh and clear the published views (the lane went off, or we left the world).
fn teardown(lane: &mut TorchLane, meshes: &mut Assets<Mesh>, views: &mut TorchShadowViews) {
if let Some(handle) = lane.caster_mesh.take() {
meshes.remove(handle.id());
}
if let Some(handle) = lane.entity_mesh.take() {
meshes.remove(handle.id());
}
lane.caster_at = None;
*views = TorchShadowViews::default();
}
+21
View File
@@ -226,6 +226,20 @@ pub(crate) struct VideoConfig {
/// Realtime-shadow render distance in yards (the `shadowDistance` slider) — the shadow-map
/// cascade range + caster reach. Clamped to `shadow_core::SHADOW_DISTANCE_RANGE`.
pub(crate) shadow_distance: f32,
/// MONKEY (dynamic interiors): WMO interiors + their props light from the room's live fixtures
/// (`interiorLight`) instead of the baked path. The three knobs are `interiorAmbient` (base
/// ambient, 0..1), `interiorFill` (per-fixture bounce gain, 0..2) and `interiorExposure`
/// (light-budget multiplier, 0.25..8) — bridged to benilla-world by `dynamic_interior`.
pub(crate) interior_light: bool,
pub(crate) interior_ambient: f32,
pub(crate) interior_fill: f32,
pub(crate) interior_exposure: f32,
/// MONKEY (torch shadows, Stage B): whether interior fixtures cast real shadows (the nearest few
/// promoted to cube-map casters — `torch_shadow`). Only meaningful with `interior_light` on.
pub(crate) interior_shadows: bool,
/// MONKEY (interior debug): the interior-lane diagnostic overlay (`interiorDebug`, 0..3). See
/// [`benilla_world::lighting::DynamicInteriors::debug`].
pub(crate) interior_debug: u32,
pub(crate) display: DisplayMode,
/// The windowed size, `gxResolution`. Kept while fullscreen so leaving it can restore it.
pub(crate) windowed: UVec2,
@@ -238,6 +252,13 @@ impl Default for VideoConfig {
world_shadows: false,
character_shadows: false,
shadow_distance: crate::shadow_core::DEFAULT_SHADOW_DISTANCE,
// The cvar defaults are the source of truth at load; these only stand in until then.
interior_light: true,
interior_ambient: 0.15,
interior_fill: 0.12,
interior_exposure: 2.5,
interior_shadows: true,
interior_debug: 0,
display: if windowed_env() {
DisplayMode::Windowed
} else {
+37
View File
@@ -222,6 +222,43 @@ pub struct WowModelExt {
/// Set once, never mutated.
#[storage(90, read_only, buffer, visibility(vertex, fragment))]
pub light_buf: Buffer,
/// MONKEY (torch shadows Phase 3A): the interior torch depth-map ARRAY — the one shared
/// `Depth32Float` 512×512×24 `Image` (4 fixtures × 6 cube faces) the torch depth node renders
/// into each frame (`benilla_world::static_gx::torch_depth`, `TorchDepthImage`). Bound as a
/// depth 2D-array + the image's own `GreaterEqual` comparison sampler (the `Image`'s
/// `ImageSamplerDescriptor { compare: Some(GreaterEqual) }` — bevy builds a real comparison
/// sampler from it; the derive's prepare-time check requires the format's sample type to be
/// `Depth`, which `Depth32Float` is). The image is created ONCE at app startup regardless of
/// the cvars — an absent image would stall every model material's bind group on
/// `RetryNextUpdate` and blank every model. Fragment-only, like static_gx's group 3.
#[texture(91, dimension = "2d_array", sample_type = "depth", visibility(fragment))]
#[sampler(92, sampler_type = "comparison", visibility(fragment))]
pub torch_depth: Handle<Image>,
/// MONKEY (torch shadows Phase 3A): the ≤4-fixture torch TABLE — the same 1616-byte
/// `TorchTableUniform` bytes static_gx's group-3 uniform carries (count / positions[4] /
/// view_projs[24]), as a SEPARATE shared raw buffer (`SharedTorchBuffer`) rewritten in place
/// every frame from `TorchShadowViews`. A raw `Buffer` like `light_buf`, deliberately NOT a
/// `#[uniform]` field (a per-frame-mutated uniform re-prepares every material every frame)
/// and NOT a region of the shared light blob (whose `LightStd430` mirror + the booth packer
/// would all have to grow in lock-step). `wow_model.wgsl` reads it as
/// `var<storage, read> torch_table` (std430 of this struct == the std140 1616 bytes: every
/// member is 16-aligned). Set once, never mutated.
#[storage(93, read_only, buffer, visibility(fragment))]
pub torch_buf: Buffer,
}
/// MONKEY (torch shadows Phase 3A): the two torch-receiver bindings every [`WowModelExt`] shares —
/// threaded into the material builders beside the shared light `Buffer`, and stored on
/// `WorldAssets` the same way. Both come from the client's startup resources
/// (`benilla_world::static_gx::{TorchDepthImage, SharedTorchBuffer}`); this crate only clones them
/// into materials, which is why it takes the raw handle + buffer and not those resources (the same
/// severing `WorldAssets::open` does for the light buffer, decision 1164).
#[derive(Clone)]
pub struct TorchBinds {
/// The shared torch depth array image (→ `WowModelExt::torch_depth`).
pub depth: Handle<Image>,
/// The shared 1616-byte torch table buffer (→ `WowModelExt::torch_buf`).
pub table: Buffer,
}
impl MaterialExtension for WowModelExt {
@@ -15,8 +15,7 @@
#import bevy_pbr::{
shadows,
mesh_view_bindings::{lights, clusterable_objects},
mesh_view_types::POINT_LIGHT_FLAGS_SHADOWS_ENABLED_BIT,
mesh_view_bindings::lights,
}
// The realtime shadow lightens over the last SHADOW_EDGE_BAND yards of the cascade's max distance so
@@ -57,22 +56,95 @@ fn realtime_shadow(
return 1.0 - (1.0 - shadow) * night * (1.0 - edge_fade);
}
// MONKEY (torch shadows #2): the realtime POINT-light shadow factor at `sample_pos` — 1.0 = lit,
// 0.0 = fully shadowed. Scans the view's clusterable objects for shadow-casting point lights (the
// torch-shadow lane promotes the nearest interior fixture to one, `benilla_app::torch_shadow`) and,
// for any whose radius covers the fragment, samples its cube shadow map. `min` over them so the
// darkest occluder wins. A no-op indoors when no such light exists (returns 1.0). Called by the
// INTERIOR receiver paths (the exterior sun's `realtime_shadow` is gated out of interiors).
// MONKEY (torch shadows Phase 1): the point/cluster-based torch shadow is DEAD. benilla's world
// camera sets `ClusterConfig::None`, which starves `clusterable_objects` AND the point-shadow prep,
// so `fetch_point_shadow`/the clusterable scan can never fire. The replacement is a from-scratch
// depth map (`benilla_world::static_gx::torch_depth` + `torch_map_shadow` below), sampled by the
// static_gx interior lane through its own group-3 bindings, and (Phase 3A) by `wow_model.wgsl`'s
// entity lane through its material's own bindings 91/92/93 — both call `torch_map_shadow` below.
// These two functions are kept only so `interior_debug_override` (mode 2) compiles; they return
// "unshadowed" and no receiver's lighting path calls them any more.
fn torch_shadow(sample_pos: vec4<f32>, normal: vec3<f32>) -> f32 {
var shadow = 1.0;
let count = arrayLength(&clusterable_objects.data);
for (var i = 0u; i < count; i = i + 1u) {
if ((clusterable_objects.data[i].flags & POINT_LIGHT_FLAGS_SHADOWS_ENABLED_BIT) != 0u) {
let pr = clusterable_objects.data[i].position_radius;
if (distance(pr.xyz, sample_pos.xyz) < pr.w) {
shadow = min(shadow, shadows::fetch_point_shadow(i, sample_pos, normal));
}
}
}
return shadow;
return 1.0;
}
fn torch_shadow_for(light_pos: vec3<f32>, sample_pos: vec4<f32>, normal: vec3<f32>) -> f32 {
return 1.0;
}
// MONKEY (torch shadows Phase 1): the old clusterable-caster count diagnostic. The clustering is
// dead (see above), so this is now a constant 0 — kept only so `interior_debug_override` compiles.
fn torch_caster_count() -> u32 {
return 0u;
}
// MONKEY (torch shadows Phase 1): the depth-map projector. Because `shadow_hook` is imported by
// receivers WITHOUT the group-3 torch bindings (wow_model, terrain), the texture + comparison
// sampler come in AS PARAMETERS — the caller supplies them only where they are bound (static_gx).
// - `view_proj` the fixture's down-looking reverse-Z matrix.
// - `layer` which array layer (fixture index) to sample.
// - `world_pos` the receiving fragment's world position.
// - `depth_tex`/`comp` the group-3 depth array + `GreaterEqual` comparison sampler.
// - `bias` reverse-Z receiver bias (nudges the compare ref up to kill self-shadow acne).
// Returns 1.0 outside the frustum / behind the light; a 4-tap PCF factor otherwise (0 = shadowed).
fn torch_map_shadow(
view_proj: mat4x4<f32>,
layer: i32,
world_pos: vec3<f32>,
depth_tex: texture_depth_2d_array,
comp: sampler_comparison,
bias: f32,
) -> f32 {
let clip = view_proj * vec4<f32>(world_pos, 1.0);
if (clip.w <= 0.0) {
return 1.0;
}
let ndc = clip.xyz / clip.w;
if (ndc.x < -1.0 || ndc.x > 1.0 || ndc.y < -1.0 || ndc.y > 1.0 || ndc.z < 0.0 || ndc.z > 1.0) {
return 1.0;
}
let uv = ndc.xy * vec2<f32>(0.5, -0.5) + vec2<f32>(0.5);
let texel = 1.0 / vec2<f32>(textureDimensions(depth_tex).xy);
// Reverse-Z: the fragment is lit iff its own depth is at least the stored nearest depth, so the
// compare ref is `ndc.z + bias` against `GreaterEqual`.
let ref_depth = ndc.z + bias;
var sum = 0.0;
sum += textureSampleCompareLevel(depth_tex, comp, uv + vec2<f32>(-0.5, -0.5) * texel, layer, ref_depth);
sum += textureSampleCompareLevel(depth_tex, comp, uv + vec2<f32>( 0.5, -0.5) * texel, layer, ref_depth);
sum += textureSampleCompareLevel(depth_tex, comp, uv + vec2<f32>(-0.5, 0.5) * texel, layer, ref_depth);
sum += textureSampleCompareLevel(depth_tex, comp, uv + vec2<f32>( 0.5, 0.5) * texel, layer, ref_depth);
// Phase 5: the six cube faces tile the whole sphere, so there is no cone edge to soften — a fade
// there would punch a lit seam along every face border. Only the far plane fades (reverse-Z:
// ndc.z → 0 at far), so a shadow at the fixture's range limit ends softly.
let far_fade = smoothstep(0.0, 0.06, ndc.z);
return mix(1.0, sum * 0.25, far_fade);
}
// MONKEY (interior debug): the interior lane's diagnostic overlay. `mode` is decoded from the
// receiver's `wmo_fog_params.w` (`1 + debug`); the receivers call this at the END of their interior
// branch so ONLY interior-lit fragments are recoloured — a wrongly-classified outdoor entity then
// stands out against the normally-shaded world. `mode == 0` returns the real `rgb` unchanged.
// 1 CLASSIFICATION → solid green (this fragment took the interior lane)
// 2 SHADOW → the torch-shadow factor as greyscale (black shadowed .. white lit)
// 3 CASTER COUNT → red 0 / green 1 / blue 2 / white 3+ shadow proxies visible to this view
fn interior_debug_override(
mode: u32,
rgb: vec3<f32>,
sample_pos: vec4<f32>,
normal: vec3<f32>,
) -> vec3<f32> {
if (mode == 1u) {
return vec3<f32>(0.0, 1.0, 0.0);
}
if (mode == 2u) {
return vec3<f32>(torch_shadow(sample_pos, normal));
}
if (mode == 3u) {
let n = torch_caster_count();
if (n == 0u) { return vec3<f32>(1.0, 0.0, 0.0); }
if (n == 1u) { return vec3<f32>(0.0, 1.0, 0.0); }
if (n == 2u) { return vec3<f32>(0.0, 0.0, 1.0); }
return vec3<f32>(1.0, 1.0, 1.0);
}
return rgb;
}
@@ -203,6 +203,79 @@ struct WowLight {
};
@group(#{MATERIAL_BIND_GROUP}) @binding(90) var<storage, read> wow_light: WowLight;
// MONKEY (torch shadows Phase 3A): the ENTITY receiver's torch bindings — the SAME depth array
// static_gx's group 3 samples (one shared `Image`, rendered by `static_gx::torch_depth`) and the
// SAME 1616-byte table, riding the material's own group (`WowModelExt` bindings 91/92/93) because
// a Bevy material draw sets groups 0/1/2 only. ALWAYS bound (the image and buffer exist from
// startup), so no shader-def guards this block; only the fragment stage reads it. The struct is
// std430 here and std140 in static_gx — identical bytes, every member is 16-aligned.
struct TorchTable {
count: vec4<u32>, // .x = live fixture count
positions: array<vec4<f32>, 4>, // xyz = fixture world pos, w = range
view_projs: array<mat4x4<f32>, 24>, // 6 cube faces per fixture: [fixture*6 + face]
}
@group(#{MATERIAL_BIND_GROUP}) @binding(91) var torch_depth: texture_depth_2d_array;
@group(#{MATERIAL_BIND_GROUP}) @binding(92) var torch_samp: sampler_comparison;
@group(#{MATERIAL_BIND_GROUP}) @binding(93) var<storage, read> torch_table: TorchTable;
// Reverse-Z receiver bias — KEEP IN SYNC with static_gx.wgsl's TORCH_BIAS (0.001, the known-good
// value; larger detaches every shadow far from the torch).
const TORCH_BIAS: f32 = 0.001;
// NORMAL-OFFSET for entity receivers (yd): sample the map a hand's width OUTSIDE the surface along
// its normal. An entity's caster is the CPU-skinned copy of the same body; the GPU-skinned receiver
// differs by tiny pose/interpolation deltas, so with a plain depth compare every fragment sits a
// hair BEHIND its own caster and the body shadows itself everywhere (a solid-black NPC in
// interiorDebug 2, flickering shadows in the real render). Pushing the sample point out of the skin
// makes self-occlusion impossible while a pillar or another character still casts onto it.
const TORCH_NORMAL_OFFSET: f32 = 0.15;
// MONKEY (Phase 5, MIRRORED from static_gx.wgsl — keep in sync): the cube face that contains
// direction `d` (fixture → fragment) — the major axis, signed. Face order is the contract with
// `benilla_app::torch_shadow::cube_view_projs`: 0 +X, 1 −X, 2 +Y, 3 −Y, 4 +Z, 5 −Z.
fn torch_face(d: vec3<f32>) -> u32 {
let a = abs(d);
if (a.x >= a.y && a.x >= a.z) {
return select(1u, 0u, d.x > 0.0);
}
if (a.y >= a.z) {
return select(3u, 2u, d.y > 0.0);
}
return select(5u, 4u, d.z > 0.0);
}
// MONKEY (torch shadows Phase 3A): this interior fixture's OWN cast shadow on an ENTITY fragment —
// exactly static_gx.wgsl's `torch_surface_shadow` (keep in sync): correlate the `wow_light`
// fixture at `light_pos` to a promoted torch (position match within 1 yd), pick the cube face
// facing the fragment, and sample that layer through the shared projector. 1.0 (unshadowed) when
// no map matches — the lane is off, or the fixture was not among the ≤4 promoted.
fn torch_entity_shadow(light_pos: vec3<f32>, P: vec3<f32>, N: vec3<f32>) -> f32 {
// Normal-offset the sample point out of the body (see TORCH_NORMAL_OFFSET).
let Ps = P + N * TORCH_NORMAL_OFFSET;
for (var i = 0u; i < torch_table.count.x; i = i + 1u) {
let fixture = torch_table.positions[i].xyz;
if (distance(fixture, light_pos) < 1.0) {
let layer = i * 6u + torch_face(Ps - fixture);
return shadow_hook::torch_map_shadow(
torch_table.view_projs[layer], i32(layer), Ps, torch_depth, torch_samp, TORCH_BIAS);
}
}
return 1.0;
}
// MONKEY (torch debug, interiorDebug 2 on ENTITIES): the MIN raw cube-map shadow factor over every
// promoted fixture (ignoring the fixture match) — mirrors static_gx's `torch_debug_factor`, so an
// entity's sampling can be SEEN as greyscale: all-white = the table is empty / the projection misses;
// shaped dark = the depth map reaches the entity and any fault is downstream of sampling.
fn torch_entity_debug_factor(P: vec3<f32>, N: vec3<f32>) -> f32 {
let Ps = P + N * TORCH_NORMAL_OFFSET;
var s = 1.0;
for (var i = 0u; i < torch_table.count.x; i = i + 1u) {
let layer = i * 6u + torch_face(Ps - torch_table.positions[i].xyz);
s = min(s, shadow_hook::torch_map_shadow(
torch_table.view_projs[layer], i32(layer), Ps, torch_depth, torch_samp, TORCH_BIAS));
}
return s;
}
// Vanilla M2 cutout alpha-test reference (224/255 on ≤ WotLK) — kept in sync with
// `debug_panel::VANILLA_ALPHA_KEY_REF`. Used to re-apply the hard cutout on the distance-fade blend
// twin so its silhouette matches the steady cutout exactly.
@@ -317,6 +390,53 @@ fn point_light_sum(P: vec3<f32>, N: vec3<f32>, anchor: vec3<f32>) -> vec3<f32> {
return sum;
}
// MONKEY (dynamic interiors): the per-FRAGMENT room light — MIRRORED from `static_gx.wgsl`
// (`interior_room_light`; keep in sync). Every in-range light of the room-gated table, no
// nearest-3 selection: DIRECT (falloff × wrapped Lambert, so a floor-level hearth still lights
// the floor) + FILL (normal-free, half-desaturated bounce) + the base ambient. The knobs are the
// live cvars packed into `point_count.yzw` (`.y` ambient, `.z` fill gain, `.w` exposure — the
// caller's multiplier before the rolloff). Here it lights INDOOR units and GameObjects (gated on the
// camera-independent probe slot) so they match the fixture-lit room around them instead of the
// day/night CGLight, and takes the SAME per-fixture torch shadows (Phase 3A: the shared depth
// array through this material's own group-2 bindings) as the surfaces.
const INTERIOR_WRAP: f32 = 0.5;
fn interior_room_light(P: vec3<f32>, N: vec3<f32>) -> vec3<f32> {
let count = u32(wow_light.point_count.x);
let k_fill = wow_light.point_count.z;
var direct = vec3<f32>(0.0);
var fill = vec3<f32>(0.0);
for (var i = 0u; i < count; i = i + 1u) {
let pos_range = wow_light.points[2u * i];
let to_light = pos_range.xyz - P;
let d2 = dot(to_light, to_light);
if (d2 > pos_range.w * pos_range.w) {
continue;
}
let d = sqrt(d2);
let c = wow_light.points[2u * i + 1u].rgb;
// Normalised for BOTH terms (keep in sync with static_gx.wgsl): the table commits RAW
// over-gamut colour × intensity; a hot forge would wash the direct term to white.
let c_norm = c / max(1.0, max(c.r, max(c.g, c.b)));
let atten = 1.0 / (0.7 * d + 0.03 * d2);
let nl = max(
(dot(N, to_light / max(d, 1e-4)) + INTERIOR_WRAP) / (1.0 + INTERIOR_WRAP),
0.0,
);
// Phase 3A per-fixture cast shadow (keep in sync with static_gx.wgsl's
// `torch_surface_shadow` call): this fixture's OWN cube map, sampled through the
// material's group-2 torch bindings — a pillar between an NPC and torch A darkens A's
// term without touching torch B's, exactly as on the surfaces around it.
let s = torch_entity_shadow(pos_range.xyz, P, N);
direct += c_norm * (atten * nl) * s;
let reach = 1.0 - d / max(pos_range.w, 1e-4);
let c_fill = mix(c_norm, vec3<f32>(dot(c_norm, vec3<f32>(0.299, 0.587, 0.114))), 0.5);
// DOMINANT fixture, not the sum (keep in sync with static_gx.wgsl): fill must not scale
// with candle count or a dense room saturates flat. Fill is indirect, so not shadowed.
fill = max(fill, c_fill * (k_fill * reach * reach));
}
return direct + fill + vec3<f32>(wow_light.point_count.y);
}
// The MCNK chunk cell center under a world point — the light-selection anchor for geometry merged in
// WORLD space (clutter; terrain.wgsl mirrors this): the reference draws terrain per 33.33-yd MCNK
// chunk and gathers that unit's lights like any other. Grid constants per the ADT format (chunk =
@@ -1136,6 +1256,37 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
);
lit_rgb = albedo * primary;
#endif
// MONKEY (dynamic interiors, `interiorLight` on): an INDOOR unit / GameObject / WMO prop — the
// entity's OWN light-node classification (tag bit 30, `interior_fogged`), never a WMO surface,
// never the create booth's authored rig — takes the fixture-lit room light
// (`interior_room_light`, mirrored from `static_gx.wgsl`) instead of the day/night CGLight or
// the baked probe, so it matches the surfaces around it (the tavern's chairs and NPCs stayed
// bake-bright in a torch-lit room). The instance tint still modulates; the same soft rolloff
// as the surfaces; and (Phase 3A) the same per-fixture torch shadows, from the shared map.
// CAMERA-INDEPENDENT indoor test: `is_interior` (`model_flags.z`) — the SAME signal the WMO
// surface lane and the original interior-prop lane trust to route an entity to interior lighting,
// set from the anchor's down-ray classification, not the camera. (The earlier gates were both
// wrong: `interior_fogged` is the MFOG bit the camera rewrites every frame — an indoor unit went
// dark when the camera left; `probe != 0` is non-zero for EVERY entity, indoor or out, since an
// exterior entity also carries a day/night SH probe slot — so every outdoor GO/unit lit as
// interior, the debug-1 green everywhere.)
// PLUS the classifier's MATTE law (tag bit 14, `mesh_tag::MATTE_INDOOR_BIT`): the anchor
// stands indoors but its material stayed in exterior mode (no bake to fold) — the same room,
// the same lane. Without it a moving unit whose verdict flickers Bake↔Matte at one spot
// (the down-ray marginally hitting the baked floor) switched its lane off and on. Read only
// in exterior material mode: in interior mode those bits are the probe slot.
let matte_indoor = !is_interior && (fade_tag & 0x4000u) != 0u;
if ((is_interior || matte_indoor) && !is_wmo && !is_rig && wow_light.wmo_fog_params.w > 0.5) {
let room = interior_room_light(in.world_position.xyz, n_lit);
lit_rgb = albedo * inst_tint * (vec3<f32>(1.0) - exp(-room * wow_light.point_count.w));
let idbg = u32(max(wow_light.wmo_fog_params.w - 1.0, 0.0) + 0.5);
if (idbg == 2u) {
// The entity's OWN cube-map sampling as greyscale (the shared stub would show white).
lit_rgb = vec3<f32>(torch_entity_debug_factor(in.world_position.xyz, n_lit));
} else {
lit_rgb = shadow_hook::interior_debug_override(idbg, lit_rgb, in.world_position, n_lit);
}
}
// The fullbright/UNLIT path takes the tint too, unlike the highlight: with GL_LIGHTING off the
// same gx state (SetState(1)) is a plain `glColor` modulate on the texture, while GL_EMISSION is
// dead. So a ghost's glow cards tint with its body.
+13 -2
View File
@@ -24,7 +24,7 @@ use bevy::image::Image;
use bevy::prelude::*;
use bevy::render::render_resource::{Buffer, Face};
use crate::materials::{WowModelExt, WowModelMaterial, VANILLA_ALPHA_KEY_REF};
use crate::materials::{TorchBinds, WowModelExt, WowModelMaterial, VANILLA_ALPHA_KEY_REF};
use crate::SpatialCache;
use benilla_formats::{blp_to_rgba, read_texture_native_chain, tga_to_rgba, Chain, ModelBlend};
@@ -91,6 +91,10 @@ pub struct WorldAssets {
/// can clone it into every deduped model material's `light_buf` without threading a `Buffer` through
/// the many call sites. One buffer for the whole scene, updated in place each frame.
pub shared_light: Buffer,
/// MONKEY (torch shadows Phase 3A): the shared torch depth image + torch table buffer, held
/// beside `shared_light` for the same reason — cloned into every deduped model material's
/// `torch_depth`/`torch_buf` without threading them through the call sites.
pub torch: TorchBinds,
}
/// Identity of a deduped model material: a textured material is uniquely determined by its texture
@@ -349,7 +353,10 @@ impl WorldAssets {
/// sit under the renderer: the store's only use of it is `clone()` into every deduped model
/// material's `light_buf`, so the parameter severs what would otherwise be a dependency on the
/// lighting layout and, through it, the rig-palette regions (decision 1164).
pub fn open(chain: Chain, shared_light: Buffer) -> Self {
///
/// `torch` (MONKEY, torch shadows Phase 3A) is the same shape of seam for the torch-shadow
/// receiver bindings: the raw image handle + table buffer, cloned into every model material.
pub fn open(chain: Chain, shared_light: Buffer, torch: TorchBinds) -> Self {
Self {
chain: Arc::new(Mutex::new(chain)),
textures: SpatialCache::default(),
@@ -361,6 +368,7 @@ impl WorldAssets {
loose_root: None,
model_materials: SpatialCache::default(),
shared_light,
torch,
}
}
@@ -641,6 +649,9 @@ impl WorldAssets {
anim_slots: Vec4::ZERO,
// The shared global light (light/fog/SH come from here, updated in place each frame).
light_buf: self.shared_light.clone(),
// MONKEY (torch shadows Phase 3A): the shared torch receiver bindings.
torch_depth: self.torch.depth.clone(),
torch_buf: self.torch.table.clone(),
},
});
self.model_materials.insert(key, handle.clone());
+20 -2
View File
@@ -21,6 +21,10 @@ impl Plugin for AssetPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Startup, open_world_assets.in_set(AssetSet::Open));
app.add_systems(Update, (evict_world_art, scope_world_art));
// MONKEY (torch shadows Phase 3A): the shared torch depth image + table buffer's extract
// plugins and per-frame table upload — always on, like the light buffer's, because every
// model material binds them (`open_world_assets` creates them below).
crate::static_gx::register_torch_shared(app);
}
}
@@ -60,7 +64,11 @@ fn scope_world_art(mut scope: ArtScope, assets: Option<ResMut<WorldAssets>>) {
/// insert the shared [`WorldAssets`] (chain + dedup caches) + [`RenderConfig`]. If the client data
/// can't be found or opened, `WorldAssets` is simply absent and downstream startup falls back to
/// an empty free-fly scene.
fn open_world_assets(mut commands: Commands, device: Res<RenderDevice>) {
fn open_world_assets(
mut commands: Commands,
device: Res<RenderDevice>,
mut images: ResMut<Assets<Image>>,
) {
// The one shared global-light buffer, created here (RenderDevice is live by Startup) so it exists
// before any material is built. Inserted FIRST — ahead of the install lookup, so no early return
// below can skip it: it is cloned into `WorldAssets` (for model materials) and read as the
@@ -72,6 +80,16 @@ fn open_world_assets(mut commands: Commands, device: Res<RenderDevice>) {
let shared_light = crate::lighting::new_shared_light_buffer(&device);
let light_buf = shared_light.0.clone();
commands.insert_resource(shared_light);
// MONKEY (torch shadows Phase 3A): the shared torch depth image + table buffer, on the same
// always-present rule and for the same reason — every model material binds them, and a
// material built against a missing image never gets a bind group (every model blanks).
let (torch_image, torch_buffer) = crate::static_gx::new_torch_shared(&device, &mut images);
let torch = benilla_assets::materials::TorchBinds {
depth: torch_image.0.clone(),
table: torch_buffer.0.clone(),
};
commands.insert_resource(torch_image);
commands.insert_resource(torch_buffer);
let Some(data) = benilla_formats::wow_data() else {
warn!(
"no WoW install found — looked in {:?}; starting with no world",
@@ -99,7 +117,7 @@ fn open_world_assets(mut commands: Commands, device: Res<RenderDevice>) {
});
match open_chain(&data) {
Ok(chain) => commands.insert_resource(WorldAssets::open(chain, light_buf)),
Ok(chain) => commands.insert_resource(WorldAssets::open(chain, light_buf, torch)),
Err(e) => error!("failed to open client data: {e:#}"),
}
}
+5 -1
View File
@@ -861,7 +861,11 @@ fn write_part_law(
}
let payload = match law {
AppliedLaw::Bake(slot) => crate::mesh_tag::with_interior_probe(tag.0, slot),
AppliedLaw::Matte | AppliedLaw::Exterior => crate::mesh_tag::with_exterior_reset(tag.0),
// MONKEY (torch shadows Phase 3A): the Matte law keeps the exterior material but flags
// the part INDOORS, so the shader's dynamic-interior lane holds across a Bake↔Matte
// flicker (the down-ray marginally hitting the baked floor at one spot).
AppliedLaw::Matte => crate::mesh_tag::with_matte_indoor(tag.0),
AppliedLaw::Exterior => crate::mesh_tag::with_exterior_reset(tag.0),
};
tag.0 = crate::mesh_tag::with_interior_fog(payload, fog);
true
@@ -147,6 +147,16 @@ pub fn commit_raw(rgb: [f32; 3]) -> [f32; 3] {
/// [`build_light_data`] packs the nearest-to-camera first when over capacity.
pub(super) const MAX_POINT_LIGHTS: usize = 256;
/// MONKEY (dynamic interiors): a fixture this close to the camera (yd) is packed regardless of the
/// portal flood — see the room term in [`build_light_data`]. Sized to a WHOLE BUILDING PLUS its
/// approach, not just the room: at 40 yd, walking around the outside of the inn kept crossing the
/// boundary and a fixture would evict, so an interior-classified NPC by the door brightened and
/// dimmed as the camera moved (issue A). 90 yd keeps a building's fixtures admitted the whole time
/// you are near it; the per-fixture 48 yd range check still gates which fragments they actually
/// light, so the wider admit only affects table MEMBERSHIP, never reach. (256-slot cap: Goldshire's
/// buildings stay well under it at this radius.)
const INTERIOR_NEAR_ADMIT: f32 = 90.0;
/// Pack lights only within this camera distance (yd). A point light's whole visible effect lives
/// within its ~48 yd candidacy range (`spawn::POINT_LIGHT_RANGE`, the packed `.w`); a pool farther
/// than ~300 yd is sub-pixel and usually fogged, and the cap keeps the per-vertex selection walk
@@ -175,6 +185,15 @@ const POINT_PACK_RADIUS: f32 = 300.0;
#[derive(Component)]
pub struct LightRooms(pub(crate) crate::wmo_portal::WmoGroupVis);
/// MONKEY (torch shadows, Stage B): marks a `PointLight` that exists ONLY to render a cube shadow
/// map (`benilla_app::torch_shadow`'s promoted fixtures). Such a proxy must NOT enter the
/// `wow_light` light table — benilla's receivers would treat it as a real fixture and its nominal
/// intensity (~1000, committed far over gamut) would blast light onto everything near the building,
/// blinking as the proxy repositions. [`build_light_data`]'s gather excludes it; only its Bevy cube
/// map + clusterable entry are wanted.
#[derive(Component)]
pub struct ShadowProxyLight;
/// MONKEY (world shadows): whether the realtime WORLD-shadow lane is active this frame (the
/// `worldShadows` cvar). Set by benilla-app's shadow rig; read by [`build_light_data`], which packs
/// it into the free `sh_c16.w` light lane so `terrain.wgsl` can suppress the baked MCSH terrain
@@ -225,6 +244,7 @@ pub(super) fn register(app: &mut App) {
app.init_resource::<WowLightData>()
.init_resource::<WorldShadowActive>()
.init_resource::<ShadowDistance>()
.init_resource::<DynamicInteriors>()
.init_resource::<super::prop_probes::PropProbeExtract>()
.add_plugins(ExtractResourcePlugin::<WowLightData>::default())
.add_plugins(ExtractResourcePlugin::<SharedLightBuffer>::default())
@@ -297,13 +317,54 @@ fn sun_shadow_strength(sun_height: f32) -> f32 {
t * t * (3.0 - 2.0 * t)
}
/// MONKEY (dynamic interiors): the live knobs of the interior lane — WMO interior surfaces AND
/// interior props light from the room's live fixtures (`static_gx.wgsl` `interior_room_light`)
/// instead of the MOCV bake / the baked prop probe. Bridged every frame from the app-side cvars
/// (`interiorLight`, `interiorAmbient`, `interiorFill`, `interiorExposure` — benilla-app's
/// `dynamic_interior` module) the way [`ShadowDistance`] is, so the look is tunable in-game.
/// `enabled == false` → the faithful baked path. Packed into the free `wmo_fog_params.w` (on/off)
/// and `point_count.yzw` (the three knobs) lanes.
#[derive(Resource, Clone, Copy, PartialEq, Debug)]
pub struct DynamicInteriors {
/// The lane on/off (`interiorLight`).
pub enabled: bool,
/// Base ambient every interior fragment gets, so a fixture-less nook never goes black.
pub ambient: f32,
/// Bounce gain per fixture — the normal-free "lamps everywhere" glow the bake carried.
pub fill: f32,
/// Multiplier on the whole light budget before the soft rolloff — the room's brightness.
pub exposure: f32,
/// MONKEY (interior debug): diagnostic overlay for the interior lane (`interiorDebug`). 0 = off
/// (normal shading). 1 = CLASSIFICATION: interior-lit fragments render solid green, so a wrongly
/// interior-classified OUTDOOR entity shows up. 2 = SHADOW: the torch-shadow factor as greyscale
/// (black = shadowed, white = lit) — shows whether cast shadows are computed at all. 3 = CASTER
/// COUNT: how many shadow-casting proxies the fragment sees in `clusterable_objects` (red = 0 —
/// the proxies never reached the shader; green = 1; blue = 2; white = 3+). Packed with the on/off
/// flag into `wmo_fog_params.w` as `1 + debug` (so `>0.5` still means "on").
pub debug: u32,
}
impl Default for DynamicInteriors {
fn default() -> Self {
Self {
enabled: true,
ambient: 0.15,
fill: 0.12,
exposure: 2.5,
debug: 0,
}
}
}
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
fn build_light_data(
light: Res<WowLighting>,
debug: Res<DebugState>,
view: Res<ViewDistance>,
cam: Query<&GlobalTransform, With<WorldCamera>>,
lights_q: Query<(&PointLight, &GlobalTransform, Option<&LightRooms>)>,
// `Without<ShadowProxyLight>`: the torch-shadow proxies are `PointLight`s too, but they exist
// only to cast a cube map — never to light benilla's receivers (see [`ShadowProxyLight`]).
lights_q: Query<(&PointLight, &GlobalTransform, Option<&LightRooms>), Without<ShadowProxyLight>>,
// The per-frame portal PVS, for the room term below ([`LightRooms`]).
portals: Query<&crate::wmo_portal::WmoPortalInstance>,
mut data: ResMut<WowLightData>,
@@ -312,6 +373,8 @@ fn build_light_data(
world_shadow: Res<WorldShadowActive>,
// MONKEY (distance slider): the realtime-shadow render distance, packed for the edge fade.
shadow_distance: Res<ShadowDistance>,
// MONKEY (dynamic interiors): the interior lane's on/off + live knobs, packed for `static_gx.wgsl`.
dynamic_interiors: Res<DynamicInteriors>,
mut last_dump: Local<f64>,
mut last_rows_dump: Local<f64>,
) {
@@ -360,6 +423,15 @@ fn build_light_data(
// `_wmo_fog[1].z` / `wmo_fog_params.z` lane (row 19). The receivers' edge fade reads it so the
// shadow fades at the cascade's actual `maximum_distance`, whatever the slider is set to.
rows[19][2] = shadow_distance.0;
// MONKEY (dynamic interiors): the lane's on/off into the free `wmo_fog_params.w` lane (1 = WMO
// interior surfaces + props light from the room's live fixtures, 0 = the faithful baked path).
// Its three knobs ride `point_count.yzw`, packed with the table below.
// On/off in the integer part, the debug mode added on top: 0 = off, 1 = on, 1+n = on + debug n.
rows[19][3] = if dynamic_interiors.enabled {
1.0 + dynamic_interiors.debug as f32
} else {
0.0
};
// The dynamic point-light table (decision 0278): every spawned point light within
// [`POINT_PACK_RADIUS`] of the camera, nearest-first when over capacity — the VERTEX stages of
// `terrain.wgsl`/`wow_model.wgsl` walk it for the Gouraud point term (bevy's clusterable buffer
@@ -372,15 +444,23 @@ fn build_light_data(
let cam_pos = cam.single().map(|t| t.translation()).unwrap_or(Vec3::ZERO);
let mut pts: Vec<(f32, Vec3, f32, [f32; 3])> = lights_q
.iter()
.filter(|(_, _, rooms)| {
.filter(|(_, gt, rooms)| {
// The ROOM term (decision 0689's law, fourth lane — see [`LightRooms`]). Not a
// visibility test bolted onto a faithful gather: the reference's register walk has no
// such term either, it simply never has a culled room's torch to register. Ungated for
// every light that names no rooms, which is every light outside a building.
//
// MONKEY (dynamic interiors): a fixture NEAR the camera is admitted regardless of the
// flood. The PVS follows the camera, so a room's torches dropped out of the table with
// camera angle — outside its door, or from the next room — and a fixture-LIT interior
// went dark where the bake never could (the dump flipped 18↔9 packed walking the inn).
// The wall leak this allows is the reference's own: a drawn room's torches register.
crate::wmo_portal::room_admits(
rooms.map(|r| &r.0),
rooms.and_then(|r| portals.get(r.0.instance).ok()),
)
) || (dynamic_interiors.enabled
&& gt.translation().distance_squared(cam_pos)
< INTERIOR_NEAR_ADMIT * INTERIOR_NEAR_ADMIT)
})
.filter_map(|(pl, gt, _)| {
let p = gt.translation();
@@ -395,7 +475,14 @@ fn build_light_data(
.collect();
pts.sort_by(|a, b| a.0.total_cmp(&b.0));
pts.truncate(MAX_POINT_LIGHTS);
data.0.rows[20] = [pts.len() as f32, 0.0, 0.0, 0.0];
// `.x` = the live entry count; MONKEY (dynamic interiors): `.yzw` = the interior lane's live
// knobs (base ambient, per-fixture fill gain, exposure) — free lanes until now.
data.0.rows[20] = [
pts.len() as f32,
dynamic_interiors.ambient,
dynamic_interiors.fill,
dynamic_interiors.exposure,
];
for (i, (_, p, range, rgb)) in pts.iter().enumerate() {
data.0.points[2 * i] = [p.x, p.y, p.z, *range];
data.0.points[2 * i + 1] = [rgb[0], rgb[1], rgb[2], 0.0];
+2 -1
View File
@@ -18,7 +18,8 @@ mod resolve; // the per-frame time-of-day sample into WowLighting + the WMO inte
mod sh; // the model SH light-probe coefficient math
pub use blob::LightBlob;
pub use global_light::{
new_shared_light_buffer, LightRooms, SharedLightBuffer, ShadowDistance, WorldShadowActive,
new_shared_light_buffer, DynamicInteriors, LightRooms, SharedLightBuffer, ShadowDistance,
ShadowProxyLight, WorldShadowActive,
};
pub use prop_probes::{PropProbeSlot, PropProbes, MAX_PROP_PROBES};
// The std430 layout itself — row indices, byte sizes, region offsets and the folds that fill them
+36 -1
View File
@@ -47,7 +47,12 @@
//! `255` = fully MCSH-shadowed): the per-instance mix from the batch's lit sun level toward the
//! shaded one (`wow_model.wgsl`). Entities (units/players/GameObjects) ramp it per frame
//! ([`crate::entity_shade`]); statics (doodads/props) leave it `0` — their shade is the
//! per-material selector (`sun_scale.x`). Bits 14..=18 are reserved (0).
//! per-material selector (`sun_scale.x`). Bit 14 is the **MATTE-INDOOR flag**
//! ([`MATTE_INDOOR_BIT`], MONKEY torch shadows Phase 3A): the classifier's `Matte` law — an
//! entity standing INDOORS whose material nonetheless stays in exterior mode (day/night, no
//! bake). `wow_model.wgsl` routes such a part to the dynamic-interior lane on it, so a unit
//! whose verdict flickers `Bake`↔`Matte` at one spot keeps its lane. Bits 15..=18 are
//! reserved (0).
//! - **Interior probe slot** (interior M2 props/entities — material in interior mode,
//! `model_flags.z` set, not a WMO): bits 6..=18 carry the SH-probe TABLE SLOT (see
//! [`crate::lighting::PropProbes`]; 8192 slots = 13 bits), alpha and rig ride their fixed
@@ -136,6 +141,11 @@ const ALPHA_MAX: f32 = 63.0;
/// Bits 6..=13 of the exterior payload: the ground-shade byte.
const SHADE_MASK: u32 = 0x0000_3fc0;
const SHADE_SHIFT: u32 = 6;
/// Bit 14 of the exterior payload (MONKEY, torch shadows Phase 3A): the part's anchor classified
/// INDOORS under the `Matte` law — exterior material mode, but the room's dynamic light applies.
/// Sits in the exterior payload's reserved bits, so [`with_shade`]/[`with_alpha`]/[`with_rig`]
/// carry it through and the shader's 8-bit shade decode never sees it.
pub(crate) const MATTE_INDOOR_BIT: u32 = 0x0000_4000;
/// Bits 6..=18 of the interior payload: the SH-probe table slot (13 bits ⇔ 8192 slots).
const PROBE_MASK: u32 = 0x0007_ffc0;
const PROBE_SHIFT: u32 = 6;
@@ -197,6 +207,13 @@ pub(crate) fn with_exterior_reset(tag: u32) -> u32 {
(tag & RIG_MASK) | carried_alpha(tag)
}
/// Rewrite a tag as a fresh exterior payload flagged MATTE-INDOOR ([`MATTE_INDOOR_BIT`]),
/// preserving the rig and alpha fields: the classifier's `Matte`-law write (MONKEY, torch shadows
/// Phase 3A). The shade writer re-asserts its byte the same frame and carries the bit through.
pub(crate) fn with_matte_indoor(tag: u32) -> u32 {
with_exterior_reset(tag) | MATTE_INDOOR_BIT
}
/// **A spawned part's initial `MeshTag`** — its rig slot and its starting render alpha, the two
/// fields every spawner sets and the only two it may.
///
@@ -455,6 +472,24 @@ mod tests {
assert_eq!((probe_bits(6660) & PROBE_MASK) >> PROBE_SHIFT, 6660);
}
/// MONKEY (torch shadows Phase 3A): the Matte-law indoor flag rides the exterior payload's
/// reserved bit 14 — the per-frame shade/alpha writers carry it, the shade decode never sees
/// it, and the exterior reclaim clears it.
#[test]
fn matte_indoor_bit_survives_the_field_writers_and_clears_on_exterior() {
let t = with_matte_indoor(rig_bits(9) | alpha_bits(0.5));
assert_eq!(t & MATTE_INDOOR_BIT, MATTE_INDOOR_BIT);
assert_eq!(rig_of(t), 9);
assert_eq!(t & ALPHA_MASK, alpha_bits(0.5));
assert_eq!(with_shade(t, 255) & MATTE_INDOOR_BIT, MATTE_INDOOR_BIT);
assert_eq!(shade_of(with_shade(t, 255)), 255);
assert_eq!(with_alpha(t, 0.25) & MATTE_INDOOR_BIT, MATTE_INDOOR_BIT);
assert_eq!(with_rig(t, 3) & MATTE_INDOOR_BIT, MATTE_INDOOR_BIT);
assert_eq!(with_interior_fog(t, true) & MATTE_INDOOR_BIT, MATTE_INDOOR_BIT);
assert_eq!(with_exterior_reset(t) & MATTE_INDOOR_BIT, 0);
assert_eq!(shade_of(t), 0, "the flag is outside the shade byte");
}
/// Decision 0755: the classifier's whole-payload rewrites carry the tag's ALPHA field, which
/// is what lets a part change light law while a fade owns its ramp. Before this they wrote
/// `alpha_bits(1.0)`, so the classifier had to be locked out of fading parts entirely — and a
+9 -1
View File
@@ -9,7 +9,7 @@ use bevy::pbr::ExtendedMaterial;
use bevy::prelude::*;
use bevy::render::render_resource::{Buffer, Face};
use benilla_assets::materials::{WowModelExt, WowModelMaterial, VANILLA_ALPHA_KEY_REF};
use benilla_assets::materials::{TorchBinds, WowModelExt, WowModelMaterial, VANILLA_ALPHA_KEY_REF};
mod batch;
mod visibility;
@@ -235,6 +235,9 @@ pub fn model_material(
// and the skybox sort rung instead of the ordinary batch-order eps. See [`MatKey::sky_depth`].
sky_depth: bool,
light: &Buffer,
// MONKEY (torch shadows Phase 3A): the shared torch depth image + table buffer, cloned into the
// material beside `light` (not a key axis — one pair for the whole scene, like the light).
torch: &TorchBinds,
// The ONE placement this material belongs to, or `None` for the shared batch material every
// instance of the model reuses. `Some` only for a batch whose animated UV/tint loop depends on
// the sequence its instance is playing (decision 1408): the animated-material registries are
@@ -484,6 +487,8 @@ pub fn model_material(
// table slot is baked in exactly once.
anim_slots: Vec4::ZERO,
light_buf: light.clone(),
torch_depth: torch.depth.clone(),
torch_buf: torch.table.clone(),
},
});
cache.insert(key, handle.clone());
@@ -576,6 +581,7 @@ pub fn zfill_material(
two_sided: bool,
cutout: bool,
light: &Buffer,
torch: &TorchBinds,
) -> Handle<WowModelMaterial> {
let key = MatKey {
light: light.id(),
@@ -645,6 +651,8 @@ pub fn zfill_material(
sidn: Vec4::ZERO,
anim_slots: Vec4::ZERO,
light_buf: light.clone(),
torch_depth: torch.depth.clone(),
torch_buf: torch.table.clone(),
},
});
cache.insert(key, handle.clone());
+46 -7
View File
@@ -20,7 +20,7 @@
//! two lanes that build the same batch the same way get the same handle — which is the whole
//! reason the cache exists, and was true of four separate caches only by luck.
use benilla_assets::materials::WowModelMaterial;
use benilla_assets::materials::{TorchBinds, WowModelMaterial};
use benilla_assets::ModelSubmesh;
use benilla_formats::ModelBlend;
use bevy::ecs::system::SystemParam;
@@ -29,6 +29,7 @@ use bevy::render::render_resource::Buffer;
use super::{model_material, zfill_material, MaterialCache, ShadeSel};
use crate::lighting::SharedLightBuffer;
use crate::static_gx::TorchShared;
/// The engine's one `WowModelMaterial` dedup cache. Swept by distance like every other art cache
/// (`art_scope`) and cleared whole on a map change; an evicted entry costs one rebuild, never a
@@ -78,12 +79,16 @@ pub struct M2BatchMaterials<'w> {
cache: ResMut<'w, ModelMaterials>,
materials: ResMut<'w, Assets<WowModelMaterial>>,
light: Option<Res<'w, SharedLightBuffer>>,
/// MONKEY (torch shadows Phase 3A): the shared torch bindings every built material takes —
/// created in the same startup system as the light buffer, so the same "retry" contract.
torch: TorchShared<'w>,
}
impl M2BatchMaterials<'_> {
/// Is the shared light buffer resident? A spawner that has other work to skip asks first.
/// Is the shared light buffer (and the torch pair) resident? A spawner that has other work to
/// skip asks first.
pub fn ready(&self) -> bool {
self.light.is_some()
self.light.is_some() && self.torch.binds().is_some()
}
/// One steady material for an authored batch drawn in the world on the sky lane.
@@ -99,6 +104,7 @@ impl M2BatchMaterials<'_> {
order: u16,
) -> Option<Handle<WowModelMaterial>> {
let light = self.light.as_ref()?.0.clone();
let torch = self.torch.binds()?;
Some(self.build(
sub,
texture,
@@ -108,6 +114,7 @@ impl M2BatchMaterials<'_> {
false,
false,
&light,
&torch,
))
}
@@ -152,6 +159,7 @@ impl M2BatchMaterials<'_> {
order: u16,
) -> Option<SkyboxBatch> {
let light = self.light.as_ref()?.0.clone();
let torch = self.torch.binds()?;
let mut mk = |fade: bool| {
model_material(
&mut self.cache.0,
@@ -183,6 +191,7 @@ impl M2BatchMaterials<'_> {
false,
true, // the sky lane
&light,
&torch,
None, // one shared sky material — no per-sequence channel to key on
)
};
@@ -225,7 +234,18 @@ impl M2BatchMaterials<'_> {
rig: bool,
) -> Handle<WowModelMaterial> {
let shade = if rig { ShadeSel::Rig } else { ShadeSel::Lit };
self.build(sub, texture, order, shade, false, false, rig, light)
// MONKEY (torch shadows Phase 3A): the torch pair is global (one image, one table for the
// whole process), so an off-world lane takes it from the shared resources even though its
// LIGHT is its own. Both are inserted by `assets::open_world_assets` at `Startup`, before
// any booth or glue system can run, so this cannot fire in a real app; it is an `expect`
// rather than an `Option` return because both callers build `BoothPart`s inside `.map()`
// closures that want a handle, not a retry. (An off-world scene never lights by the
// interior room lane, so the bindings are only ever read as "no torches".)
let torch = self.torch.binds().expect(
"TorchShared: the torch depth image + table are created at Startup \
(assets::open_world_assets) before any off-world material is built",
);
self.build(sub, texture, order, shade, false, false, rig, light, &torch)
}
/// The full variant set an **entity** part needs: every M2 entity — unit, player, GameObject,
@@ -238,6 +258,7 @@ impl M2BatchMaterials<'_> {
order: u16,
) -> Option<BatchVariants> {
let light = self.light.as_ref()?.0.clone();
let torch = self.torch.binds()?;
let steady = self.build(
sub,
texture.clone(),
@@ -247,6 +268,7 @@ impl M2BatchMaterials<'_> {
false,
false,
&light,
&torch,
);
let interior = self.build(
sub,
@@ -257,6 +279,7 @@ impl M2BatchMaterials<'_> {
false,
false,
&light,
&torch,
);
let interior_bake = self.build(
sub,
@@ -267,6 +290,7 @@ impl M2BatchMaterials<'_> {
false,
false,
&light,
&torch,
);
// A multiply batch (Mod/Mod2x) and an authored-Blend batch are their own twin: the first
// because its blend equation reads no alpha at all — the fade rides the shader's
@@ -290,6 +314,7 @@ impl M2BatchMaterials<'_> {
true,
false,
&light,
&torch,
)
};
let interior_bake_blend = if own_twin {
@@ -304,6 +329,7 @@ impl M2BatchMaterials<'_> {
true,
false,
&light,
&torch,
)
};
Some(BatchVariants {
@@ -312,7 +338,7 @@ impl M2BatchMaterials<'_> {
interior_bake,
interior_bake_blend,
fade_blend,
zfill: self.zfill_for(sub, texture, &light),
zfill: self.zfill_for(sub, texture, &light, &torch),
})
}
@@ -330,6 +356,7 @@ impl M2BatchMaterials<'_> {
two_sided: bool,
) -> Option<BatchVariants> {
let light = self.light.as_ref()?.0.clone();
let torch = self.torch.binds()?;
let mut mk = |shade: ShadeSel, probe: bool, fade: bool| {
model_material(
&mut self.cache.0,
@@ -360,6 +387,7 @@ impl M2BatchMaterials<'_> {
false, // …nor the WINDOW flag
false, // a character composite is never a skybox
&light,
&torch,
None, // a composite sheet carries no animated UV/tint channel at all
)
};
@@ -379,6 +407,7 @@ impl M2BatchMaterials<'_> {
two_sided,
blend == ModelBlend::AlphaTest,
&light,
&torch,
)),
})
}
@@ -394,9 +423,15 @@ impl M2BatchMaterials<'_> {
/// this facade to a lane it does not serve.
pub fn pieces(
&mut self,
) -> Option<(&mut MaterialCache, &mut Assets<WowModelMaterial>, Buffer)> {
) -> Option<(
&mut MaterialCache,
&mut Assets<WowModelMaterial>,
Buffer,
TorchBinds,
)> {
let light = self.light.as_ref()?.0.clone();
Some((&mut self.cache.0, &mut self.materials, light))
let torch = self.torch.binds()?;
Some((&mut self.cache.0, &mut self.materials, light, torch))
}
/// The depth-prime twin, or `None` for a batch that primes nothing: one that disables depth
@@ -407,6 +442,7 @@ impl M2BatchMaterials<'_> {
sub: &ModelSubmesh,
texture: Option<Handle<Image>>,
light: &Buffer,
torch: &TorchBinds,
) -> Option<Handle<WowModelMaterial>> {
if sub.no_depth_write || sub.no_depth_test {
return None;
@@ -420,6 +456,7 @@ impl M2BatchMaterials<'_> {
sub.two_sided,
b == ModelBlend::AlphaTest,
light,
torch,
)),
}
}
@@ -437,6 +474,7 @@ impl M2BatchMaterials<'_> {
fade: bool,
play_uv: bool,
light: &Buffer,
torch: &TorchBinds,
) -> Handle<WowModelMaterial> {
model_material(
&mut self.cache.0,
@@ -469,6 +507,7 @@ impl M2BatchMaterials<'_> {
// asks for, so it cannot be one more variant built from `sub` here.
false,
light,
torch,
// The ENTITY lane's batches (units, GameObjects, held items): shared per batch, as
// ever. The per-placement lane is the world streamer's alone (decision 1408) — every
// affected model is a placed `World\…` prop, and an entity already resolves its
@@ -49,6 +49,8 @@ pub(super) fn update_model_particles(
mut mesh_assets: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<WowModelMaterial>>,
light: Res<crate::lighting::SharedLightBuffer>,
// MONKEY (torch shadows Phase 3A): the shared torch bindings every instance material takes.
torch: crate::static_gx::TorchShared,
mut emitters: Query<&mut ParticleEmitter>,
mut draws: Query<
(
@@ -60,6 +62,11 @@ pub(super) fn update_model_particles(
With<ChildDraw>,
>,
) {
// Created in the same startup system as the light buffer; absent only before it ran, when
// there is nothing to build a material against yet.
let Some(torch) = torch.binds() else {
return;
};
for mut emitter in &mut emitters {
let Some(geometry) = emitter.geometry.clone() else {
continue;
@@ -118,6 +125,7 @@ pub(super) fn update_model_particles(
false,
false, // an effect model is never a skybox
&light.0,
&torch,
None, // a shard's material is shared by the whole emitter
);
// The owner-last draw-order rung, stamped on after the fact: a 3-D model
+5 -2
View File
@@ -37,6 +37,8 @@ pub(crate) fn plugin(app: &mut App) {
bevy::asset::embedded_asset!(app, "shaders/ffx_glow.wgsl");
bevy::asset::embedded_asset!(app, "shaders/wow_effect.wgsl");
bevy::asset::embedded_asset!(app, "shaders/static_gx.wgsl");
// MONKEY (torch shadows Phase 1): the vertex-only depth-map shader for the interior-fixture pass.
bevy::asset::embedded_asset!(app, "shaders/torch_depth.wgsl");
}
#[cfg(test)]
@@ -79,7 +81,8 @@ mod tests {
}
// 6 since decision 1264 retired `skybox.wgsl` (a WMO skybox draws on the shared
// model lane, whose forced-far-depth branch lives in `benilla_assets`'
// `wow_model.wgsl`); 7 since 1429 added `static_gx.wgsl` (the B1 retained pass).
assert_eq!(found, 7, "the engine's shader set changed size");
// `wow_model.wgsl`); 7 since 1429 added `static_gx.wgsl` (the B1 retained pass);
// 8 since the torch-shadow Phase 1 added `torch_depth.wgsl` (the interior depth map).
assert_eq!(found, 8, "the engine's shader set changed size");
}
}
+195 -11
View File
@@ -108,6 +108,79 @@ const WORD_MATTE: u32 = 268435456u; // 1 << 28 — exterior MODD prop: intens
// Vanilla cutout ref (224/255) — wow_model.wgsl's VANILLA_ALPHA_KEY.
const VANILLA_ALPHA_KEY: f32 = 0.8784314;
// MONKEY (torch shadows Phase 1): group 3 — the interior torch depth map + the ≤4-entry fixture
// table. Declared (and sampled) ONLY under TORCH_SHADOWS, which the static_gx pipeline always sets;
// the wow_model.wgsl copy of interior_room_light has no group 3 and must not see this block.
#ifdef TORCH_SHADOWS
struct TorchTable {
// .x = live fixture count (a vec4 so the 16-byte-aligned std140 layout matches the Rust
// `TorchTableUniform`: count@0, positions@16, view_projs@80 — total 1616 bytes).
count: vec4<u32>,
positions: array<vec4<f32>, 4>, // xyz = fixture world pos, w = range
view_projs: array<mat4x4<f32>, 24>, // 6 cube faces per fixture: [fixture*6 + face]
}
@group(3) @binding(0) var torch_depth: texture_depth_2d_array;
@group(3) @binding(1) var torch_samp: sampler_comparison;
@group(3) @binding(2) var<uniform> torch_table: TorchTable;
// Reverse-Z depth bias: nudges the receiver's own depth up so a surface never shadows ITSELF (the
// striped acne). KEEP SMALL — reverse-Z compresses depth far from the torch, so a large constant
// bias there detaches every shadow (0.004 made the whole room read "lit"). 0.001 is the known-good
// value that gave the clean forge-cast floor shadows; the residual acne only showed in the
// worst-case interiorDebug 2 (min over all 4 maps), not the real per-fixture render.
const TORCH_BIAS: f32 = 0.001;
#endif
// MONKEY (Phase 5): the cube face that contains direction `d` (fixture → fragment) — the major
// axis, signed. The face order is the contract with `benilla_app::torch_shadow::cube_view_projs`:
// 0 +X, 1 −X, 2 +Y, 3 −Y, 4 +Z, 5 −Z. Each face is a 90°(+ε) frustum down that axis, so the face
// holding the major axis always contains the direction.
fn torch_face(d: vec3<f32>) -> u32 {
let a = abs(d);
if (a.x >= a.y && a.x >= a.z) {
return select(1u, 0u, d.x > 0.0);
}
if (a.y >= a.z) {
return select(3u, 2u, d.y > 0.0);
}
return select(5u, 4u, d.z > 0.0);
}
// This interior fixture's OWN cast shadow: correlate the `wow_light` fixture at `light_pos` to a
// promoted torch (position match within 1 yd), pick the cube face facing the fragment, and sample
// that layer. 1.0 (unshadowed) when no map matches, or when TORCH_SHADOWS is off (wow_model's copy
// of the caller never sets it).
fn torch_surface_shadow(light_pos: vec3<f32>, P: vec3<f32>) -> f32 {
#ifdef TORCH_SHADOWS
for (var i = 0u; i < torch_table.count.x; i = i + 1u) {
let fixture = torch_table.positions[i].xyz;
if (distance(fixture, light_pos) < 1.0) {
let layer = i * 6u + torch_face(P - fixture);
return shadow_hook::torch_map_shadow(
torch_table.view_projs[layer], i32(layer), P, torch_depth, torch_samp, TORCH_BIAS);
}
}
#endif
return 1.0;
}
// MONKEY (torch debug, interiorDebug 2): the MIN raw depth-map shadow factor over EVERY promoted map
// (ignores the fixture-position match), so the map's actual content is visible as greyscale on the
// floor: all-WHITE = maps empty / projection misses the fragment; uniform GREY = self-shadow/bias;
// SHAPED dark regions = real occlusion is being captured (then the fix is correlation/placement).
fn torch_debug_factor(P: vec3<f32>) -> f32 {
#ifdef TORCH_SHADOWS
var s = 1.0;
for (var i = 0u; i < torch_table.count.x; i = i + 1u) {
let layer = i * 6u + torch_face(P - torch_table.positions[i].xyz);
s = min(s, shadow_hook::torch_map_shadow(
torch_table.view_projs[layer], i32(layer), P, torch_depth, torch_samp, TORCH_BIAS));
}
return s;
#else
return 1.0;
#endif
}
// ---- mirrored law (wow_model.wgsl) ----
fn wow_normalize(v: vec3<f32>) -> vec3<f32> {
@@ -152,6 +225,66 @@ fn point_light_sum(P: vec3<f32>, N: vec3<f32>, anchor: vec3<f32>) -> vec3<f32> {
return sum;
}
// MONKEY (dynamic interiors): the per-FRAGMENT room light for interior WMO surfaces AND interior
// props — EVERY in-range light of the room-gated table, no nearest-3 selection (a per-fragment
// selection swap draws a seam where the 4th light drops out). Two terms per light:
// · DIRECT — the reference's falloff × a WRAPPED Lambert, (N·L + wrap)/(1 + wrap): a hearth
// sitting at floor level is horizontal to the floor around it, and a hard max(N·L, 0) lights
// nothing there (the smithy only worked because its forges are raised).
// · FILL — a normal-free bounce, colour × K_FILL × (1 − d/range)²: the "lamps everywhere" glow
// Blizzard's bake carried, sourced from the room's own fixtures instead of a constant. The
// colour is normalised for this term (the table commits RAW over-gamut colour × intensity),
// so one hot forge doesn't wash a whole room.
// `torch_shadow` (the #2 lane, 1.0 when no torch is promoted) shades the direct term only —
// bounce is indirect. A base ambient keeps a fixture-less nook from going black. The knobs are
// LIVE cvars packed into `point_count.yzw` — `.y` base ambient, `.z` fill gain, `.w` exposure
// (the callers' multiplier on the whole budget before the rolloff) — so
// `/script SetCVar("interiorExposure", 2)` retunes a room without a rebuild. The fill is
// half-desaturated: bounce off wood and stone is not candle-orange.
const INTERIOR_WRAP: f32 = 0.5;
fn interior_room_light(P: vec3<f32>, N: vec3<f32>) -> vec3<f32> {
let count = u32(wow_light.point_count.x);
let k_fill = wow_light.point_count.z;
var direct = vec3<f32>(0.0);
var fill = vec3<f32>(0.0);
for (var i = 0u; i < count; i = i + 1u) {
let pos_range = wow_light.points[2u * i];
let to_light = pos_range.xyz - P;
let d2 = dot(to_light, to_light);
if (d2 > pos_range.w * pos_range.w) {
continue;
}
let d = sqrt(d2);
let c = wow_light.points[2u * i + 1u].rgb;
let atten = 1.0 / (0.7 * d + 0.03 * d2);
let nl = max(
(dot(N, to_light / max(d, 1e-4)) + INTERIOR_WRAP) / (1.0 + INTERIOR_WRAP),
0.0,
);
// Normalised for BOTH terms: the table commits RAW over-gamut colour × intensity, and the
// smithy's three forges (1.4, 0.87, 0.4) drove the direct term to a washed-out white while
// the inn's unit candles sat where they should. The hue is what survives.
let c_norm = c / max(1.0, max(c.r, max(c.g, c.b)));
// Phase 1: this fixture's OWN cast shadow, sampled from its down-looking depth map
// (`torch_surface_shadow` correlates the fixture to its promoted map by position; 1.0 when
// it was not promoted). Per-fixture, so a pillar between the fragment and torch A darkens A's
// term without touching torch B's — the occlusion that makes an interior read
// lit-and-shadowed instead of flat. (`static_gx.wgsl` only; the wow_model copy of this
// function keeps the 1.0 `torch_shadow_for` stub — it has no group 3.)
let s = torch_surface_shadow(pos_range.xyz, P);
direct += c_norm * (atten * nl) * s;
let reach = 1.0 - d / max(pos_range.w, 1e-4);
let c_fill = mix(c_norm, vec3<f32>(dot(c_norm, vec3<f32>(0.299, 0.587, 0.114))), 0.5);
// The DOMINANT fixture's fill, not the SUM: an ambient room glow must not scale with the
// candle count, or a dense room (the inn's ~10 fixtures vs the smithy's 3) piles fill up
// until the rolloff saturates every surface to a flat white. `max` keeps the nearest/
// brightest fixture's glow and leaves the direct term to carry the per-fixture relief.
fill = max(fill, c_fill * (k_fill * reach * reach));
}
// Fill is INDIRECT bounce, so it is not shadowed; direct already carries each fixture's shadow.
return direct + fill + vec3<f32>(wow_light.point_count.y);
}
// ---- the pass ----
struct GxVertex {
@@ -218,7 +351,11 @@ fn vertex(v: GxVertex) -> GxVsOut {
// (wow-re trace-forensics-abbey-interior-d3d §2: zero on every observed WMO surface) —
// and so do interior M2 props (B4): their group-MOLR point lobes are folded into the
// per-item SH probe at spawn, the entity path's own vertex-stage zeroing.
if ((v.word & (WORD_WMO | WORD_INTERIOR)) != 0u) {
if ((v.word & WORD_WMO) != 0u) {
// WMO surfaces stay zero here, the reference's own vertex-stage zeroing. MONKEY (dynamic
// interiors): interior groups light from the live torches PER-FRAGMENT in the fragment
// stage (`interior_room_light`) — a per-vertex sum on a WMO's huge floor triangles is
// Gouraud: straight-edged wedges, and a torch mid-triangle lights nothing.
out.point_lit = vec3<f32>(0.0);
} else {
out.point_lit = point_light_sum(world, v.normal, v.anchor);
@@ -404,16 +541,48 @@ fn fragment(in: GxVsOut) -> @location(0) vec4<f32> {
// break the pixel-identity bar.
let tex_rgb = folded / max(vc.rgb, vec3<f32>(1.0 / 255.0));
rgb = tex_rgb * primary;
if (interior && class_int && has_vc) {
// INT self-illumination: tex·MOCV·(1 + 4·MOCV.a), only the final [0,1] clamp —
// the client's own interior pixel shader (literal 4.0). Gated on AUTHORED
// colours: a colour-less INT batch takes the entity path's no-COLORS combine
// (plain tex × lit), which the white-vc algebra above already reproduces.
rgb = clamp(
tex_rgb * vc.rgb * (1.0 + 4.0 * trans_a),
vec3<f32>(0.0),
vec3<f32>(1.0),
);
if (interior && wow_light.wmo_fog_params.w > 0.5) {
// MONKEY (dynamic interiors #A, `interiorLight` on): the WHOLE interior group — INT, TRANS and EXT batch
// classes alike — lights from the LIVE room torches instead of the MOCV bake. Gating
// this on `class_int` left every TRANS/EXT batch (most of a floor near a doorway) on
// the baked `vc.rgb × lit` path above: the "old baked light/shadow still there".
//
// Per-FRAGMENT (`interior_room_light`, every room light): a per-vertex sum on a WMO's
// huge floor triangles is Gouraud — straight-edged wedges, a torch mid-triangle lights
// nothing — the same artefact shape as Blizzard's per-vertex bake. The torch shadow
// (#2 lane, 1.0 when no torch is promoted) shades the torch term only.
//
// Daylight keeps the AUTHORED batch-class weight, so a doorway still takes the sun:
// INT = none, TRANS = the per-vertex MOCV alpha lerp, EXT = full `lit_int_base`.
// MOCV.rgb (the baked light + shadow) is what gets dropped; its alpha is the exterior
// blend weight, not a shadow, and stays.
//
// Soft rolloff instead of a hard clamp: `1 − exp(−x)` maps the ILLUMINATION into [0,1)
// with derivative 1 at 0 (dim nooks untouched) and a smooth asymptote — a bright forge
// never draws the iso-brightness contour a hard `clamp` did. `tex × illum` can't clip.
// SIDN rides inside as the emission it is. Ambient 0.15 fills the dark (cvar/slider
// once the look is confirmed).
let room = interior_room_light(in.world_position.xyz, n_lit);
var day_w = 0.0;
if (class_trans) {
day_w = trans_a;
} else if (!class_int) {
day_w = 1.0;
}
let illum = vec3<f32>(1.0) - exp(-(room + sidn_e) * wow_light.point_count.w);
// BLEND toward the reference's own lit result (`rgb` above — MOCV × the exterior law)
// by the authored batch-class weight; never ADD daylight on top of the room light,
// which left the interior side of every threshold brighter than the exterior floor a
// step away (the hard line at the smithy door). At the portal (weight 1) this IS the
// exterior group's law, so the seam closes by construction, day or night; deep inside
// (weight 0) it is pure room light. EXT-class batches (1) stay on the reference path.
rgb = mix(tex_rgb * illum, rgb, day_w);
let idbg = u32(max(wow_light.wmo_fog_params.w - 1.0, 0.0) + 0.5);
if (idbg == 2u) {
rgb = vec3<f32>(torch_debug_factor(in.world_position.xyz));
} else {
rgb = shadow_hook::interior_debug_override(idbg, rgb, in.world_position, n_lit);
}
}
} else if ((in.word & WORD_INTERIOR) != 0u) {
// ---- the interior M2-PROP lane (B4, decision 1433) — wow_model.wgsl's
@@ -443,6 +612,21 @@ fn fragment(in: GxVsOut) -> @location(0) vec4<f32> {
);
let primary = clamp(lit_prop + in.point_lit, vec3<f32>(0.0), vec3<f32>(1.0));
rgb = folded * primary;
// MONKEY (dynamic interiors #A): interior props take the SAME live room light as the
// surfaces around them (`interior_room_light`) instead of the baked probe — on the probe a
// tavern's tables and barrels stayed bake-bright inside a torch-lit room ("the .m2s ignore
// the light"). Same soft rolloff as the surface lane. UNCONDITIONAL for the prove-out; the
// probe path above is the faithful baked result the cvar will restore.
if (wow_light.wmo_fog_params.w > 0.5) {
let room = interior_room_light(in.world_position.xyz, n_lit);
rgb = folded * (vec3<f32>(1.0) - exp(-room * wow_light.point_count.w));
let idbg = u32(max(wow_light.wmo_fog_params.w - 1.0, 0.0) + 0.5);
if (idbg == 2u) {
rgb = vec3<f32>(torch_debug_factor(in.world_position.xyz));
} else {
rgb = shadow_hook::interior_debug_override(idbg, rgb, in.world_position, n_lit);
}
}
} else {
// ---- the exterior ADT-doodad lane (slice 1) + the exterior MODD-prop family (B4) --
// The intensity family: statics are `mat_shade` only (no per-instance ramp byte —
@@ -0,0 +1,16 @@
// MONKEY (torch shadows, Phase 1): the vertex-only depth pass for one interior fixture.
//
// Renders the retained interior caster geometry (position only, stride 12) from a fixture's
// down-looking reverse-Z `view_proj` into one layer of the torch depth 2D-array texture. No
// fragment stage: only depth is written (clear 0.0, GreaterEqual — reverse-Z). See
// `static_gx/torch_depth.rs` for the pipeline and `static_gx.wgsl` for the sampling side.
struct TorchView {
view_proj: mat4x4<f32>,
}
@group(0) @binding(0) var<uniform> torch_view: TorchView;
@vertex
fn vertex(@location(0) position: vec3<f32>) -> @builtin(position) vec4<f32> {
return torch_view.view_proj * vec4<f32>(position, 1.0);
}
@@ -133,7 +133,16 @@ mod pick;
mod pool;
mod render;
mod shadow; // MONKEY (world shadows): CPU triangle collection for the static-world shadow caster
mod torch_depth; // MONKEY (torch shadows Phase 1): the per-fixture depth-map render + its targets
pub use shadow::CutoutBucket; // MONKEY (world shadows): per-leaf-texture alpha-cutout caster group
pub use torch_depth::TorchShadowViews; // MONKEY (torch shadows Phase 1): the app→render publication
// MONKEY (torch shadows Phase 3A): the shared depth image + table buffer every model material binds,
// their startup constructor, the one-param main-world accessor, and the always-on wiring — used by
// the asset foundation (`crate::assets`) and every material-building site; NOT gated on `enabled()`.
pub use torch_depth::{
new_torch_shared, SharedTorchBuffer, TorchDepthImage, TorchShared,
};
pub(crate) use torch_depth::register_shared as register_torch_shared;
/// The doodad spatial cell — ¼ ADT tile, the same 133⅓-yd locality key the merge lanes use
/// (`terrain_stream::merge::CELL`; 1413 round 2 proved the locality load-bearing).
@@ -221,6 +221,9 @@ struct GxPipelines {
view_layout: BindGroupLayoutDescriptor,
cell_layout: BindGroupLayoutDescriptor,
light_layout: BindGroupLayoutDescriptor,
/// MONKEY (torch shadows Phase 1): group 3 — the torch depth array + comparison sampler + the
/// ≤4-entry `TorchTable` uniform. Present on every static_gx pipeline (the `TORCH_SHADOWS` def).
torch_layout: BindGroupLayoutDescriptor,
sampler: Sampler,
sampler_clamp: Sampler,
/// Keyed `(cutout, two_sided)`; specialized for the world view's (samples, format) pair —
@@ -275,6 +278,25 @@ fn init_pipelines(
// MIXED-wrap batch (repeat one axis, clamp the other) keeps the repeat sampler plus the
// shader's half-texel inset clamp on its clamped axis — an approximation confined to that
// class (decision 0763's silhouette concern, honoured per axis).
// MONKEY (torch shadows Phase 1): group 3 — the interior torch depth map (a Depth32Float
// 2D-array sampled through a `GreaterEqual` comparison sampler) + the ≤4-entry `TorchTable`
// uniform (count/positions/view_projs). Fragment-only; declared behind the shader's
// `TORCH_SHADOWS` def, which the specialization below always sets for this pipeline family.
let torch_layout = BindGroupLayoutDescriptor::new(
"static_gx_torch_layout",
&BindGroupLayoutEntries::sequential(
ShaderStages::FRAGMENT,
(
// A depth 2D-array (= WGSL `texture_depth_2d_array`): no dedicated helper, so a
// `texture_2d_array` with the Depth sample type.
texture_2d_array(TextureSampleType::Depth),
sampler(SamplerBindingType::Comparison),
// TorchTable: count (u32 + pad to 16) + positions[4] (64) + view_projs[24] (1536)
// — six cube faces per fixture (Phase 5).
uniform_buffer_sized(false, Some(std::num::NonZero::new(1616).unwrap())),
),
),
);
let filter = benilla_assets::tex_filter();
let make = |label: &'static str, mode: AddressMode| {
render_device.create_sampler(&SamplerDescriptor {
@@ -292,6 +314,7 @@ fn init_pipelines(
view_layout,
cell_layout,
light_layout,
torch_layout,
sampler: make("static_gx_repeat", AddressMode::Repeat),
sampler_clamp: make("static_gx_clamp", AddressMode::ClampToEdge),
pipelines: HashMap::default(),
@@ -393,6 +416,9 @@ fn prepare_static_gx(
// 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"));
// MONKEY (torch shadows Phase 1): arm the shader's group-3 torch-map code. Set on
// EVERY static_gx pipeline so the group-3 bindings (always bound by the node) match.
defs.push(ShaderDefVal::from("TORCH_SHADOWS"));
if cutout {
defs.push(ShaderDefVal::from("GX_CUTOUT"));
}
@@ -404,6 +430,7 @@ fn prepare_static_gx(
pipes.view_layout.clone(),
pipes.cell_layout.clone(),
pipes.light_layout.clone(),
pipes.torch_layout.clone(),
],
vertex: VertexState {
shader: shader.clone(),
@@ -759,6 +786,56 @@ fn prepare_view_bind(
)));
}
/// The retained pass's group-3 bind group (MONKEY, torch shadows Phase 1): the interior torch depth
/// array (Phase 3A: the shared [`super::torch_depth::TorchDepthImage`]'s `GpuImage` D2Array view —
/// the same texture the entity receiver binds through its material) + static_gx's own comparison
/// sampler (from [`super::torch_depth::TorchDepthTargets`]) and the `TorchTable` uniform packed from
/// the extracted [`super::torch_depth::TorchShadowViews`] by the shared
/// [`super::torch_depth::TorchTableUniform::pack`]. ALWAYS built (with `count = 0` and a zero table
/// when the lane is off or absent) so the pipeline's group 3 is never left unbound — except while the
/// shared image is not yet resident, when static_gx skips the frame (the node early-outs on a
/// missing `GxTorchBind`).
#[derive(Resource)]
struct GxTorchBind(BindGroup);
fn prepare_torch_bind(
mut commands: Commands,
pipes: Res<GxPipelines>,
pipeline_cache: Res<PipelineCache>,
render_device: Res<RenderDevice>,
targets: Option<Res<super::torch_depth::TorchDepthTargets>>,
views: Option<Res<super::torch_depth::TorchShadowViews>>,
image: Option<Res<super::torch_depth::TorchDepthImage>>,
images: Res<RenderAssets<GpuImage>>,
) {
// The sampler/pipeline are created in RenderStartup; the shared image is prepared by the
// render-asset pass (before this set). Without either, group 3 has nothing to bind this frame.
let Some(targets) = targets else {
return;
};
let Some(gpu_image) = image.as_ref().and_then(|h| images.get(&h.0)) else {
return;
};
let table = super::torch_depth::TorchTableUniform::pack(views.as_deref());
let buffer = render_device.create_buffer_with_data(&BufferInitDescriptor {
label: Some("static_gx_torch_table"),
contents: bytemuck::bytes_of(&table),
usage: BufferUsages::UNIFORM,
});
let layout = pipeline_cache.get_bind_group_layout(&pipes.torch_layout);
commands.insert_resource(GxTorchBind(render_device.create_bind_group(
"static_gx_torch",
&layout,
&BindGroupEntries::sequential((
// The image's default view: D2Array over all 24 layers (wgpu's default for a
// multi-layer 2D texture; `torch_depth.rs` spells out the guarantee).
&gpu_image.texture_view,
targets.sampler(),
buffer.as_entire_binding(),
)),
)));
}
#[derive(Debug, Hash, PartialEq, Eq, Clone, RenderLabel)]
struct StaticGxLabel;
@@ -811,6 +888,12 @@ impl ViewNode for StaticGxNode {
let Some(light_bind) = world.get_resource::<GxLightBind>() else {
return Ok(());
};
// MONKEY (torch shadows Phase 1): group 3 is part of every static_gx pipeline now, so it
// must be bound before any draw. `prepare_torch_bind` always builds it (count 0 when the
// lane is off); a missing one means the depth targets weren't ready — skip the frame.
let Some(torch_bind) = world.get_resource::<GxTorchBind>() else {
return Ok(());
};
let meshes = world.resource::<RenderAssets<RenderMesh>>();
let allocator = world.resource::<MeshAllocator>();
// Cells draw whole; a WMO region draws only the runs whose group the flood admitted
@@ -907,6 +990,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.set_bind_group(3, &torch_bind.0, &[]);
pass.draw_indexed(
(islice.range.start + run.index_range.start)
..(islice.range.start + run.index_range.end),
@@ -932,6 +1016,9 @@ pub(super) fn build(app: &mut App) {
));
app.init_resource::<GxWorld>();
app.add_systems(Update, mark_world_camera);
// MONKEY (torch shadows Phase 1): the depth-map targets + node (its own RenderStartup + graph
// wiring). Built before we borrow the render app below — the two borrows are sequential.
super::torch_depth::build(app);
let Some(render_app) = app.get_sub_app_mut(bevy::render::RenderApp) else {
return;
};
@@ -944,6 +1031,7 @@ pub(super) fn build(app: &mut App) {
(
prepare_static_gx.in_set(RenderSystems::PrepareResources),
prepare_view_bind.in_set(RenderSystems::PrepareBindGroups),
prepare_torch_bind.in_set(RenderSystems::PrepareBindGroups),
),
)
.add_render_graph_node::<ViewNodeRunner<StaticGxNode>>(Core3d, StaticGxLabel)
@@ -0,0 +1,569 @@
//! MONKEY (torch shadows, Phase 1): a from-scratch depth-map render for the nearest interior torch
//! fixtures.
//!
//! benilla's world camera sets `ClusterConfig::None`, which disables Bevy's point/spot clustering AND
//! starves the point-light shadow-map prep — so Bevy's built-in `fetch_point_shadow` path is dead for
//! us (see `benilla_app::torch_shadow`). This module replaces it: for each of the nearest ≤4 interior
//! fixtures (published by the app as [`TorchShadowViews`]) it renders the retained interior caster
//! geometry from the fixture's viewpoint into one layer of a `Depth32Float` 2D-array texture.
//! `static_gx.wgsl`'s interior surface lane then samples that array (group 3, built in `render.rs`)
//! so a pillar throws a radial shadow on the floor.
//!
//! **Phase 3A: the depth array is a shared `Image` ASSET**, not a render-world-owned texture. The
//! entity receiver (`wow_model.wgsl`, every unit/NPC/GameObject/interior prop) has no spare bind
//! group — a Bevy material draw sets groups 0/1/2 only — so its torch bindings ride the material's
//! own group as `Handle<Image>` + raw `Buffer` fields (`WowModelExt::torch_depth`/`torch_buf`),
//! and `AsBindGroup` resolves a `Handle<Image>` through `RenderAssets<GpuImage>`. So the image
//! ([`TorchDepthImage`]) is created ONCE at app startup in the main world (always — an absent image
//! would stall every model material's bind group and blank every model), extracted here, and BOTH
//! receivers read the same `GpuImage`: the depth node renders into per-layer views of its texture,
//! static_gx binds its D2Array view, the material binds the same view + the image's comparison
//! sampler. The ≤4-fixture table gets the same treatment ([`SharedTorchBuffer`]: one raw buffer,
//! rewritten in place every frame from [`TorchShadowViews`] with the SAME [`TorchTableUniform`]
//! bytes static_gx's group-3 uniform carries).
//!
//! Reverse-Z throughout (the whole engine is — `capture::depth_probe`): the depth pass clears to 0.0
//! (= far), keeps the nearest surface with `GreaterEqual`, and the comparison sampler is `GreaterEqual`
//! too. The per-fixture `view_proj` is a reverse-Z perspective built app-side in f64 (fixtures sit at
//! absolute Bevy world coordinates, ~9,300 out) and downcast to f32.
use bevy::asset::RenderAssetUsages;
use bevy::core_pipeline::core_3d::graph::{Core3d, Node3d};
use bevy::ecs::query::QueryItem;
use bevy::ecs::system::SystemParam;
use bevy::image::{ImageCompareFunction, ImageFilterMode, ImageSampler, ImageSamplerDescriptor};
// Bevy's OWNED vertex-buffer layout (the pipeline descriptor wants this, not wgpu's borrowed one).
use bevy::mesh::VertexBufferLayout;
use bevy::prelude::*;
use bevy::render::extract_resource::{ExtractResource, ExtractResourcePlugin};
use bevy::render::mesh::allocator::MeshAllocator;
use bevy::render::mesh::{RenderMesh, RenderMeshBufferInfo};
use bevy::render::render_asset::RenderAssets;
use bevy::render::render_graph::{
NodeRunError, RenderGraphContext, RenderGraphExt, RenderLabel, ViewNode, ViewNodeRunner,
};
use bevy::render::render_resource::binding_types::uniform_buffer_sized;
use bevy::render::render_resource::*;
use bevy::render::renderer::{RenderContext, RenderDevice, RenderQueue};
use bevy::render::texture::GpuImage;
use bevy::render::{Render, RenderApp, RenderStartup, RenderSystems};
use bytemuck::Zeroable;
use super::render::StaticGxView;
use benilla_assets::materials::TorchBinds;
/// The depth-map edge (square) of ONE cube face. 512 keeps 24 faces at 24 MB of Depth32Float.
const TORCH_MAP_EDGE: u32 = 512;
/// The fixture cap — four fixtures, four positions.
pub(crate) const MAX_TORCH_MAPS: usize = 4;
/// MONKEY (Phase 5): six cube faces per fixture — a 90° reverse-Z perspective down each of ±X, ±Y,
/// ±Z — so the maps cover EVERY direction. The single aimed cone this replaces always had an edge,
/// and both the vertical (walk up to the forge) and lateral (stand beside the player) shadow
/// collapses were that edge. Layer index = `fixture * CUBE_FACES + face`; the face order is the
/// contract with `benilla_app::torch_shadow::cube_view_projs` and `static_gx.wgsl`'s `torch_face`.
pub(crate) const CUBE_FACES: usize = 6;
/// Total array layers: four fixtures × six faces.
pub(crate) const MAX_TORCH_LAYERS: usize = MAX_TORCH_MAPS * CUBE_FACES;
/// The byte size of [`TorchTableUniform`] — 16 + 64 + 1536; the group-3 layout's uniform size and
/// the [`SharedTorchBuffer`]'s allocation, checked against the struct below.
pub(crate) const TORCH_TABLE_BYTES: u64 = 1616;
/// **The app→render publication** (`benilla_app::torch_shadow` writes it each frame, extracted here):
/// the nearest interior fixtures' down-looking `view_proj`s + world positions and the shared interior
/// caster mesh. `count` is how many of the four slots are live this frame (0 when the lane is off).
#[derive(Resource, Clone, ExtractResource, Default)]
pub struct TorchShadowViews {
pub count: u32,
/// `positions[i].xyz` = fixture world position (absolute Bevy), `.w` = its range (yd).
pub positions: [Vec4; MAX_TORCH_MAPS],
/// The reverse-Z `view_proj` of cube face `f` of fixture `i` at `[i * CUBE_FACES + f]`
/// (identity in unused slots).
pub view_projs: [Mat4; MAX_TORCH_LAYERS],
/// The interior shadow-caster mesh (retained static geometry near the camera), or `None` when the
/// lane has not built one this frame.
pub caster_mesh: Option<AssetId<Mesh>>,
/// MONKEY (Phase 3B): the per-frame ENTITY caster — furniture, NPCs, the player — rebuilt every
/// frame by the lane (entities move), drawn into every layer alongside `caster_mesh` so a table
/// or a character throws a torch shadow on the floor too.
pub entity_mesh: Option<AssetId<Mesh>>,
}
/// MONKEY (torch shadows Phase 3A): the ONE shared torch depth array — a `Depth32Float`
/// 512×512×24-layer `Image` asset (`RENDER_ATTACHMENT | TEXTURE_BINDING`, `RENDER_WORLD`-only,
/// no CPU data) whose sampler descriptor carries `compare: Some(GreaterEqual)`, so its `GpuImage`
/// sampler is a real comparison sampler and its default view is a `D2Array` over all 24 layers
/// (wgpu's default view dimension for a multi-layer 2D texture). Created ONCE at startup by
/// [`new_torch_shared`] (always — regardless of the interior-shadow cvars — because every model
/// material binds it; a missing image would stall every material's bind group), inserted in the
/// main world so material construction can clone the handle, and cloned into the render world so
/// the depth node and static_gx's group-3 prepare can look its `GpuImage` up.
#[derive(Resource, Clone, ExtractResource)]
pub struct TorchDepthImage(pub Handle<Image>);
/// MONKEY (torch shadows Phase 3A): the shared ≤4-fixture torch TABLE buffer — 1616 bytes of
/// [`TorchTableUniform`] (`STORAGE | COPY_DST`), the entity receiver's `torch_table` at material
/// binding 93. Created once beside [`TorchDepthImage`], cloned into every model material, and
/// rewritten in place every frame by [`upload_torch_table`] (render world, `PrepareResources`) —
/// the exact pattern of the shared light buffer (`lighting::global_light::upload_light`).
#[derive(Resource, Clone, ExtractResource)]
pub struct SharedTorchBuffer(pub Buffer);
/// The main-world pair of torch resources as the ONE system param a material-building site adds
/// beside its `SharedLightBuffer` (several of those systems sit at Bevy's 16-param ceiling).
/// [`Self::binds`] is `None` until [`new_torch_shared`] has run — the same "retry, don't bake
/// against nothing" contract the light buffer gives those sites.
#[derive(SystemParam)]
pub struct TorchShared<'w> {
image: Option<Res<'w, TorchDepthImage>>,
table: Option<Res<'w, SharedTorchBuffer>>,
}
impl TorchShared<'_> {
/// The receiver bindings to clone into a `WowModelExt`, or `None` before startup created them.
pub fn binds(&self) -> Option<TorchBinds> {
Some(TorchBinds {
depth: self.image.as_ref()?.0.clone(),
table: self.table.as_ref()?.0.clone(),
})
}
}
/// Create the shared torch depth image + table buffer (main world, `Startup` — `RenderDevice` and
/// `Assets<Image>` are both live there). Called by the asset foundation next to the shared light
/// buffer so the pair exists before any material is built, whatever the cvars say.
pub fn new_torch_shared(
device: &RenderDevice,
images: &mut Assets<Image>,
) -> (TorchDepthImage, SharedTorchBuffer) {
let image = Image {
// A pure GPU render target: no CPU pixels, ever.
data: None,
texture_descriptor: TextureDescriptor {
label: Some("torch_depth"),
size: Extent3d {
width: TORCH_MAP_EDGE,
height: TORCH_MAP_EDGE,
depth_or_array_layers: MAX_TORCH_LAYERS as u32,
},
mip_level_count: 1,
sample_count: 1,
dimension: TextureDimension::D2,
format: TextureFormat::Depth32Float,
usage: TextureUsages::RENDER_ATTACHMENT | TextureUsages::TEXTURE_BINDING,
view_formats: &[],
},
// The receivers' `GreaterEqual` comparison sampler (reverse-Z: lit iff the fragment's own
// depth ≥ the stored nearest depth). `ImageSamplerDescriptor::as_wgpu` carries `compare`
// through, so `GpuImage::sampler` IS a comparison sampler — which the `AsBindGroup`
// derive's `sampler_type = "comparison"` binding hands to the material.
sampler: ImageSampler::Descriptor(ImageSamplerDescriptor {
label: Some("torch_depth_cmp".into()),
mag_filter: ImageFilterMode::Linear,
min_filter: ImageFilterMode::Linear,
compare: Some(ImageCompareFunction::GreaterEqual),
..Default::default()
}),
// `None` ⇒ wgpu's default view: the texture's format, all mips/layers, and — for a D2
// texture with >1 layers — `D2Array`, exactly the `texture_depth_2d_array` both receivers
// declare. (Spelled out rather than relied on silently: it is the one fact the whole
// sharing scheme rests on.)
texture_view_descriptor: None,
asset_usage: RenderAssetUsages::RENDER_WORLD,
..Default::default()
};
let buffer = device.create_buffer(&BufferDescriptor {
label: Some("wow_shared_torch_table"),
size: TORCH_TABLE_BYTES,
usage: BufferUsages::STORAGE | BufferUsages::COPY_DST,
mapped_at_creation: false,
});
(TorchDepthImage(images.add(image)), SharedTorchBuffer(buffer))
}
/// The std140/std430 `TorchTable` — mirrored in `static_gx.wgsl` (group 3, binding 2, uniform) and
/// `wow_model.wgsl` (material binding 93, storage). `count` pads to a vec4; `positions[i].xyz` is
/// the fixture, `.w` its range; `view_projs[i*6 + f]` is fixture `i`'s reverse-Z matrix for cube
/// face `f` (Phase 5: six 90° faces cover every direction, replacing the single aimed cone whose
/// edge kept eating shadows). Unused slots stay zero (never sampled: the shaders loop `count`).
/// 16 + 64 + 1536 = 1616 bytes — [`TORCH_TABLE_BYTES`]; identical bytes under both layouts
/// because every member is 16-byte aligned.
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
pub(crate) struct TorchTableUniform {
count: u32,
_pad: [u32; 3],
positions: [[f32; 4]; MAX_TORCH_MAPS],
view_projs: [[[f32; 4]; 4]; MAX_TORCH_LAYERS],
}
const _: () = assert!(std::mem::size_of::<TorchTableUniform>() as u64 == TORCH_TABLE_BYTES);
impl TorchTableUniform {
/// Pack this frame's table from the extracted views — a zero table (count 0) when the lane is
/// off or the resource is absent, so both receivers read "no torches" rather than stale data.
pub(crate) fn pack(views: Option<&TorchShadowViews>) -> Self {
let mut table = Self::zeroed();
let Some(views) = views else {
return table;
};
let count = (views.count as usize).min(MAX_TORCH_MAPS);
table.count = count as u32;
for i in 0..count {
table.positions[i] = views.positions[i].to_array();
// Six cube faces per fixture: layer = fixture * 6 + face (the depth node renders and
// the shaders' `torch_face` pick by the same index).
for f in 0..CUBE_FACES {
table.view_projs[i * CUBE_FACES + f] =
views.view_projs[i * CUBE_FACES + f].to_cols_array_2d();
}
}
table
}
}
/// Render-world: rewrite the shared torch table in place every frame, before any draw reads it
/// (`RenderSystems::PrepareResources` — the same stage `upload_light` writes the light blob). One
/// 1.6 KB upload per frame, independent of material count. static_gx's group-3 uniform is packed
/// from the same [`TorchTableUniform::pack`] in `render.rs`, so the two receivers can never
/// disagree on a fixture.
fn upload_torch_table(
queue: Res<RenderQueue>,
buffer: Option<Res<SharedTorchBuffer>>,
views: Option<Res<TorchShadowViews>>,
) {
let Some(buffer) = buffer else {
return;
};
let table = TorchTableUniform::pack(views.as_deref());
queue.write_buffer(&buffer.0, 0, bytemuck::bytes_of(&table));
}
/// The persistent render-world state that does NOT depend on the image: the comparison sampler
/// static_gx's group 3 binds (kept as its own `GreaterEqual` sampler — unchanged behaviour; the
/// material binds the image's, which is built from the same descriptor), the depth-only pipeline
/// and its per-fixture view-uniform layout. Built once in [`RenderStartup`].
#[derive(Resource)]
pub(crate) struct TorchDepthTargets {
sampler: Sampler,
pipeline: CachedRenderPipelineId,
view_layout: BindGroupLayoutDescriptor,
}
impl TorchDepthTargets {
/// The `GreaterEqual` comparison sampler for the static_gx group-3 shadow lookup.
pub(crate) fn sampler(&self) -> &Sampler {
&self.sampler
}
}
/// The per-layer D2 render-attachment views over the shared image's texture, cached by that
/// texture's id: rebuilt only if the `GpuImage` is ever re-prepared (it is created once and never
/// modified, so in practice exactly once), never per frame. Empty until the image is resident.
#[derive(Resource, Default)]
struct TorchLayerViews {
texture: Option<TextureId>,
/// `views[i]` = the depth attachment for array layer `i` (fixture `i / 6`, face `i % 6`).
views: Vec<TextureView>,
}
/// This frame's per-fixture view-uniform bind groups (one per live map), rebuilt in
/// [`RenderSystems::PrepareBindGroups`].
#[derive(Resource, Default)]
struct TorchDepthDraw {
count: usize,
/// `bind_groups[i]` binds layer `i`'s `view_proj` at group 0 of the depth pipeline.
bind_groups: Vec<BindGroup>,
}
fn init_torch_depth(
mut commands: Commands,
render_device: Res<RenderDevice>,
pipeline_cache: Res<PipelineCache>,
asset_server: Res<AssetServer>,
) {
let sampler = render_device.create_sampler(&SamplerDescriptor {
label: Some("torch_depth_cmp"),
min_filter: FilterMode::Linear,
mag_filter: FilterMode::Linear,
compare: Some(CompareFunction::GreaterEqual),
..Default::default()
});
// Group 0: the single per-fixture `view_proj` (a mat4, 64 bytes), vertex-only.
let view_layout = BindGroupLayoutDescriptor::new(
"torch_depth_view_layout",
&BindGroupLayoutEntries::sequential(
ShaderStages::VERTEX,
(uniform_buffer_sized(false, Some(std::num::NonZero::new(64).unwrap())),),
),
);
let shader: Handle<Shader> =
asset_server.load("embedded://benilla_world/shaders/torch_depth.wgsl");
let pipeline = pipeline_cache.queue_render_pipeline(RenderPipelineDescriptor {
label: Some("torch_depth".into()),
layout: vec![view_layout.clone()],
vertex: VertexState {
shader,
shader_defs: vec![],
entry_point: Some("vertex".into()),
// The caster mesh (`shadow_core::empty_shadow_mesh`) carries ONLY position — stride 12.
buffers: vec![VertexBufferLayout {
array_stride: 12,
step_mode: VertexStepMode::Vertex,
attributes: vec![VertexAttribute {
format: VertexFormat::Float32x3,
offset: 0,
shader_location: 0,
}],
}],
},
// Depth only — no fragment, no color targets.
fragment: None,
primitive: PrimitiveState {
// Cull none is safest for the mixed-winding WMO/static caster geometry.
cull_mode: None,
..Default::default()
},
depth_stencil: Some(DepthStencilState {
format: TextureFormat::Depth32Float,
depth_write_enabled: true,
depth_compare: CompareFunction::GreaterEqual,
stencil: StencilState::default(),
bias: DepthBiasState::default(),
}),
multisample: MultisampleState {
count: 1,
..Default::default()
},
..default()
});
commands.insert_resource(TorchDepthTargets {
sampler,
pipeline,
view_layout,
});
}
/// Build one view-uniform bind group per live layer from the extracted [`TorchShadowViews`], and
/// (re)build the per-layer attachment views whenever the shared image's texture changes identity.
fn prepare_torch_depth(
mut commands: Commands,
targets: Option<Res<TorchDepthTargets>>,
views: Option<Res<TorchShadowViews>>,
image: Option<Res<TorchDepthImage>>,
images: Res<RenderAssets<GpuImage>>,
mut layers: ResMut<TorchLayerViews>,
pipeline_cache: Res<PipelineCache>,
render_device: Res<RenderDevice>,
) {
// The attachment views, cached by texture id — 24 views once, not per frame.
let gpu_image = image.as_ref().and_then(|h| images.get(&h.0));
match gpu_image {
Some(gpu) if layers.texture != Some(gpu.texture.id()) => {
layers.views = (0..MAX_TORCH_LAYERS as u32)
.map(|i| {
gpu.texture.create_view(&TextureViewDescriptor {
label: Some("torch_depth_layer"),
format: Some(TextureFormat::Depth32Float),
dimension: Some(TextureViewDimension::D2),
aspect: TextureAspect::DepthOnly,
base_mip_level: 0,
mip_level_count: Some(1),
base_array_layer: i,
array_layer_count: Some(1),
..default()
})
})
.collect();
layers.texture = Some(gpu.texture.id());
}
Some(_) => {}
None => {
// Not resident (yet): nothing to render into this frame.
layers.views.clear();
layers.texture = None;
}
}
let Some(targets) = targets else {
commands.insert_resource(TorchDepthDraw::default());
return;
};
let count = views
.as_ref()
.map_or(0, |v| (v.count as usize).min(MAX_TORCH_MAPS));
if count == 0 {
commands.insert_resource(TorchDepthDraw::default());
return;
}
let views = views.expect("count > 0 implies views present");
let layout = pipeline_cache.get_bind_group_layout(&targets.view_layout);
// Six cube faces per live fixture: layer = fixture * CUBE_FACES + face.
let layer_count = count * CUBE_FACES;
let mut bind_groups = Vec::with_capacity(layer_count);
for i in 0..layer_count {
let vp = views.view_projs[i].to_cols_array();
let buffer = render_device.create_buffer_with_data(&BufferInitDescriptor {
label: Some("torch_depth_view"),
contents: bytemuck::cast_slice(&vp),
usage: BufferUsages::UNIFORM,
});
// The wgpu bind group keeps the buffer alive; the local handle can drop.
bind_groups.push(render_device.create_bind_group(
"torch_depth_view",
&layout,
&BindGroupEntries::sequential((buffer.as_entire_binding(),)),
));
}
commands.insert_resource(TorchDepthDraw {
count: layer_count,
bind_groups,
});
}
#[derive(Debug, Hash, PartialEq, Eq, Clone, RenderLabel)]
struct TorchDepthLabel;
#[derive(Default)]
struct TorchDepthNode;
impl ViewNode for TorchDepthNode {
// Run only for the world view — the same marker the retained pass gates on, so a portrait-booth
// bake never renders torch depth with the booth's matrices.
type ViewQuery = &'static StaticGxView;
fn run<'w>(
&self,
_graph: &mut RenderGraphContext,
render_context: &mut RenderContext<'w>,
_view: QueryItem<'w, '_, Self::ViewQuery>,
world: &'w World,
) -> Result<(), NodeRunError> {
let Some(views) = world.get_resource::<TorchShadowViews>() else {
return Ok(());
};
let Some(draw) = world.get_resource::<TorchDepthDraw>() else {
return Ok(());
};
let Some(targets) = world.get_resource::<TorchDepthTargets>() else {
return Ok(());
};
// The shared image's per-layer attachment views — empty while the GpuImage is not resident.
let Some(layers) = world.get_resource::<TorchLayerViews>() else {
return Ok(());
};
// `count` is LAYERS here: six cube faces per live fixture.
let count = draw
.count
.min(views.count as usize * CUBE_FACES)
.min(MAX_TORCH_LAYERS)
.min(layers.views.len());
if count == 0 {
return Ok(());
}
let pipeline_cache = world.resource::<PipelineCache>();
let Some(pipeline) = pipeline_cache.get_render_pipeline(targets.pipeline) else {
return Ok(());
};
let meshes = world.resource::<RenderAssets<RenderMesh>>();
let allocator = world.resource::<MeshAllocator>();
// MONKEY (Phase 3B): BOTH casters — the drift-rebuilt static interior (walls/columns) and the
// per-frame entity set (furniture, NPCs, the player) — resolved up front. One that is not
// resident this frame (just built, or empty) is skipped, never fatal to the other.
let mut draws = Vec::with_capacity(2);
for mesh_id in [views.caster_mesh, views.entity_mesh].into_iter().flatten() {
let Some(mesh) = meshes.get(mesh_id) else {
continue;
};
let (Some(vslice), Some(islice)) = (
allocator.mesh_vertex_slice(&mesh_id),
allocator.mesh_index_slice(&mesh_id),
) else {
continue;
};
let RenderMeshBufferInfo::Indexed { index_format, .. } = &mesh.buffer_info else {
continue;
};
if islice.range.is_empty() {
continue;
}
draws.push((vslice, islice, *index_format));
}
if draws.is_empty() {
return Ok(());
}
for i in 0..count {
let mut pass = render_context.begin_tracked_render_pass(RenderPassDescriptor {
label: Some("torch_depth"),
color_attachments: &[],
depth_stencil_attachment: Some(RenderPassDepthStencilAttachment {
view: &layers.views[i],
// Reverse-Z: clear to 0.0 (far), keep the nearest surface (GreaterEqual).
depth_ops: Some(Operations {
load: LoadOp::Clear(0.0),
store: StoreOp::Store,
}),
stencil_ops: None,
}),
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_render_pipeline(pipeline);
pass.set_bind_group(0, &draw.bind_groups[i], &[]);
// Static walls + moving entities into the SAME layer under the one clear.
for (vslice, islice, index_format) in &draws {
pass.set_vertex_buffer(0, vslice.buffer.slice(..));
pass.set_index_buffer(islice.buffer.slice(..), *index_format);
pass.draw_indexed(
islice.range.start..islice.range.end,
i32::try_from(vslice.range.start).unwrap_or(0),
0..1,
);
}
}
Ok(())
}
}
/// Wire the ALWAYS-ON half (MONKEY, Phase 3A): the extract plugins for the shared image + table
/// (so the render world can look up the `GpuImage` and write the buffer), and the per-frame table
/// upload. Registered by the asset foundation next to the shared light buffer — NOT gated on the
/// retained pass (`static_gx::enabled`), because every model material binds these whether or not
/// static_gx draws. With the depth node off the table reads count 0 and the image stays at its
/// zero-initialised (= reverse-Z far ⇒ unshadowed) contents, so entities simply receive no shadow.
pub(crate) fn register_shared(app: &mut App) {
app.add_plugins((
ExtractResourcePlugin::<TorchDepthImage>::default(),
ExtractResourcePlugin::<SharedTorchBuffer>::default(),
));
if let Some(render_app) = app.get_sub_app_mut(RenderApp) {
render_app.add_systems(
Render,
upload_torch_table.in_set(RenderSystems::PrepareResources),
);
}
}
/// Wire the render half: the persistent targets (RenderStartup), the per-frame view bind groups, and
/// the depth node between the prepass and the main opaque pass.
pub(super) fn build(app: &mut App) {
let Some(render_app) = app.get_sub_app_mut(RenderApp) else {
return;
};
render_app
.init_resource::<TorchLayerViews>()
.add_systems(RenderStartup, init_torch_depth)
.add_systems(
Render,
prepare_torch_depth.in_set(RenderSystems::PrepareBindGroups),
)
.add_render_graph_node::<ViewNodeRunner<TorchDepthNode>>(Core3d, TorchDepthLabel)
.add_render_graph_edges(
Core3d,
// After all prepasses complete, before the main opaque pass — so the depth maps are
// ready when `StaticGxNode` (between opaque and transparent) samples them.
(
Node3d::EndPrepasses,
TorchDepthLabel,
Node3d::MainOpaquePass,
),
);
}
@@ -20,7 +20,7 @@ use crate::mesh_tag::alpha_bits;
use crate::model_fade::DoodadFade;
use crate::model_render::{model_material, MaterialCache, ShadeSel};
use crate::model_render::{ModelKind, ModelPart};
use benilla_assets::materials::WowModelMaterial;
use benilla_assets::materials::{TorchBinds, WowModelMaterial};
/// What one placement's animation host armed, for the consumers that spawn alongside its submeshes.
/// All fields are *per placement*, not per model: the anchors are this instance's, and `arm` is
@@ -88,6 +88,8 @@ pub fn spawn_model_entities(
mat_cache: &mut MaterialCache,
materials: &mut Assets<WowModelMaterial>,
light: &Buffer,
// MONKEY (torch shadows Phase 3A): the shared torch bindings, beside the light buffer.
torch: &TorchBinds,
submeshes: &[ModelSubmesh],
// The model's app-built render forms (decision 0834), index-parallel with `submeshes`: the
// static handle + its build-time `Aabb` per batch, and the skinned twins when this model's
@@ -300,6 +302,7 @@ pub fn spawn_model_entities(
sub.window,
false, // the world streamer never spawns a skybox
light,
torch,
seq_owner,
);
// The blend twin for the distance-fade feather pass (reuse the cutout when already blend, or when
@@ -344,6 +347,7 @@ pub fn spawn_model_entities(
sub.window,
false, // the world streamer never spawns a skybox
light,
torch,
seq_owner,
)
};
@@ -69,6 +69,9 @@ type SpawnTables<'w> = (
// `None` only under `WOW_STATIC_GX=0` (the resource exists whenever the retained pass
// is armed — the default since 1434).
Option<ResMut<'w, crate::static_gx::StaticGx>>,
// MONKEY (torch shadows Phase 3A): the shared torch bindings every spawned material takes
// (nested here for the same 16-param reason as the rest of the tuple).
crate::static_gx::TorchShared<'w>,
);
#[allow(clippy::too_many_arguments)]
@@ -97,10 +100,15 @@ pub(super) fn spawn_loaded_placements(
// model-forms cache (decision 0834).
tables: SpawnTables,
) {
let (mut probes, mut activity, focus, mut forms, mut welds, mut merge, mut staticgx) = tables;
let (mut probes, mut activity, focus, mut forms, mut welds, mut merge, mut staticgx, torch) =
tables;
let Some(shared_light) = shared_light else {
return;
};
let Some(torch) = torch.binds() else {
return; // created in the same startup system as the light buffer — retry like it
};
let torch = &torch;
// Steady state is everything spawned, and the walk below still visits every placement and
// every WMO prop to find that out — the pending count (kept by the register/handoff/release
// sites) makes that frame free.
@@ -192,6 +200,7 @@ pub(super) fn spawn_loaded_placements(
mat_cache,
materials,
light,
torch,
&m.submeshes,
FormSlices {
stat: forms.static_meshes(key).unwrap_or(&[]),
@@ -372,6 +381,7 @@ pub(super) fn spawn_loaded_placements(
mat_cache,
materials,
light,
torch,
&m.submeshes,
FormSlices {
stat: forms.static_meshes(key).unwrap_or(&[]),
@@ -716,6 +726,7 @@ pub(super) fn spawn_loaded_placements(
mat_cache,
materials,
light,
torch,
&m.submeshes,
FormSlices {
stat: forms.static_meshes(key).unwrap_or(&[]),