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:
co-authored by
Claude Fable 5.1
parent
c4e7660003
commit
e254e6dce8
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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());
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(&[]),
|
||||
|
||||
Reference in New Issue
Block a user