MONKEY: split shadows into independent character + world lanes
Rework the realtime shadow feature into two INDEPENDENT, plug-and-play modules over one shared rig, plus alpha-tested foliage: - character_shadow.rs (was player_shadow.rs): owns the shared rig — one directional "sun" on private layer 31, the camera-layer plumbing, the shadow map, the invisible proxy material — and the per-frame CHARACTER lane. The rig lights up whenever EITHER lane is on. - world_shadow.rs (NEW, self-contained): the WORLD lane — the retained static_gx world (trees + buildings, cast solid) and alpha-tested foliage (leaf canopies, one caster per leaf texture). Removing the feature is deleting this module + its two calls + the cvar. Two separate cvars, each on its own Options row: `characterShadows` (players/NPCs/creatures/mounts, replacing the oval blob) and `worldShadows` (the static world + foliage, and MCSH terrain-shadow suppression). Either alone drives the shared sun; the per-frame entity caster admits Creature parts under characterShadows and entity-resident environment under worldShadows. Alpha-tested foliage caster (leaf-shaped canopy shadows): a CutoutShadowCasterMaterial (AlphaMode::Mask + an explicit prepass fragment, shadow_caster_cutout_prepass.wgsl) samples the leaf sheet and discards transparent texels in the directional-shadow pass, so a canopy casts its leaves instead of a solid box. Grouped one caster per leaf texture; StaticGx::collect_cutout_shadow_triangles buckets by AssetId and reuses the foliage's own image handle so the sampler inherits the correct clamp mode. MCSH terrain-shadow suppression now keys on the WORLD lane only (packed into the free sh_c16.w light lane via a new WorldShadowActive resource), not on the mere presence of a shadow sun — so character-only shadows leave the world's baked shadows intact. blob_shadow now hides the oval on characterShadows, not worldShadows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
50771c7c05
commit
3c33fd7117
@@ -677,10 +677,15 @@
|
||||
.. "There is no exclusive fullscreen mode. WoW itself removed one in 8.0.1, and "
|
||||
.. "modern Classic offers these same two."
|
||||
|
||||
BENILLA_TOOLTIP_CHARACTER_SHADOWS =
|
||||
"Renders real dynamic silhouettes for players, NPCs, creatures, and mounts, replacing "
|
||||
.. "the legacy oval blob. Matches Wrath's character-only shadow tier. This uses "
|
||||
.. "additional GPU time and memory."
|
||||
|
||||
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."
|
||||
"Casts real dynamic shadows from the static world (trees, buildings, and foliage) "
|
||||
.. "following the sun, and switches off the baked terrain shadows they replace. "
|
||||
.. "Independent of Real Character Shadows. This uses additional GPU time and memory."
|
||||
|
||||
OPTIONS_PAGE_ROWS = {
|
||||
Controls = {
|
||||
@@ -694,7 +699,7 @@
|
||||
},
|
||||
Graphics = {
|
||||
"RowUiScale", "RowFarclip", "RowWorldDetail",
|
||||
"RowDisplayMode", "RowVerticalSync", "RowWorldShadows", "RowRenderScale",
|
||||
"RowDisplayMode", "RowVerticalSync", "RowCharacterShadows", "RowWorldShadows", "RowRenderScale",
|
||||
},
|
||||
Nameplates = {
|
||||
"RowEnemyPlates", "RowFriendlyPlates",
|
||||
@@ -2522,13 +2527,23 @@
|
||||
"OPTION_TOOLTIP_VERTICAL_SYNC")
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<Frame name="$parentRowWorldShadows" inherits="OptionsCheckboxRowTemplate">
|
||||
<Frame name="$parentRowCharacterShadows" 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",
|
||||
OptionsRow_OnLoad(self, "characterShadows", "Real Character Shadows",
|
||||
"BENILLA_TOOLTIP_CHARACTER_SHADOWS")
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
<Frame name="$parentRowWorldShadows" inherits="OptionsCheckboxRowTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parentRowCharacterShadows" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
<Anchor point="TOPRIGHT" relativeTo="$parentRowCharacterShadows" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>
|
||||
OptionsRow_OnLoad(self, "worldShadows", "World Shadows",
|
||||
"BENILLA_TOOLTIP_WORLD_SHADOWS")
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
|
||||
@@ -220,12 +220,14 @@ 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 {
|
||||
// `characterShadows 1`: the realtime shadow-map path owns UNIT shadows — every record hides so a
|
||||
// unit never wears the oval underneath its cast silhouette. (This keys on the CHARACTER lane, not
|
||||
// the world lane: the oval is a character's shadow, so `worldShadows` alone must leave it be.)
|
||||
// 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.character_shadows {
|
||||
for (_, mut key, mut verts) in &mut shadows {
|
||||
if key.shown || !verts.0.is_empty() {
|
||||
hide(&mut key, &mut verts);
|
||||
|
||||
+275
-120
@@ -1,19 +1,32 @@
|
||||
//! Isolated real shadow-map support for the world.
|
||||
//! Real shadow-map support for benilla, split into two INDEPENDENT lanes over ONE shared rig.
|
||||
//!
|
||||
//! 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.
|
||||
//! This module owns the shared **rig** — a single directional light (the "sun") on a private render
|
||||
//! layer, that layer's membership on the world camera, and the `DirectionalLightShadowMap` — plus
|
||||
//! the **character lane**: the per-frame CPU-built caster carrying entity geometry (players, NPCs,
|
||||
//! creatures, mounts). The sibling [`super::world_shadow`] module owns the **world lane** (the
|
||||
//! static `static_gx` trees/buildings + alpha-tested foliage). The two lanes are gated by SEPARATE
|
||||
//! cvars — `characterShadows` and `worldShadows` — and either one alone lights up the shared rig.
|
||||
//!
|
||||
//! The rig 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, the source of the pipeline
|
||||
//! validation/corruption failure seen during the first experiment. Instead a private layer holds
|
||||
//! CPU-built copies of the caster triangles; only those copies cast, and the normal world shaders
|
||||
//! merely RECEIVE the resulting shadow lookup.
|
||||
//!
|
||||
//! Why one shared light and not one per lane: the custom receiver in `terrain.wgsl`/`wow_model.wgsl`
|
||||
//! samples a SINGLE directional light (it assigns, not accumulates, over the light loop). Two lights
|
||||
//! would double the shadow-pass cost and the last one would win the receiver. So the lanes share the
|
||||
//! rig and each simply contributes its own caster geometry into the one map.
|
||||
|
||||
use bevy::asset::RenderAssetUsages;
|
||||
use bevy::asset::{AssetId, RenderAssetUsages};
|
||||
use bevy::camera::visibility::{NoFrustumCulling, RenderLayers};
|
||||
use bevy::image::Image;
|
||||
use bevy::light::{
|
||||
CascadeShadowConfigBuilder, DirectionalLight, DirectionalLightShadowMap, NotShadowReceiver,
|
||||
};
|
||||
use bevy::mesh::{Indices, PrimitiveTopology};
|
||||
use bevy::pbr::{Material, MaterialPipeline, MaterialPlugin, MeshMaterial3d};
|
||||
use bevy::platform::collections::HashMap;
|
||||
use bevy::prelude::*;
|
||||
use bevy::reflect::TypePath;
|
||||
use bevy::render::render_resource::{
|
||||
@@ -26,13 +39,14 @@ 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::{WorldShadowActive, 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;
|
||||
use crate::world_shadow;
|
||||
|
||||
/// 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.
|
||||
@@ -47,7 +61,7 @@ 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;
|
||||
pub(crate) 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
|
||||
@@ -75,8 +89,8 @@ fn shadow_reach_extension(sun_travel: Vec3) -> f32 {
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// exile, or the whole static world): 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)
|
||||
}
|
||||
@@ -86,7 +100,7 @@ fn static_collection_reach(sun_travel: Vec3) -> f32 {
|
||||
/// 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 {
|
||||
pub(crate) 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"))
|
||||
}
|
||||
@@ -119,7 +133,7 @@ fn sun_snap_due(held: Option<Vec3>, live: Vec3) -> bool {
|
||||
}
|
||||
|
||||
#[derive(Component)]
|
||||
struct WorldShadowCaster;
|
||||
pub(crate) struct WorldShadowCaster;
|
||||
|
||||
#[derive(Component)]
|
||||
struct WorldShadowSun;
|
||||
@@ -129,32 +143,44 @@ struct WorldShadowCameraLayer {
|
||||
had_layers: bool,
|
||||
}
|
||||
|
||||
/// Shared state for both shadow lanes. The rig + character fields are private to this module; the
|
||||
/// world-lane fields are `pub(crate)` because [`super::world_shadow`] owns their lifecycle.
|
||||
#[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>,
|
||||
pub(crate) struct ShadowRuntime {
|
||||
// --- shared rig ---
|
||||
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.
|
||||
/// 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>>,
|
||||
/// The invisible proxy material shared by BOTH lanes' solid casters (opaque to the shadow pass,
|
||||
/// discarded in the forward pass).
|
||||
material: Option<Handle<ShadowCasterMaterial>>,
|
||||
// --- character lane (per-frame entity caster) ---
|
||||
/// The PER-FRAME caster: entity parts (animated rigs, creatures, and — when `worldShadows` is
|
||||
/// on — entity-resident environment doodads/gameobjects) collected fresh every frame.
|
||||
dynamic_caster: Option<Entity>,
|
||||
dynamic_mesh: Option<Handle<Mesh>>,
|
||||
/// [`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),
|
||||
// --- world lane (owned by `super::world_shadow`) ---
|
||||
/// The CACHED static caster — the retained `static_gx` world (trees + buildings), rebuilt only
|
||||
/// on camera drift.
|
||||
pub(crate) static_caster: Option<Entity>,
|
||||
pub(crate) static_mesh: Option<Handle<Mesh>>,
|
||||
pub(crate) static_rebuilt_at: Option<Vec3>,
|
||||
/// The alpha-tested foliage casters — one per distinct leaf texture, keyed by its `AssetId`.
|
||||
pub(crate) static_cutout: HashMap<AssetId<Image>, CutoutCaster>,
|
||||
}
|
||||
|
||||
pub(crate) struct WorldShadowPlugin;
|
||||
pub(crate) struct ShadowPlugin;
|
||||
|
||||
/// 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.
|
||||
/// world in the main camera. Shared by both lanes' SOLID casters.
|
||||
#[derive(Asset, AsBindGroup, TypePath, Debug, Clone, Default)]
|
||||
struct ShadowCasterMaterial {}
|
||||
pub(crate) struct ShadowCasterMaterial {}
|
||||
|
||||
impl Material for ShadowCasterMaterial {
|
||||
fn fragment_shader() -> ShaderRef {
|
||||
@@ -174,23 +200,86 @@ impl Material for ShadowCasterMaterial {
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
/// The alpha-tested twin of [`ShadowCasterMaterial`]: the same invisible-in-forward proxy, but its
|
||||
/// prepass fragment samples a leaf sheet and discards transparent texels so cutout FOLIAGE (tree
|
||||
/// canopies) casts a leaf-shaped silhouette instead of the solid box a positions-only proxy throws.
|
||||
/// One instance per distinct leaf texture — a merged caster mesh carries only one sheet. Used only
|
||||
/// by the WORLD lane ([`super::world_shadow`]).
|
||||
///
|
||||
/// `AlphaMode::Mask` sets Bevy's `MAY_DISCARD` pipeline key, and the EXPLICIT (non-`Default`)
|
||||
/// `prepass_fragment_shader` is what makes the otherwise depth-only directional-light SHADOW pass
|
||||
/// run a fragment at all — together they are the exact pair Bevy's prepass specializer requires to
|
||||
/// alpha-test in the shadow map (`prepass/mod.rs`: `MAY_DISCARD && get_shader(PrepassFragmentShader)`).
|
||||
#[derive(Asset, AsBindGroup, TypePath, Debug, Clone)]
|
||||
pub(crate) struct CutoutShadowCasterMaterial {
|
||||
/// The leaf sheet, bound at material binding 0/1. Reusing the SAME image handle the forward
|
||||
/// pass draws makes the sampler inherit that image's clamp/repeat address mode, so the shadow
|
||||
/// silhouette matches the visible foliage (a cutout card's out-of-range UVs must clamp, not
|
||||
/// wrap, or the transparent margin folds back into the opaque middle and it casts solid).
|
||||
#[texture(0)]
|
||||
#[sampler(1)]
|
||||
pub(crate) leaf: Handle<Image>,
|
||||
}
|
||||
|
||||
impl Material for CutoutShadowCasterMaterial {
|
||||
fn fragment_shader() -> ShaderRef {
|
||||
// Forward pass: still fully invisible — the shared proxy fragment discards every fragment.
|
||||
"embedded://benilla_app/shaders/shadow_caster.wgsl".into()
|
||||
}
|
||||
|
||||
fn prepass_fragment_shader() -> ShaderRef {
|
||||
// EXPLICIT → the shadow pass runs this fragment for the MAY_DISCARD material and alpha-tests.
|
||||
"embedded://benilla_app/shaders/shadow_caster_cutout_prepass.wgsl".into()
|
||||
}
|
||||
|
||||
fn alpha_mode(&self) -> AlphaMode {
|
||||
AlphaMode::Mask(benilla_assets::materials::VANILLA_ALPHA_KEY_REF)
|
||||
}
|
||||
|
||||
fn specialize(
|
||||
_pipeline: &MaterialPipeline,
|
||||
descriptor: &mut RenderPipelineDescriptor,
|
||||
_layout: &bevy::mesh::MeshVertexBufferLayoutRef,
|
||||
_key: bevy::pbr::MaterialPipelineKey<Self>,
|
||||
) -> Result<(), SpecializedMeshPipelineError> {
|
||||
// Two-sided like the solid caster — a canopy card seen edge-on must still occlude. Applies
|
||||
// to the shadow (prepass) pipeline too: Bevy runs `specialize` for the prepass specializer.
|
||||
descriptor.primitive.cull_mode = None;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn update_world_shadow(
|
||||
/// One live alpha-tested foliage caster: its layer-31 entity, its per-texture caster mesh, and the
|
||||
/// [`CutoutShadowCasterMaterial`] carrying that one leaf sheet. Keyed by the leaf texture's
|
||||
/// `AssetId` in [`ShadowRuntime::static_cutout`] so it persists across rebuilds.
|
||||
pub(crate) struct CutoutCaster {
|
||||
pub(crate) entity: Entity,
|
||||
pub(crate) mesh: Handle<Mesh>,
|
||||
pub(crate) material: Handle<CutoutShadowCasterMaterial>,
|
||||
}
|
||||
|
||||
impl Plugin for ShadowPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.insert_resource(DirectionalLightShadowMap { size: 4096 }) // MONKEY: crisper shadow edges
|
||||
.add_plugins(MaterialPlugin::<ShadowCasterMaterial>::default())
|
||||
// MONKEY (world shadows): the alpha-tested foliage caster material (leaf-shaped canopy shadows).
|
||||
.add_plugins(MaterialPlugin::<CutoutShadowCasterMaterial>::default())
|
||||
.init_resource::<ShadowRuntime>()
|
||||
.add_systems(Last, update_shadows);
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn update_shadows(
|
||||
state: Res<State<ClientState>>,
|
||||
video: Res<VideoConfig>,
|
||||
lighting: Res<WowLighting>,
|
||||
mut runtime: ResMut<WorldShadowRuntime>,
|
||||
mut runtime: ResMut<ShadowRuntime>,
|
||||
mut commands: Commands,
|
||||
mut meshes: ResMut<Assets<Mesh>>,
|
||||
mut materials: ResMut<Assets<ShadowCasterMaterial>>,
|
||||
// MONKEY (world shadows): the per-leaf-texture alpha-tested foliage caster materials.
|
||||
mut cutout_materials: ResMut<Assets<CutoutShadowCasterMaterial>>,
|
||||
parts: Query<
|
||||
(
|
||||
&PickMesh,
|
||||
@@ -214,9 +303,24 @@ fn update_world_shadow(
|
||||
>,
|
||||
mut suns: Query<&mut Transform, With<WorldShadowSun>>,
|
||||
cameras_for_position: Query<&GlobalTransform, With<WorldCamera>>,
|
||||
// MONKEY (world shadows): the retained static world — the source of the cached static caster.
|
||||
// `Option` because the resource exists only when the retained pass is armed (`WOW_STATIC_GX`).
|
||||
gx: Option<Res<benilla_world::static_gx::StaticGx>>,
|
||||
// MONKEY (world shadows): the terrain MCSH-suppression flag — driven by the WORLD lane only.
|
||||
mut world_active: ResMut<WorldShadowActive>,
|
||||
) {
|
||||
let wanted = video.world_shadows && *state.get() == ClientState::InWorld;
|
||||
if !wanted {
|
||||
let in_world = *state.get() == ClientState::InWorld;
|
||||
let world_on = video.world_shadows && in_world;
|
||||
let char_on = video.character_shadows && in_world;
|
||||
|
||||
// Baked MCSH terrain shadows switch off ONLY for the world lane (a character-only shadow sun
|
||||
// must leave the world's baked shadows intact). `terrain.wgsl` reads this via `sh_c16.w`.
|
||||
if world_active.0 != world_on {
|
||||
world_active.0 = world_on;
|
||||
}
|
||||
|
||||
if !world_on && !char_on {
|
||||
// Both lanes dark: tear the whole rig down.
|
||||
if let Some(entity) = runtime.dynamic_caster.take() {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
@@ -229,6 +333,7 @@ fn update_world_shadow(
|
||||
if let Some(handle) = runtime.dynamic_mesh.take() {
|
||||
meshes.remove(handle.id());
|
||||
}
|
||||
world_shadow::teardown(&mut runtime, &mut commands, &mut meshes, &mut cutout_materials);
|
||||
if let Some(handle) = runtime.material.take() {
|
||||
materials.remove(handle.id());
|
||||
}
|
||||
@@ -246,27 +351,11 @@ fn update_world_shadow(
|
||||
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);
|
||||
// --- Shared rig: the proxy material + the sun, alive whenever EITHER lane is on ---
|
||||
if runtime.material.is_none() {
|
||||
runtime.material = Some(materials.add(ShadowCasterMaterial {}));
|
||||
}
|
||||
if runtime.sun.is_none() {
|
||||
let sun = commands
|
||||
.spawn((
|
||||
DirectionalLight {
|
||||
@@ -276,18 +365,14 @@ fn update_world_shadow(
|
||||
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`.
|
||||
// A short-range shadow feature, not a whole-map sun-shadow system. With ONE
|
||||
// cascade, Bevy ignores `first_cascade_far_bound`/`overlap_proportion` and the
|
||||
// single map spans the full `maximum_distance`.
|
||||
num_cascades: 1,
|
||||
minimum_distance: 0.1,
|
||||
maximum_distance: CASTER_RANGE,
|
||||
@@ -300,10 +385,8 @@ fn update_world_shadow(
|
||||
WorldShadowSun,
|
||||
))
|
||||
.id();
|
||||
runtime.dynamic_caster = Some(dynamic_caster);
|
||||
runtime.sun = Some(sun);
|
||||
runtime.dynamic_mesh = Some(dynamic_mesh);
|
||||
runtime.material = Some(material);
|
||||
runtime.sun_written = None; // aim it on the first frame
|
||||
}
|
||||
|
||||
for (entity, layers, marker) in &mut cameras {
|
||||
@@ -335,25 +418,37 @@ fn update_world_shadow(
|
||||
} 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.
|
||||
// The two gating radii: short casters (creatures/gameobjects) stop mattering just past the
|
||||
// resolve range; a tall-caster lane (doodads/WMO/static world) carries the sun-dependent
|
||||
// shadow reach on top, so a tree whose shadow lands in view casts before its trunk enters the
|
||||
// resolve range.
|
||||
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.
|
||||
// --- CHARACTER lane (+ entity-resident environment when the world lane is on) ---
|
||||
// The per-frame entity caster always exists past the early-out; the ADMIT filter decides what
|
||||
// rides it: Creature parts when `characterShadows`, entity-resident doodads/gameobjects when
|
||||
// `worldShadows`. Animated rigs re-skin every frame, and entity content spawns/despawns under
|
||||
// lifecycles this module cannot cheaply observe — so it is collected fresh every frame.
|
||||
if runtime.dynamic_caster.is_none() {
|
||||
let mesh = meshes.add(empty_shadow_mesh());
|
||||
let material = runtime
|
||||
.material
|
||||
.clone()
|
||||
.expect("rig material ensured above");
|
||||
let entity = spawn_solid_caster(&mut commands, mesh.clone(), material);
|
||||
runtime.dynamic_caster = Some(entity);
|
||||
runtime.dynamic_mesh = Some(mesh);
|
||||
}
|
||||
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(
|
||||
let (admitted, rejected) = collect_entity_geometry(
|
||||
&parts,
|
||||
&rigs,
|
||||
&palettes,
|
||||
char_on,
|
||||
world_on,
|
||||
light_position,
|
||||
entity_reach,
|
||||
tall_reach,
|
||||
@@ -375,13 +470,27 @@ fn update_world_shadow(
|
||||
}
|
||||
}
|
||||
|
||||
// --- WORLD lane: the cached static world (trees + buildings) + alpha-tested foliage ---
|
||||
if world_on {
|
||||
let solid = runtime.material.clone();
|
||||
if let (Some(gx), Some(solid)) = (gx.as_ref(), solid) {
|
||||
world_shadow::update(
|
||||
&mut runtime,
|
||||
&mut commands,
|
||||
&mut meshes,
|
||||
&solid,
|
||||
&mut cutout_materials,
|
||||
gx,
|
||||
light_position,
|
||||
tall_reach,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
world_shadow::teardown(&mut runtime, &mut commands, &mut meshes, &mut cutout_materials);
|
||||
}
|
||||
|
||||
// --- Aim the shared sun (quantised — see SUN_SNAP_RADIANS) ---
|
||||
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);
|
||||
@@ -391,6 +500,27 @@ fn update_world_shadow(
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn a SOLID caster entity on the private shadow layer with the shared proxy material. Used by
|
||||
/// both the character lane (its per-frame caster) and the world lane (its cached static caster).
|
||||
pub(crate) fn spawn_solid_caster(
|
||||
commands: &mut Commands,
|
||||
mesh: Handle<Mesh>,
|
||||
material: Handle<ShadowCasterMaterial>,
|
||||
) -> Entity {
|
||||
commands
|
||||
.spawn((
|
||||
Mesh3d(mesh),
|
||||
MeshMaterial3d(material),
|
||||
Transform::IDENTITY,
|
||||
Visibility::Visible,
|
||||
RenderLayers::layer(PLAYER_SHADOW_LAYER),
|
||||
NoFrustumCulling,
|
||||
NotShadowReceiver,
|
||||
WorldShadowCaster,
|
||||
))
|
||||
.id()
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -405,7 +535,7 @@ fn append_triangles(source: &[u32], vertex_count: u32, base: u32, indices: &mut
|
||||
/// 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>) {
|
||||
pub(crate) 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(),
|
||||
@@ -419,12 +549,12 @@ fn take_mesh_buffers(mesh: &mut Mesh) -> (Vec<[f32; 3]>, Vec<u32>) {
|
||||
(positions, indices)
|
||||
}
|
||||
|
||||
fn restore_mesh_buffers(mesh: &mut Mesh, positions: Vec<[f32; 3]>, indices: Vec<u32>) {
|
||||
pub(crate) 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 {
|
||||
pub(crate) fn empty_shadow_mesh() -> Mesh {
|
||||
let mut mesh = Mesh::new(
|
||||
PrimitiveTopology::TriangleList,
|
||||
RenderAssetUsages::default(),
|
||||
@@ -434,7 +564,23 @@ fn empty_shadow_mesh() -> Mesh {
|
||||
mesh
|
||||
}
|
||||
|
||||
fn collect_world_geometry(
|
||||
/// A cutout caster mesh: like [`empty_shadow_mesh`] but carrying a `UV_0` lane, because the
|
||||
/// alpha-tested prepass fragment samples the leaf sheet at these UVs. The mesh MUST declare UV_0
|
||||
/// (even empty) so Bevy's prepass specializer sets the `VERTEX_UVS_A` shader-def and the fragment's
|
||||
/// `in.uv` exists — without it the cutout discard is compiled out and foliage casts solid again.
|
||||
pub(crate) fn empty_cutout_mesh() -> Mesh {
|
||||
let mut mesh = Mesh::new(
|
||||
PrimitiveTopology::TriangleList,
|
||||
RenderAssetUsages::default(),
|
||||
);
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, Vec::<[f32; 3]>::new());
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, Vec::<[f32; 2]>::new());
|
||||
mesh.insert_indices(Indices::U32(Vec::new()));
|
||||
mesh
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn collect_entity_geometry(
|
||||
parts: &Query<
|
||||
(
|
||||
&PickMesh,
|
||||
@@ -448,6 +594,8 @@ fn collect_world_geometry(
|
||||
>,
|
||||
rigs: &Query<&RigSkin>,
|
||||
palettes: &RigPalettes,
|
||||
char_on: bool,
|
||||
world_on: bool,
|
||||
light_position: Vec3,
|
||||
creature_reach: f32,
|
||||
doodad_reach: f32,
|
||||
@@ -456,7 +604,7 @@ fn collect_world_geometry(
|
||||
) -> (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) {
|
||||
if !casts_realtime_shadow(part.kind, part.blend, char_on, world_on) {
|
||||
continue;
|
||||
}
|
||||
// NOT `InheritedVisibility`: that folds the exterior window gate, the portal PVS and
|
||||
@@ -469,10 +617,8 @@ fn collect_world_geometry(
|
||||
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.
|
||||
// a part beyond its lane's reach contributes nothing. 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,
|
||||
@@ -509,12 +655,21 @@ fn collect_world_geometry(
|
||||
(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)
|
||||
/// The per-frame entity caster's admit law. CHARACTERS (creatures/players/mounts) ride the
|
||||
/// `characterShadows` lane; entity-resident ENVIRONMENT (gameobjects, distance-faded doodads, WMO
|
||||
/// props) rides the `worldShadows` lane — so a chest or a fading tree casts with the world, not
|
||||
/// with characters. Only the BLEND gate additionally excludes additive/transparent geometry.
|
||||
fn casts_realtime_shadow(kind: ModelKind, blend: ModelBlend, char_on: bool, world_on: bool) -> bool {
|
||||
match kind {
|
||||
// Characters: opaque + alpha-test — hair/fringe cards are small, so casting them solid
|
||||
// doesn't produce the ugly canopy-box artifact (and dropping them would lose hair shadows).
|
||||
ModelKind::Creature => char_on && matches!(blend, ModelBlend::Opaque | ModelBlend::AlphaTest),
|
||||
// Entity-resident environment: OPAQUE only. A foliage leaf-card cast solid becomes a box on
|
||||
// the ground (this per-frame caster has no texture to alpha-test); cutout foliage in the
|
||||
// retained world is cast leaf-shaped by the WORLD lane's `CutoutShadowCasterMaterial`, and
|
||||
// entity-resident cutout (a doodad mid distance-fade) waits for that path to extend here.
|
||||
_ => world_on && matches!(blend, ModelBlend::Opaque),
|
||||
}
|
||||
}
|
||||
|
||||
fn append_skinned(
|
||||
@@ -567,24 +722,25 @@ mod tests {
|
||||
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
|
||||
));
|
||||
|
||||
fn the_two_lanes_admit_independently() {
|
||||
// Characters ride the CHARACTER lane (opaque + alpha-test); entity environment rides the
|
||||
// WORLD lane (opaque only). Nothing casts Blend on either lane, and a lane that is off
|
||||
// admits nothing of its kind.
|
||||
// Both lanes on:
|
||||
assert!(casts_realtime_shadow(ModelKind::Creature, ModelBlend::Opaque, true, true));
|
||||
assert!(casts_realtime_shadow(ModelKind::Creature, ModelBlend::AlphaTest, true, true));
|
||||
assert!(!casts_realtime_shadow(ModelKind::Creature, ModelBlend::Blend, true, true));
|
||||
for kind in [ModelKind::GameObject, ModelKind::Doodad, ModelKind::Wmo] {
|
||||
assert!(!casts_realtime_shadow(kind, ModelBlend::Opaque));
|
||||
assert!(!casts_realtime_shadow(kind, ModelBlend::AlphaTest));
|
||||
assert!(casts_realtime_shadow(kind, ModelBlend::Opaque, true, true));
|
||||
assert!(!casts_realtime_shadow(kind, ModelBlend::AlphaTest, true, true));
|
||||
assert!(!casts_realtime_shadow(kind, ModelBlend::Blend, true, true));
|
||||
}
|
||||
// Character lane OFF → creatures cast nothing; the world lane still casts environment.
|
||||
assert!(!casts_realtime_shadow(ModelKind::Creature, ModelBlend::Opaque, false, true));
|
||||
assert!(casts_realtime_shadow(ModelKind::Doodad, ModelBlend::Opaque, false, true));
|
||||
// World lane OFF → environment casts nothing; the character lane still casts creatures.
|
||||
assert!(!casts_realtime_shadow(ModelKind::Doodad, ModelBlend::Opaque, true, false));
|
||||
assert!(casts_realtime_shadow(ModelKind::Creature, ModelBlend::Opaque, true, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -620,15 +776,14 @@ mod tests {
|
||||
// 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.
|
||||
// The night lighting sun (~20° elevation): ~2.7× height, still under the cap.
|
||||
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.
|
||||
// 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.
|
||||
@@ -585,9 +585,12 @@ 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.
|
||||
// Benilla's opt-in realtime shadow-map path, split into two INDEPENDENT lanes over one shared
|
||||
// shadow rig (one sun / one map). `worldShadows` = the static world (trees, buildings, foliage)
|
||||
// casts realtime shadows and baked MCSH terrain shadows switch off; `characterShadows` =
|
||||
// players/NPCs/creatures/mounts cast realtime silhouettes instead of the legacy oval blob.
|
||||
same("worldShadows", "0"),
|
||||
same("characterShadows", "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
|
||||
@@ -1068,6 +1071,7 @@ fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool {
|
||||
// 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,
|
||||
"charactershadows" => knobs.video.character_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.
|
||||
@@ -1370,7 +1374,7 @@ fn sync_cvars(
|
||||
.collect(),
|
||||
);
|
||||
let flag = |b: bool| if b { "1" } else { "0" }.to_string();
|
||||
let session: [(&str, String); 45] = [
|
||||
let session: [(&str, String); 46] = [
|
||||
("MasterVolume", sound.master.to_string()),
|
||||
("SoundVolume", sound.sfx.to_string()),
|
||||
("MusicVolume", sound.music.to_string()),
|
||||
@@ -1417,6 +1421,7 @@ fn sync_cvars(
|
||||
("minimapInsideZoom", minimap.inside.to_string()),
|
||||
("gxVSync", flag(video.vsync)),
|
||||
("worldShadows", flag(video.world_shadows)),
|
||||
("characterShadows", flag(video.character_shadows)),
|
||||
// The reference's polarity: the CVar is `gxWindow`, so `1` is the WINDOWED state.
|
||||
(
|
||||
"gxWindow",
|
||||
@@ -1830,6 +1835,10 @@ mod tests {
|
||||
d["worldShadows"] != 0.0,
|
||||
VideoConfig::default().world_shadows
|
||||
);
|
||||
assert_eq!(
|
||||
d["characterShadows"] != 0.0,
|
||||
VideoConfig::default().character_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
|
||||
|
||||
@@ -91,9 +91,9 @@ mod npc_text;
|
||||
mod pending_item_ops;
|
||||
#[cfg(feature = "dev")]
|
||||
mod perf;
|
||||
mod character_shadow;
|
||||
mod pipe_warm;
|
||||
mod player;
|
||||
mod player_shadow;
|
||||
mod poi_marker;
|
||||
mod portrait;
|
||||
#[cfg(feature = "dev")]
|
||||
@@ -181,6 +181,7 @@ mod ui_world_map;
|
||||
mod video;
|
||||
mod vplates;
|
||||
mod world_backdrop;
|
||||
mod world_shadow;
|
||||
mod world_state;
|
||||
mod world_state_ui;
|
||||
|
||||
@@ -616,7 +617,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)
|
||||
.add_plugins(character_shadow::ShadowPlugin)
|
||||
// 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)
|
||||
|
||||
@@ -25,6 +25,7 @@ pub(crate) fn plugin(app: &mut App) {
|
||||
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");
|
||||
bevy::asset::embedded_asset!(app, "shaders/shadow_caster_cutout_prepass.wgsl");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -60,6 +61,6 @@ mod tests {
|
||||
`shaders::plugin`, or was registered from a file that is not directly under src/"
|
||||
);
|
||||
}
|
||||
assert_eq!(found, 6, "the game's shader set changed size");
|
||||
assert_eq!(found, 7, "the game's shader set changed size");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// MONKEY (world shadows): alpha-tested foliage shadow caster — the PREPASS fragment.
|
||||
//
|
||||
// The proxy caster is invisible in the camera's forward pass (its forward fragment `discard`s every
|
||||
// fragment — `shadow_caster.wgsl`). Its material is `AlphaMode::Mask`, which sets Bevy's
|
||||
// `MAY_DISCARD` pipeline key; paired with this EXPLICIT prepass fragment shader that decides Bevy
|
||||
// runs a fragment during the directional-light SHADOW pass (which is otherwise depth-only). So a
|
||||
// leaf card can discard its transparent texels and cast a leaf-SHAPED silhouette instead of the
|
||||
// solid rectangular box a positions-only proxy throws.
|
||||
//
|
||||
// The leaf sheet is bound as the material's own texture at group `MATERIAL_BIND_GROUP` — the
|
||||
// prepass material slot (index 3; substituted by the prepass pipeline). Sampling the SAME image the
|
||||
// forward pass draws keeps the clamp/repeat address mode identical, so the shadow silhouette
|
||||
// matches the visible foliage exactly (a cutout card's UVs run outside 0..1 into the transparent
|
||||
// border — sampled with the wrong wrap mode the margin wraps back into the opaque middle and the
|
||||
// card casts solid again).
|
||||
//
|
||||
// Structure mirrors Bevy's own `pbr_prepass.wgsl`: a `FragmentOutput`-returning entry under
|
||||
// `PREPASS_FRAGMENT` (the unclipped-depth-emulation path for GPUs without `depth_clip_control`) and
|
||||
// a void entry otherwise (the common desktop depth-only path); both run the same cutout discard.
|
||||
|
||||
#import bevy_pbr::prepass_io
|
||||
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(0) var leaf_texture: texture_2d<f32>;
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(1) var leaf_sampler: sampler;
|
||||
|
||||
// WoW ≤ WotLK alpha-test key (`benilla_assets::materials::VANILLA_ALPHA_KEY_REF` = 224/255): the
|
||||
// value below which a cutout fragment is discarded. The exact ref the forward cutout pass uses.
|
||||
const VANILLA_ALPHA_KEY: f32 = 0.8784314;
|
||||
|
||||
// Sample the leaf sheet at the interpolated UV and discard transparent texels, exactly as the
|
||||
// forward cutout does. Sampled BEFORE any `discard` so the texture access stays in uniform control
|
||||
// flow. A caster mesh always carries `UV_0` (`VERTEX_UVS_A`); lacking it, nothing discards (the
|
||||
// card falls back to a solid cast rather than vanishing).
|
||||
fn cutout_discard(in: prepass_io::VertexOutput) {
|
||||
#ifdef VERTEX_UVS_A
|
||||
let alpha = textureSample(leaf_texture, leaf_sampler, in.uv).a;
|
||||
if alpha < VANILLA_ALPHA_KEY {
|
||||
discard;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef PREPASS_FRAGMENT
|
||||
// A GPU without `depth_clip_control`: Bevy emulates the directional-shadow view's unclipped depth
|
||||
// by writing `frag_depth` in the fragment (`prepass/mod.rs`, `UNCLIPPED_DEPTH_ORTHO_EMULATION`).
|
||||
// Mirror the default prepass fragment's single field so that depth-clamp fix still applies here.
|
||||
@fragment
|
||||
fn fragment(in: prepass_io::VertexOutput) -> prepass_io::FragmentOutput {
|
||||
cutout_discard(in);
|
||||
var out: prepass_io::FragmentOutput;
|
||||
#ifdef UNCLIPPED_DEPTH_ORTHO_EMULATION
|
||||
out.frag_depth = in.unclipped_depth;
|
||||
#endif // UNCLIPPED_DEPTH_ORTHO_EMULATION
|
||||
return out;
|
||||
}
|
||||
#else
|
||||
// The common desktop path: the shadow pass is depth-only (no color targets), so the fragment
|
||||
// returns nothing and exists ONLY to run the cutout discard against the depth write.
|
||||
@fragment
|
||||
fn fragment(in: prepass_io::VertexOutput) {
|
||||
cutout_discard(in);
|
||||
}
|
||||
#endif // PREPASS_FRAGMENT
|
||||
@@ -2184,6 +2184,7 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
|
||||
"BENILLA_TOOLTIP_BACKGROUND_SOUND",
|
||||
"AudioRowBackgroundSound",
|
||||
),
|
||||
("BENILLA_TOOLTIP_CHARACTER_SHADOWS", "GraphicsRowCharacterShadows"),
|
||||
("BENILLA_TOOLTIP_WORLD_SHADOWS", "GraphicsRowWorldShadows"),
|
||||
];
|
||||
if let Some((_, want_row)) = BENILLA_OWNED.iter().find(|(k, _)| *k == key) {
|
||||
@@ -2229,7 +2230,9 @@ 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, 59, "every tipped row carries a live key");
|
||||
// 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");
|
||||
assert_eq!(
|
||||
untipped,
|
||||
vec![
|
||||
|
||||
@@ -215,8 +215,13 @@ 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.
|
||||
/// Whether the STATIC WORLD (trees, buildings, foliage) casts realtime shadows and baked MCSH
|
||||
/// terrain shadows are suppressed. Independent of [`Self::character_shadows`] — either drives
|
||||
/// the shared shadow rig (`character_shadow` / `world_shadow`).
|
||||
pub(crate) world_shadows: bool,
|
||||
/// 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,
|
||||
pub(crate) display: DisplayMode,
|
||||
/// The windowed size, `gxResolution`. Kept while fullscreen so leaving it can restore it.
|
||||
pub(crate) windowed: UVec2,
|
||||
@@ -227,6 +232,7 @@ impl Default for VideoConfig {
|
||||
Self {
|
||||
vsync: !novsync_env(),
|
||||
world_shadows: false,
|
||||
character_shadows: false,
|
||||
display: if windowed_env() {
|
||||
DisplayMode::Windowed
|
||||
} else {
|
||||
@@ -538,6 +544,7 @@ mod tests {
|
||||
app.insert_resource(VideoConfig {
|
||||
vsync: true,
|
||||
world_shadows: false,
|
||||
character_shadows: false,
|
||||
display: DisplayMode::Fullscreen,
|
||||
windowed: UVec2::new(1024, 768),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
//! MONKEY (world shadows): the WORLD shadow lane — a self-contained, plug-and-play module.
|
||||
//!
|
||||
//! This owns the realtime shadows cast by the STATIC world: the retained `static_gx` geometry
|
||||
//! (trees + buildings, collected solid) and the alpha-tested foliage (leaf canopies, one caster per
|
||||
//! leaf texture). It is deliberately independent of [`super::character_shadow`]'s character lane:
|
||||
//! the two share ONE rig (the sun + private render layer + shadow map that `character_shadow` owns)
|
||||
//! but neither lane's casters depend on the other's. Enabling/disabling the world lane
|
||||
//! (`worldShadows`) spawns/tears down only the entities below; removing the feature entirely means
|
||||
//! deleting this module + its two calls in `character_shadow::update_shadows` + the cvar.
|
||||
//!
|
||||
//! Cadence: the static world doesn't move, so both casters rebuild only when the camera has drifted
|
||||
//! [`STATIC_REBUILD_STEP`] (the character lane, by contrast, rebuilds every frame). Between rebuilds
|
||||
//! the cached meshes stay valid.
|
||||
|
||||
use bevy::asset::AssetId;
|
||||
use bevy::camera::visibility::{NoFrustumCulling, RenderLayers};
|
||||
use bevy::image::Image;
|
||||
use bevy::light::NotShadowReceiver;
|
||||
use bevy::mesh::Indices;
|
||||
use bevy::pbr::MeshMaterial3d;
|
||||
use bevy::platform::collections::HashSet;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_world::static_gx::StaticGx;
|
||||
|
||||
use super::character_shadow::{
|
||||
empty_cutout_mesh, empty_shadow_mesh, restore_mesh_buffers, shadow_trace, spawn_solid_caster,
|
||||
take_mesh_buffers, CutoutCaster, CutoutShadowCasterMaterial, ShadowCasterMaterial,
|
||||
ShadowRuntime, WorldShadowCaster, PLAYER_SHADOW_LAYER, STATIC_REBUILD_STEP,
|
||||
};
|
||||
|
||||
/// Ensure the world lane's casters exist and, on camera drift, refresh them from the retained
|
||||
/// static world. The solid caster shares the rig's invisible proxy material (`solid_material`); the
|
||||
/// cutout casters each carry their own leaf sheet. Called only while `worldShadows` is on.
|
||||
pub(crate) fn update(
|
||||
runtime: &mut ShadowRuntime,
|
||||
commands: &mut Commands,
|
||||
meshes: &mut Assets<Mesh>,
|
||||
solid_material: &Handle<ShadowCasterMaterial>,
|
||||
cutout_materials: &mut Assets<CutoutShadowCasterMaterial>,
|
||||
gx: &StaticGx,
|
||||
light_position: Vec3,
|
||||
tall_reach: f32,
|
||||
) {
|
||||
// Ensure the static SOLID caster (trunks, buildings, fences — the opaque static geometry).
|
||||
if runtime.static_caster.is_none() {
|
||||
let mesh = meshes.add(empty_shadow_mesh());
|
||||
let entity = spawn_solid_caster(commands, mesh.clone(), solid_material.clone());
|
||||
runtime.static_caster = Some(entity);
|
||||
runtime.static_mesh = Some(mesh);
|
||||
runtime.static_rebuilt_at = None; // force a rebuild on the first frame
|
||||
}
|
||||
|
||||
// Static geometry doesn't move: rebuild only when the camera has drifted a step. Between
|
||||
// rebuilds the cached meshes stay valid, so this is a cheap early-out most frames.
|
||||
let due = runtime
|
||||
.static_rebuilt_at
|
||||
.map_or(true, |at| at.distance(light_position) > STATIC_REBUILD_STEP);
|
||||
if !due {
|
||||
return;
|
||||
}
|
||||
|
||||
// The SOLID pass: the retained static world's opaque geometry, minus the cutout foliage it skips.
|
||||
if let Some(handle) = runtime.static_mesh.clone() {
|
||||
if let Some(mesh) = meshes.get_mut(&handle) {
|
||||
let (mut positions, mut indices) = take_mesh_buffers(mesh);
|
||||
gx.append_shadow_triangles(light_position, tall_reach, &mut positions, &mut indices);
|
||||
let static_tris = (indices.len() / 3) as u32;
|
||||
restore_mesh_buffers(mesh, positions, indices);
|
||||
runtime.static_rebuilt_at = Some(light_position);
|
||||
if shadow_trace() {
|
||||
info!(
|
||||
"shadow-trace: static rebuild — {} tris within {:.0}yd",
|
||||
static_tris, tall_reach
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The ALPHA-TESTED foliage pass: the cutout leaf cards the solid pass skipped, grouped one
|
||||
// caster mesh + material per leaf texture so the shadow pass can sample the sheet and cast a
|
||||
// leaf-SHAPED silhouette (see `CutoutShadowCasterMaterial`).
|
||||
let buckets = gx.collect_cutout_shadow_triangles(light_position, tall_reach);
|
||||
let mut seen: HashSet<AssetId<Image>> = HashSet::new();
|
||||
let mut cutout_tris = 0u32;
|
||||
for bucket in buckets {
|
||||
seen.insert(bucket.texture_id);
|
||||
cutout_tris += (bucket.indices.len() / 3) as u32;
|
||||
// Spawn a persistent caster the first time a leaf texture appears.
|
||||
if !runtime.static_cutout.contains_key(&bucket.texture_id) {
|
||||
let mesh = meshes.add(empty_cutout_mesh());
|
||||
let material = cutout_materials.add(CutoutShadowCasterMaterial {
|
||||
leaf: bucket.texture.clone(),
|
||||
});
|
||||
let entity = commands
|
||||
.spawn((
|
||||
Mesh3d(mesh.clone()),
|
||||
MeshMaterial3d(material.clone()),
|
||||
Transform::IDENTITY,
|
||||
Visibility::Visible,
|
||||
RenderLayers::layer(PLAYER_SHADOW_LAYER),
|
||||
NoFrustumCulling,
|
||||
NotShadowReceiver,
|
||||
WorldShadowCaster,
|
||||
))
|
||||
.id();
|
||||
runtime
|
||||
.static_cutout
|
||||
.insert(bucket.texture_id, CutoutCaster { entity, mesh, material });
|
||||
}
|
||||
// Clone the handle out so the `runtime` borrow ends before `meshes.get_mut`.
|
||||
let mesh_handle = runtime.static_cutout[&bucket.texture_id].mesh.clone();
|
||||
if let Some(mesh) = meshes.get_mut(&mesh_handle) {
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, bucket.positions);
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, bucket.uvs);
|
||||
mesh.insert_indices(Indices::U32(bucket.indices));
|
||||
}
|
||||
}
|
||||
// Despawn casters whose leaf texture left the reach, so the set stays bounded as the camera
|
||||
// crosses zones (a texture no longer present casts nothing anyway).
|
||||
let stale: Vec<AssetId<Image>> = runtime
|
||||
.static_cutout
|
||||
.keys()
|
||||
.filter(|id| !seen.contains(*id))
|
||||
.copied()
|
||||
.collect();
|
||||
for id in stale {
|
||||
if let Some(caster) = runtime.static_cutout.remove(&id) {
|
||||
commands.entity(caster.entity).despawn();
|
||||
meshes.remove(caster.mesh.id());
|
||||
cutout_materials.remove(caster.material.id());
|
||||
}
|
||||
}
|
||||
if shadow_trace() {
|
||||
info!(
|
||||
"shadow-trace: cutout rebuild — {} tris across {} leaf textures",
|
||||
cutout_tris,
|
||||
runtime.static_cutout.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tear down every world-lane caster (the solid static caster + all per-texture foliage casters).
|
||||
/// Called when `worldShadows` is off, or by the rig teardown when both lanes go dark. Leaves the
|
||||
/// shared rig (sun/layer/material) alone — that is `character_shadow`'s to manage.
|
||||
pub(crate) fn teardown(
|
||||
runtime: &mut ShadowRuntime,
|
||||
commands: &mut Commands,
|
||||
meshes: &mut Assets<Mesh>,
|
||||
cutout_materials: &mut Assets<CutoutShadowCasterMaterial>,
|
||||
) {
|
||||
if let Some(entity) = runtime.static_caster.take() {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
if let Some(handle) = runtime.static_mesh.take() {
|
||||
meshes.remove(handle.id());
|
||||
}
|
||||
runtime.static_rebuilt_at = None;
|
||||
for (_texture, caster) in runtime.static_cutout.drain() {
|
||||
commands.entity(caster.entity).despawn();
|
||||
meshes.remove(caster.mesh.id());
|
||||
cutout_materials.remove(caster.material.id());
|
||||
}
|
||||
}
|
||||
@@ -325,10 +325,15 @@ 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;
|
||||
// MONKEY (world shadows): when the realtime WORLD-shadow lane is active (`worldShadows` on), the
|
||||
// BAKED MCSH terrain shadows are redundant — the realtime map now shadows the static world
|
||||
// (trees + buildings) too — and keeping both double-shadows. So drop MCSH then. This keys on the
|
||||
// world lane flag (`sh_c16.w`, packed by `global_light::build_light_data`), NOT on the mere
|
||||
// presence of a directional light: the shared shadow sun ALSO exists for CHARACTER-only shadows,
|
||||
// and those must leave the world's baked MCSH intact. With the world lane off, `shadow_lit_eff`
|
||||
// keeps MCSH and only the realtime `character_shadow_term` adds the dynamic character shadow.
|
||||
let world_shadow_lane = wow_light.sh_c16.w > 0.5;
|
||||
let shadow_lit_eff = select(shadow_lit, 1.0, world_shadow_lane);
|
||||
let spec_gate = min(shadow_lit, world_shadow);
|
||||
let character_shadow_term = mix(SHADOW_SUN_FLOOR, 1.0, world_shadow);
|
||||
|
||||
|
||||
@@ -175,6 +175,15 @@ const POINT_PACK_RADIUS: f32 = 300.0;
|
||||
#[derive(Component)]
|
||||
pub struct LightRooms(pub(crate) crate::wmo_portal::WmoGroupVis);
|
||||
|
||||
/// MONKEY (world shadows): whether the realtime WORLD-shadow lane is active this frame (the
|
||||
/// `worldShadows` cvar). Set by benilla-app's shadow rig; read by [`build_light_data`], which packs
|
||||
/// it into the free `sh_c16.w` light lane so `terrain.wgsl` can suppress the baked MCSH terrain
|
||||
/// shadows ONLY when the world is casting realtime — not merely because a shadow sun exists (the
|
||||
/// sun also exists for character-only shadows, which must leave MCSH alone). A dedicated resource
|
||||
/// rather than a `WowLighting` field because `update_time_lighting` wholesale-overwrites that.
|
||||
#[derive(Resource, Clone, Copy, Default)]
|
||||
pub struct WorldShadowActive(pub bool);
|
||||
|
||||
/// 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)]
|
||||
@@ -201,6 +210,7 @@ pub struct SharedLightBuffer(pub Buffer);
|
||||
/// creation, and the render-world upload.
|
||||
pub(super) fn register(app: &mut App) {
|
||||
app.init_resource::<WowLightData>()
|
||||
.init_resource::<WorldShadowActive>()
|
||||
.init_resource::<super::prop_probes::PropProbeExtract>()
|
||||
.add_plugins(ExtractResourcePlugin::<WowLightData>::default())
|
||||
.add_plugins(ExtractResourcePlugin::<SharedLightBuffer>::default())
|
||||
@@ -276,6 +286,8 @@ fn build_light_data(
|
||||
portals: Query<&crate::wmo_portal::WmoPortalInstance>,
|
||||
mut data: ResMut<WowLightData>,
|
||||
time: Res<Time>,
|
||||
// MONKEY (world shadows): the `worldShadows` lane flag, packed into `sh_c16.w` for the MCSH gate.
|
||||
world_shadow: Res<WorldShadowActive>,
|
||||
mut last_dump: Local<f64>,
|
||||
mut last_rows_dump: Local<f64>,
|
||||
) {
|
||||
@@ -311,6 +323,10 @@ fn build_light_data(
|
||||
// Rows 0-2, the SH block 6-12.xyz, and the sun DC (17.yzw) — the shared model-light core
|
||||
// (also the portrait booth's packer). Row 20 (point_count) is the point-table pack's below.
|
||||
pack_model_core_rows(rows, l.ambient, l.diffuse, l.sun_dir);
|
||||
// MONKEY (world shadows): pack the world-shadow lane flag into the free `sh_c16.w` lane. The
|
||||
// MCSH terrain-shadow suppression in `terrain.wgsl` keys on THIS — not on the mere presence of
|
||||
// a shadow sun — so character-only shadows (sun present, world lane off) keep the baked MCSH.
|
||||
rows[12][3] = if world_shadow.0 { 1.0 } else { 0.0 };
|
||||
// The dynamic point-light table (decision 0278): every spawned point light within
|
||||
// [`POINT_PACK_RADIUS`] of the camera, nearest-first when over capacity — the VERTEX stages of
|
||||
// `terrain.wgsl`/`wow_model.wgsl` walk it for the Gouraud point term (bevy's clusterable buffer
|
||||
|
||||
@@ -17,7 +17,7 @@ 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};
|
||||
pub use global_light::{new_shared_light_buffer, LightRooms, SharedLightBuffer, 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
|
||||
|
||||
@@ -132,6 +132,8 @@ mod cull;
|
||||
mod pick;
|
||||
mod pool;
|
||||
mod render;
|
||||
mod shadow; // MONKEY (world shadows): CPU triangle collection for the static-world shadow caster
|
||||
pub use shadow::CutoutBucket; // MONKEY (world shadows): per-leaf-texture alpha-cutout caster group
|
||||
|
||||
/// The doodad spatial cell — ¼ ADT tile, the same 133⅓-yd locality key the merge lanes use
|
||||
/// (`terrain_stream::merge::CELL`; 1413 round 2 proved the locality load-bearing).
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
//! MONKEY (world shadows): CPU collection of the RETAINED static world's geometry as shadow-caster
|
||||
//! triangles — the same source-submesh reachback `pick.rs` does for ray hits, appended into a
|
||||
//! world-space triangle list. The world-shadow lane (`benilla-app::world_shadow`) appends
|
||||
//! these into a cached, layer-31 caster mesh so trees and buildings cast into the SAME Bevy
|
||||
//! directional shadow map as characters, instead of only their baked shading.
|
||||
//!
|
||||
//! The retained pass recentres vertices per cell and bakes them into a `RENDER_WORLD`-only mesh
|
||||
//! whose CPU data is gone after extract — so we go to the SOURCE the divert kept
|
||||
//! ([`super::GxItem::geometry`] + [`super::GxItem::transform`]), exactly as `pick.rs` does, and
|
||||
//! reconstruct world space directly: `transform.transform_point(wow_to_bevy(pos))`.
|
||||
|
||||
use benilla_assets::coords::wow_to_bevy;
|
||||
use bevy::asset::AssetId;
|
||||
use bevy::image::Image;
|
||||
use bevy::platform::collections::HashMap;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use super::{GxCell, StaticGx};
|
||||
|
||||
/// One alpha-tested foliage caster group: every resident cutout batch that shares a single leaf
|
||||
/// texture, merged into one world-space triangle list carrying UVs. The character-shadow system
|
||||
/// drives one layer-31 caster entity + [`super::super`]-side `CutoutShadowCasterMaterial` per
|
||||
/// bucket, so the shadow pass can sample this texture and discard transparent texels — casting a
|
||||
/// leaf-SHAPED silhouette instead of the solid box a positions-only proxy throws.
|
||||
pub struct CutoutBucket {
|
||||
/// The grouping key: the leaf sheet's asset id (stable across rebuilds, so the caller keys a
|
||||
/// persistent caster entity off it rather than churning one per rebuild).
|
||||
pub texture_id: AssetId<Image>,
|
||||
/// A live strong handle to the same leaf image the forward pass draws — put straight on the
|
||||
/// caster material so its sampler inherits the image's own clamp/repeat address mode.
|
||||
pub texture: Handle<Image>,
|
||||
/// World-space (Bevy) caster positions.
|
||||
pub positions: Vec<[f32; 3]>,
|
||||
/// Per-vertex UVs, parallel to [`Self::positions`] — what the prepass fragment samples.
|
||||
pub uvs: Vec<[f32; 2]>,
|
||||
pub indices: Vec<u32>,
|
||||
}
|
||||
|
||||
impl StaticGx {
|
||||
/// Append world-space (Bevy) shadow-caster triangles for resident static geometry whose
|
||||
/// placement anchor is within `reach` of `center`, onto the caller's buffers (the character
|
||||
/// system recycles them across rebuilds). Whole triangles referencing out-of-range indices are
|
||||
/// dropped — a truncated submesh must never rewire a later triangle into garbage (mirrors
|
||||
/// `character_shadow::append_triangles`).
|
||||
///
|
||||
/// FIRST CUT: a currently-EXILED (distance-faded) static item also draws as an entity, so it is
|
||||
/// collected by both lanes and its geometry lands in the caster twice — harmless (identical
|
||||
/// triangles at identical depth), a follow-up can gate on the fader state.
|
||||
pub fn append_shadow_triangles(
|
||||
&self,
|
||||
center: Vec3,
|
||||
reach: f32,
|
||||
positions: &mut Vec<[f32; 3]>,
|
||||
indices: &mut Vec<u32>,
|
||||
) {
|
||||
let r2 = reach * reach;
|
||||
let mut push = |cell: &GxCell| {
|
||||
for item in &cell.items {
|
||||
// Distance gate on the placement anchor BEFORE any vertex work.
|
||||
if item.transform.translation.distance_squared(center) > r2 {
|
||||
continue;
|
||||
}
|
||||
// MONKEY (world shadows): the SOLID caster skips alpha-cutout foliage — a leaf card
|
||||
// is a big flat quad, and cast solid here (this mesh has no texture) it would be an
|
||||
// ugly rectangular BOX on the ground. Trunks/buildings/fences (opaque) cast their
|
||||
// real silhouette here; cutout foliage is collected leaf-shaped by
|
||||
// [`Self::collect_cutout_shadow_triangles`] instead. (`item.cutout` == AlphaTest.)
|
||||
if item.cutout {
|
||||
continue;
|
||||
}
|
||||
let base = positions.len() as u32;
|
||||
let t = item.transform;
|
||||
positions.extend(
|
||||
item.geometry
|
||||
.positions
|
||||
.iter()
|
||||
.map(|p| t.transform_point(wow_to_bevy(*p)).to_array()),
|
||||
);
|
||||
let added = positions.len() as u32 - base;
|
||||
for tri in item.geometry.indices.chunks_exact(3) {
|
||||
if tri.iter().all(|i| *i < added) {
|
||||
indices.extend(tri.iter().map(|i| base + *i));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
for cell in self.cells.values() {
|
||||
push(cell);
|
||||
}
|
||||
for cell in self.wmos.values() {
|
||||
push(cell);
|
||||
}
|
||||
for cell in self.props.values() {
|
||||
push(cell);
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect the resident ALPHA-CUTOUT geometry (tree canopies, bushes, cattails — the leaf
|
||||
/// cards the solid caster skips) as one [`CutoutBucket`] per distinct leaf texture, each a
|
||||
/// world-space triangle list with UVs. The character-shadow system drives one alpha-tested
|
||||
/// layer-31 caster per bucket, so a canopy discards its transparent texels in the shadow pass
|
||||
/// and casts a leaf-SHAPED silhouette. Grouping by texture is what lets a single draw carry
|
||||
/// one sheet: a zone's canopies draw from only a handful of sheets, so this yields a handful
|
||||
/// of buckets. Range-gates on the placement anchor exactly like [`Self::append_shadow_triangles`].
|
||||
///
|
||||
/// A cutout batch without a texture handle, or whose UVs don't parallel its positions, is
|
||||
/// skipped (it can't be alpha-tested — better no shadow than a wrong solid box).
|
||||
pub fn collect_cutout_shadow_triangles(&self, center: Vec3, reach: f32) -> Vec<CutoutBucket> {
|
||||
let r2 = reach * reach;
|
||||
let mut buckets: HashMap<AssetId<Image>, CutoutBucket> = HashMap::new();
|
||||
let mut push = |cell: &GxCell| {
|
||||
for item in &cell.items {
|
||||
if !item.cutout {
|
||||
continue;
|
||||
}
|
||||
let (Some(texture_id), Some(texture)) = (item.texture, item.texture_handle.as_ref())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if item.transform.translation.distance_squared(center) > r2 {
|
||||
continue;
|
||||
}
|
||||
let geometry = &item.geometry;
|
||||
// UVs must parallel positions to map per-vertex — a cutout card that lost its UVs
|
||||
// (should never happen) can't be sampled, so drop it rather than box it out.
|
||||
if geometry.uvs.len() != geometry.positions.len() {
|
||||
continue;
|
||||
}
|
||||
let bucket = buckets.entry(texture_id).or_insert_with(|| CutoutBucket {
|
||||
texture_id,
|
||||
texture: texture.clone(),
|
||||
positions: Vec::new(),
|
||||
uvs: Vec::new(),
|
||||
indices: Vec::new(),
|
||||
});
|
||||
let base = bucket.positions.len() as u32;
|
||||
let t = item.transform;
|
||||
bucket.positions.extend(
|
||||
geometry
|
||||
.positions
|
||||
.iter()
|
||||
.map(|p| t.transform_point(wow_to_bevy(*p)).to_array()),
|
||||
);
|
||||
bucket.uvs.extend_from_slice(&geometry.uvs);
|
||||
let added = bucket.positions.len() as u32 - base;
|
||||
for tri in geometry.indices.chunks_exact(3) {
|
||||
if tri.iter().all(|i| *i < added) {
|
||||
bucket.indices.extend(tri.iter().map(|i| base + *i));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
for cell in self.cells.values() {
|
||||
push(cell);
|
||||
}
|
||||
for cell in self.wmos.values() {
|
||||
push(cell);
|
||||
}
|
||||
for cell in self.props.values() {
|
||||
push(cell);
|
||||
}
|
||||
buckets.into_values().collect()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user