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