MONKEY: shadow render-distance slider (shadowDistance)

Make the 80 yd realtime-shadow range a runtime slider (Options -> Graphics ->
Shadow Distance, 40-200 yd, default 80). Confirmed in-game.

- shadowDistance cvar -> VideoConfig.shadow_distance, clamped to
  shadow_core::SHADOW_DISTANCE_RANGE. VideoConfig drops Eq (f32 field).
- shadow_core reads it: the cascade maximum_distance + the caster-collection
  reaches. The CascadeShadowConfig is re-fit only when the slider changes (not
  per frame, which would defeat the texel snap and crawl every edge).
- Bridged to a benilla-world ShadowDistance resource; global_light packs it into
  the free row-19 light lane so the receiver EDGE FADE fades at the cascade's
  actual max distance, whatever the slider is set to (terrain _wmo_fog[1].z /
  model wmo_fog_params.z). The fixed SHADOW_RANGE consts are gone.
- Options: a deferred slider row (commits on Apply so a drag doesn't re-fit the
  cascade + recollect casters every tick).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
pkuzic
2026-09-05 17:13:49 +01:00
co-authored by Claude Opus 4.8
parent bb610744c7
commit 68f8e31c48
9 changed files with 134 additions and 27 deletions
+23 -2
View File
@@ -687,6 +687,10 @@
.. "following the sun, and switches off the baked terrain shadows they replace. "
.. "Independent of Real Character Shadows. This uses additional GPU time and memory."
BENILLA_TOOLTIP_SHADOW_DISTANCE =
"How far realtime shadows are drawn, in yards. Higher covers more ground but spreads "
.. "the shadow map thinner (softer edges) and costs more. Applies on Okay/Apply."
OPTIONS_PAGE_ROWS = {
Controls = {
"RowStickyTarget", "RowBlockTrades", "RowAutoLoot", "RowInvertMouse",
@@ -699,7 +703,7 @@
},
Graphics = {
"RowUiScale", "RowFarclip", "RowWorldDetail",
"RowDisplayMode", "RowVerticalSync", "RowCharacterShadows", "RowWorldShadows", "RowRenderScale",
"RowDisplayMode", "RowVerticalSync", "RowCharacterShadows", "RowWorldShadows", "RowShadowDistance", "RowRenderScale",
},
Nameplates = {
"RowEnemyPlates", "RowFriendlyPlates",
@@ -2547,11 +2551,28 @@
"BENILLA_TOOLTIP_WORLD_SHADOWS")
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowRenderScale" inherits="OptionsSliderRowTemplate">
<Frame name="$parentRowShadowDistance" inherits="OptionsSliderRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowWorldShadows" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowWorldShadows" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
-- Realtime-shadow render distance (yd): the cascade
-- range + caster reach. 40-200, matching the CVar's
-- own clamp (SHADOW_DISTANCE_RANGE). DEFERRED to
-- Apply so a drag doesn't re-fit the cascade + recollect
-- casters on every tick (one rebuild on commit instead).
OptionsRow_OnLoad(self, "shadowDistance", "Shadow Distance",
"BENILLA_TOOLTIP_SHADOW_DISTANCE")
OptionsSliderRow_Setup(self, 40, 200, 10, "yards")
self.deferred = 1
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowRenderScale" inherits="OptionsSliderRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowShadowDistance" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowShadowDistance" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
-- Render Scale (decision 1639) -- benilla's own
-- row, no 1.12 counterpart: the reference renders
+15 -1
View File
@@ -54,6 +54,7 @@ use crate::ui_loot::LootConfig;
use crate::ui_script::UiScaleCvar;
use crate::video::VideoConfig;
use crate::vplates::VPlateMode;
use crate::shadow_core::SHADOW_DISTANCE_RANGE;
use crate::world_backdrop::{RenderScale, RENDER_SCALE_RANGE};
use benilla_ui::script::UiScript;
use benilla_ui::widget::MINIMAP_ZOOM_LEVELS;
@@ -591,6 +592,14 @@ pub(crate) const REGISTERED: &[Registered] = &[
// players/NPCs/creatures/mounts cast realtime silhouettes instead of the legacy oval blob.
same("worldShadows", "0"),
same("characterShadows", "0"),
// Realtime-shadow render distance in yards (the shadow-map cascade range + caster reach).
// benilla's own — the reference has no realtime shadow to size. Clamped to SHADOW_DISTANCE_RANGE.
ours(
"shadowDistance",
"80",
"1900: benilla's own realtime-shadow render-distance slider; the reference bakes MCSH and \
has no cascade to size",
),
// **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
@@ -1072,6 +1081,10 @@ fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool {
"gxvsync" => knobs.video.vsync = v != 0.0,
"worldshadows" => knobs.video.world_shadows = v != 0.0,
"charactershadows" => knobs.video.character_shadows = v != 0.0,
"shadowdistance" => {
knobs.video.shadow_distance =
v.clamp(*SHADOW_DISTANCE_RANGE.start(), *SHADOW_DISTANCE_RANGE.end());
}
// 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.
@@ -1374,7 +1387,7 @@ fn sync_cvars(
.collect(),
);
let flag = |b: bool| if b { "1" } else { "0" }.to_string();
let session: [(&str, String); 46] = [
let session: [(&str, String); 47] = [
("MasterVolume", sound.master.to_string()),
("SoundVolume", sound.sfx.to_string()),
("MusicVolume", sound.music.to_string()),
@@ -1422,6 +1435,7 @@ fn sync_cvars(
("gxVSync", flag(video.vsync)),
("worldShadows", flag(video.world_shadows)),
("characterShadows", flag(video.character_shadows)),
("shadowDistance", video.shadow_distance.to_string()),
// The reference's polarity: the CVar is `gxWindow`, so `1` is the WINDOWED state.
(
"gxWindow",
+52 -12
View File
@@ -44,21 +44,27 @@ use benilla_assets::materials::WowModelMaterial;
use benilla_formats::ModelBlend;
use benilla_world::billboard::BillboardCard;
use benilla_world::interact::PickMesh;
use benilla_world::lighting::WowLighting;
use benilla_world::lighting::{ShadowDistance, WowLighting};
use benilla_world::model_render::{ModelKind, ModelPart, ShadowOccluder};
use benilla_world::rig_palette::{RigPalettes, RigPart, RigSkin};
use benilla_world::view::WorldCamera;
use crate::char_select::ClientState;
use crate::video::VideoConfig;
/// Layer 31 is deliberately private to this feature. Bevy supports 32 render layers; the normal
/// world is layer 0 and the UI/portrait layers occupy the low numbered slots.
pub(crate) const PLAYER_SHADOW_LAYER: usize = 31;
/// How far from the camera the shadow map RESOLVES (the single cascade's `maximum_distance`).
/// Collection reaches farther than this: a caster's own body may stand outside the cascade
/// while its shadow lands inside it — so an out-of-frustum tree still darkens ground in view.
const CASTER_RANGE: f32 = 80.0;
/// Shipped realtime-shadow render distance in yards — the `shadowDistance` default. How far from the
/// camera the shadow map RESOLVES (the single cascade's `maximum_distance`). Collection reaches
/// farther than this (a caster's body may stand outside the cascade while its shadow lands inside).
pub(crate) const DEFAULT_SHADOW_DISTANCE: f32 = 80.0;
/// The `shadowDistance` slider range (yd). Min keeps a useful near shadow; max is bounded because
/// the fixed 4096 shadow map spreads thinner (softer) over a larger area and the caster-collection
/// cost grows with distance.
pub(crate) const SHADOW_DISTANCE_RANGE: std::ops::RangeInclusive<f32> = 40.0..=200.0;
/// How far the camera may drift before a cached caster-collection pass must refresh. The collection
/// reach carries this as margin so coverage holds everywhere between refreshes.
@@ -98,8 +104,8 @@ fn shadow_reach_extension(sun_travel: Vec3) -> f32 {
/// The collection law for a lane that can contribute a TALL caster (a doodad/WMO exile, or the whole
/// static world): resolve range + rebuild margin + the sun-dependent shadow reach.
pub(crate) fn static_collection_reach(sun_travel: Vec3) -> f32 {
CASTER_RANGE + STATIC_REBUILD_STEP + shadow_reach_extension(sun_travel)
pub(crate) fn static_collection_reach(sun_travel: Vec3, distance: f32) -> f32 {
distance + STATIC_REBUILD_STEP + shadow_reach_extension(sun_travel)
}
/// `WOW_SHADOW_TRACE=1` — log the caster population when it CHANGES, to attribute a vanished shadow
@@ -190,6 +196,10 @@ struct ShadowRigState {
sun_written: Option<Vec3>,
/// The invisible proxy material shared by every lane's SOLID caster.
material: Option<Handle<ShadowCasterMaterial>>,
/// The cascade's currently-applied `maximum_distance` (the `shadowDistance` slider). The
/// `CascadeShadowConfig` is only re-inserted when this changes — rebuilding it every frame would
/// re-fit the cascade and defeat the texel snap (crawling every shadow edge).
cascade_distance: Option<f32>,
}
pub(crate) struct ShadowCorePlugin;
@@ -234,10 +244,14 @@ impl Plugin for ShadowCorePlugin {
#[allow(clippy::too_many_arguments)]
fn manage_rig(
state: Res<State<ClientState>>,
video: Res<VideoConfig>,
lighting: Res<WowLighting>,
mut demand: ResMut<ShadowDemand>,
mut frame: ResMut<ShadowFrame>,
mut rig: ResMut<ShadowRigState>,
// MONKEY (distance slider): bridge the app-side `shadowDistance` to benilla-world so
// `global_light` can pack it for the receivers' edge fade (which must fade at THIS distance).
mut shadow_distance_out: ResMut<ShadowDistance>,
mut commands: Commands,
mut materials: ResMut<Assets<ShadowCasterMaterial>>,
mut cameras: Query<
@@ -248,6 +262,12 @@ fn manage_rig(
cameras_for_position: Query<&GlobalTransform, With<WorldCamera>>,
) {
let in_world = *state.get() == ClientState::InWorld;
// The `shadowDistance` slider (already clamped by the cvar). Publish it to benilla-world every
// frame so the receiver edge fade tracks it.
let distance = video.shadow_distance;
if shadow_distance_out.0 != distance {
shadow_distance_out.0 = distance;
}
// Read the demand the lanes accumulated LAST frame, then reset so they re-accumulate this frame
// (they run after us, in ShadowSet::Lanes). One-frame lag on rig spawn — imperceptible.
let wanted = demand.0 && in_world;
@@ -296,7 +316,7 @@ fn manage_rig(
CascadeShadowConfigBuilder {
num_cascades: 1,
minimum_distance: 0.1,
maximum_distance: CASTER_RANGE,
maximum_distance: distance,
..default()
}
.build(),
@@ -308,6 +328,23 @@ fn manage_rig(
.id();
rig.sun = Some(sun);
rig.sun_written = None;
rig.cascade_distance = Some(distance);
}
// Re-fit the cascade only when the slider changed (not every frame — that would defeat the
// texel snap and crawl every shadow edge).
if rig.cascade_distance != Some(distance) {
if let Some(sun) = rig.sun {
commands.entity(sun).insert(
CascadeShadowConfigBuilder {
num_cascades: 1,
minimum_distance: 0.1,
maximum_distance: distance,
..default()
}
.build(),
);
}
rig.cascade_distance = Some(distance);
}
for (entity, layers, marker) in &mut cameras {
@@ -336,8 +373,8 @@ fn manage_rig(
// MONKEY (moving sun): aim at the VISIBLE celestial sun (rises/sets), clamped in elevation.
let sun_direction = shadow_sun_travel(lighting.celestial_dir());
let entity_reach = CASTER_RANGE + STATIC_REBUILD_STEP;
let tall_reach = static_collection_reach(sun_direction);
let entity_reach = distance + STATIC_REBUILD_STEP;
let tall_reach = static_collection_reach(sun_direction, distance);
if let Some(sun) = rig.sun {
// Only the rotation matters; QUANTISED to hold the cascade texel snap (see SUN_SNAP_RADIANS).
@@ -569,7 +606,7 @@ fn append_skinned(
mod tests {
use super::{
append_triangles, casts_realtime_shadow, shadow_reach_extension, shadow_sun_travel,
static_collection_reach, sun_snap_due, CASTER_RANGE, MAX_CASTER_HEIGHT,
static_collection_reach, sun_snap_due, DEFAULT_SHADOW_DISTANCE, MAX_CASTER_HEIGHT,
MAX_SHADOW_SUN_ELEVATION, MIN_SHADOW_SUN_ELEVATION, SHADOW_REACH_CAP, STATIC_REBUILD_STEP,
SUN_SNAP_RADIANS,
};
@@ -636,7 +673,10 @@ mod tests {
assert!((shadow_reach_extension(diagonal) - MAX_CASTER_HEIGHT).abs() < 1e-3);
assert_eq!(shadow_reach_extension(Vec3::NEG_Y), 0.0);
assert_eq!(shadow_reach_extension(Vec3::NEG_Z), SHADOW_REACH_CAP);
assert!(static_collection_reach(Vec3::NEG_Y) >= CASTER_RANGE + STATIC_REBUILD_STEP);
assert!(
static_collection_reach(Vec3::NEG_Y, DEFAULT_SHADOW_DISTANCE)
>= DEFAULT_SHADOW_DISTANCE + STATIC_REBUILD_STEP
);
}
#[test]
@@ -2186,6 +2186,7 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
),
("BENILLA_TOOLTIP_CHARACTER_SHADOWS", "GraphicsRowCharacterShadows"),
("BENILLA_TOOLTIP_WORLD_SHADOWS", "GraphicsRowWorldShadows"),
("BENILLA_TOOLTIP_SHADOW_DISTANCE", "GraphicsRowShadowDistance"),
];
if let Some((_, want_row)) = BENILLA_OWNED.iter().find(|(k, _)| *k == key) {
assert_eq!(row, *want_row, "{row}: not this row's string");
@@ -2231,8 +2232,9 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
// Enable Sound in Background (1847), the Audio page's fifth checkbox and the one row on that
// page the reference never made settable.
// MONKEY (world shadows): +1 for splitting the old single shadow row into Real Character
// Shadows + World Shadows (both benilla-owned keys, carved out in BENILLA_OWNED above).
assert_eq!(checked, 60, "every tipped row carries a live key");
// Shadows + World Shadows, +1 for the Shadow Distance slider (all benilla-owned keys, carved
// out in BENILLA_OWNED above).
assert_eq!(checked, 61, "every tipped row carries a live key");
assert_eq!(
untipped,
vec![
+7 -1
View File
@@ -212,7 +212,8 @@ pub(crate) fn boot_windowed_size() -> UVec2 {
/// since 0294): the window literal resolves env-then-file for itself, `load_config` applies the
/// file to this resource at `Startup`, and because both read the same key the reconcile is a no-op
/// rather than a mode change one frame into the run.
#[derive(Resource, Clone, Copy, PartialEq, Eq, Debug)]
// NB: no `Eq` — `shadow_distance` is an f32 (only `PartialEq` is needed, for `!=` change detection).
#[derive(Resource, Clone, Copy, PartialEq, Debug)]
pub(crate) struct VideoConfig {
pub(crate) vsync: bool,
/// Whether the STATIC WORLD (trees, buildings, foliage) casts realtime shadows and baked MCSH
@@ -222,6 +223,9 @@ pub(crate) struct VideoConfig {
/// Whether CHARACTERS (players, NPCs, creatures, mounts) cast realtime silhouettes instead of
/// the legacy oval blob. Independent of [`Self::world_shadows`].
pub(crate) character_shadows: bool,
/// 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,
pub(crate) display: DisplayMode,
/// The windowed size, `gxResolution`. Kept while fullscreen so leaving it can restore it.
pub(crate) windowed: UVec2,
@@ -233,6 +237,7 @@ impl Default for VideoConfig {
vsync: !novsync_env(),
world_shadows: false,
character_shadows: false,
shadow_distance: crate::shadow_core::DEFAULT_SHADOW_DISTANCE,
display: if windowed_env() {
DisplayMode::Windowed
} else {
@@ -545,6 +550,7 @@ mod tests {
vsync: true,
world_shadows: false,
character_shadows: false,
shadow_distance: 80.0,
display: DisplayMode::Fullscreen,
windowed: UVec2::new(1024, 768),
})
@@ -41,9 +41,9 @@
// A fully covered character shadow retains 45% of the authored terrain colour.
const SHADOW_SUN_FLOOR: f32 = 0.45;
// MONKEY (edge fade): the realtime shadow lightens toward the cascade's max distance so it fades in
// rather than popping at the resolve boundary. SHADOW_RANGE must match `shadow_core::CASTER_RANGE`.
const SHADOW_RANGE: f32 = 80.0;
// MONKEY (edge fade): the realtime shadow lightens over the last SHADOW_EDGE_BAND yards of the
// cascade's max distance so it fades in rather than popping at the resolve boundary. The distance
// is the `shadowDistance` slider, read live from `_wmo_fog[1].z` (packed by build_light_data).
const SHADOW_EDGE_BAND: f32 = 14.0;
// Per-tile Vec4 uniforms packed into ONE buffer (binding 106) — the field order here MUST match the
@@ -323,7 +323,8 @@ fn fragment(in: TerrainVsOut) -> @location(0) vec4<f32> {
// lighten `world_shadow` toward 1.0 (no shadow). `sun_shadow_strength` is reused for the MCSH
// fade-back below.
let cam_dist = distance(in.world_position.xyz, view.world_position.xyz);
let edge_fade = smoothstep(SHADOW_RANGE - SHADOW_EDGE_BAND, SHADOW_RANGE, cam_dist);
let shadow_range = wow_light._wmo_fog[1].z; // the `shadowDistance` slider (yd)
let edge_fade = smoothstep(shadow_range - SHADOW_EDGE_BAND, shadow_range, cam_dist);
let sun_shadow_strength = wow_light.fog_params.z;
world_shadow = 1.0 - (1.0 - world_shadow) * sun_shadow_strength * (1.0 - edge_fade);
let primary = in.primary;
@@ -115,9 +115,9 @@ struct ModelParams {
// A fully covered character shadow retains 45% of the authored model lighting.
const SHADOW_SUN_FLOOR: f32 = 0.45;
// MONKEY (edge fade): the realtime shadow lightens toward the cascade's max distance so it fades in
// rather than popping at the resolve boundary. SHADOW_RANGE must match `shadow_core::CASTER_RANGE`.
const SHADOW_RANGE: f32 = 80.0;
// MONKEY (edge fade): the realtime shadow lightens over the last SHADOW_EDGE_BAND yards of the
// cascade's max distance so it fades in rather than popping. The distance is the `shadowDistance`
// slider, read live from `wmo_fog_params.z` (packed by build_light_data).
const SHADOW_EDGE_BAND: f32 = 14.0;
// The shared global light (lighting::global_light): ONE storage buffer every material reads, updated
@@ -1025,7 +1025,8 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
// vanish at night. No effect when unshadowed (player_shadow already 1.0) — keeps the
// `worldShadows 0` path byte-identical.
let shadow_cam_dist = distance(in.world_position.xyz, view.world_position.xyz);
let shadow_edge_fade = smoothstep(SHADOW_RANGE - SHADOW_EDGE_BAND, SHADOW_RANGE, shadow_cam_dist);
let shadow_range = wow_light.wmo_fog_params.z; // the `shadowDistance` slider (yd)
let shadow_edge_fade = smoothstep(shadow_range - SHADOW_EDGE_BAND, shadow_range, shadow_cam_dist);
player_shadow = 1.0 - (1.0 - player_shadow) * wow_light.fog_params.z * (1.0 - shadow_edge_fade);
// The realtime map blocks only the directional sun. Preserve the authored ambient/probe
// contribution instead of multiplying the whole lighting result; the latter makes interiors,
@@ -184,6 +184,19 @@ pub struct LightRooms(pub(crate) crate::wmo_portal::WmoGroupVis);
#[derive(Resource, Clone, Copy, Default)]
pub struct WorldShadowActive(pub bool);
/// MONKEY (distance slider): the realtime-shadow render distance in yards (the `shadowDistance`
/// slider), bridged from benilla-app's shadow rig. [`build_light_data`] packs it into a free light
/// lane so the receivers (`terrain.wgsl`/`wow_model.wgsl`) fade the realtime shadow at THIS distance
/// rather than a fixed one — the fade must track the cascade's actual `maximum_distance`.
#[derive(Resource, Clone, Copy)]
pub struct ShadowDistance(pub f32);
impl Default for ShadowDistance {
fn default() -> Self {
Self(80.0)
}
}
/// Main-world resource holding the packed light for this frame; extracted into the render world where
/// [`upload_light`] writes it. Rebuilt every frame by [`build_light_data`] (cheap — one std430 pack).
#[derive(Resource, Clone, Copy, ExtractResource)]
@@ -211,6 +224,7 @@ pub struct SharedLightBuffer(pub Buffer);
pub(super) fn register(app: &mut App) {
app.init_resource::<WowLightData>()
.init_resource::<WorldShadowActive>()
.init_resource::<ShadowDistance>()
.init_resource::<super::prop_probes::PropProbeExtract>()
.add_plugins(ExtractResourcePlugin::<WowLightData>::default())
.add_plugins(ExtractResourcePlugin::<SharedLightBuffer>::default())
@@ -296,6 +310,8 @@ fn build_light_data(
time: Res<Time>,
// MONKEY (world shadows): the `worldShadows` lane flag, packed into `sh_c16.w` for the MCSH gate.
world_shadow: Res<WorldShadowActive>,
// MONKEY (distance slider): the realtime-shadow render distance, packed for the edge fade.
shadow_distance: Res<ShadowDistance>,
mut last_dump: Local<f64>,
mut last_rows_dump: Local<f64>,
) {
@@ -340,6 +356,10 @@ fn build_light_data(
// so shadows soften and vanish at night; the shadow basis is separately clamped to 18° so a low
// sun still casts the right DIRECTION.
rows[5][2] = sun_shadow_strength(l.celestial_dir.y);
// MONKEY (distance slider): the realtime-shadow render distance (yd), packed into the free
// `_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;
// 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
+3 -1
View File
@@ -17,7 +17,9 @@ mod prop_probes; // the per-instance interior-prop SH probe table (slot ↔ Mesh
mod resolve; // the per-frame time-of-day sample into WowLighting + the WMO interior-fog crossfade
mod sh; // the model SH light-probe coefficient math
pub use blob::LightBlob;
pub use global_light::{new_shared_light_buffer, LightRooms, SharedLightBuffer, WorldShadowActive};
pub use global_light::{
new_shared_light_buffer, LightRooms, SharedLightBuffer, ShadowDistance, 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
// — stays in the crate. Off-world producers state values through `LightBlob` and never a row index