Move the foliage wind marker from bit 14 to bit 15 in its Rust producer and WGSL consumer so distance-fade twins cannot trigger a downstream fragment effect. Keep bit 14 reserved for those extensions. Verified on Windows: cargo build -p benilla --profile play; cargo test -p benilla-app --lib (4313 passed, 0 failed, 10 ignored; no-install run). Public-base and fixed captures at the reported Elwynn position are pixel-identical; downstream integration still needs its in-game retest. Co-Authored-By: GPT-6 Astra (Codex)
1243 lines
50 KiB
Rust
1243 lines
50 KiB
Rust
//! The `WowModelMaterial` build for every M2 and WMO instance: a `StandardMaterial` base for
|
||
//! texture, alpha and cull plus the `WowModelExt` shading, deduped so a shared look is one draw.
|
||
|
||
use benilla_formats::{ModelBlend, WmoBatchClass};
|
||
use bevy::asset::AssetId;
|
||
use bevy::mesh::MeshTag;
|
||
use bevy::pbr::ExtendedMaterial;
|
||
use bevy::prelude::*;
|
||
use bevy::render::render_resource::{Buffer, Face};
|
||
|
||
use benilla_assets::materials::{TorchBinds, WowModelExt, WowModelMaterial, VANILLA_ALPHA_KEY_REF};
|
||
|
||
mod batch;
|
||
pub mod lazy;
|
||
pub mod park;
|
||
mod visibility;
|
||
|
||
pub use batch::{BatchVariants, EntityUvLane, M2BatchMaterials, ModelMaterials, SkyboxBatch};
|
||
|
||
/// Yards of transparent sort bias per authored batch step: a model's coplanar batches tie on one
|
||
/// mesh centre, and the reference keeps their authored order inside the instance's sort key
|
||
/// (`0x707db1`). Under [`benilla_formats::owner_last_rung`]'s 1-yd floor, over f32 noise at 500 yd.
|
||
pub(crate) const BATCH_ORDER_SORT_EPS: f32 = 1e-3;
|
||
|
||
/// The eps product's ceiling, since `depth_bias as i32` is a pipeline-key axis: under 1.0, and
|
||
/// under `FAR_KEY_PULL` for the far twins. Past index 899 batches tie.
|
||
pub(crate) const BATCH_ORDER_SORT_CAP: f32 = 0.9;
|
||
|
||
/// `WOW_NO_ALPHATEST=1`: every cutout batch draws opaque, in the static-gx bake too.
|
||
pub(crate) fn alphatest_disabled() -> bool {
|
||
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||
*ON.get_or_init(|| std::env::var_os("WOW_NO_ALPHATEST").is_some())
|
||
}
|
||
|
||
/// `WOW_WMO_BIAS=0`: the WMO batch clip-z nudge off.
|
||
fn wmo_bias_off() -> bool {
|
||
static OFF: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||
*OFF.get_or_init(|| matches!(std::env::var("WOW_WMO_BIAS").as_deref(), Ok("0")))
|
||
}
|
||
|
||
/// The material-dedup key: batches that agree on every field share one material.
|
||
#[derive(PartialEq, Eq, Hash)]
|
||
pub struct MatKey {
|
||
/// The bound light buffer: the world, a portrait booth and char select each bind their own.
|
||
light: bevy::render::render_resource::BufferId,
|
||
texture: Option<AssetId<Image>>,
|
||
blend: ModelBlend,
|
||
two_sided: bool,
|
||
is_wmo: bool,
|
||
is_interior: bool,
|
||
is_emissive: bool,
|
||
is_additive: bool,
|
||
fade_variant: bool,
|
||
/// M2 render flag 0x10 (0x08 for `no_depth_test`), honoured by `specialize`.
|
||
no_depth_write: bool,
|
||
no_depth_test: bool,
|
||
/// The batch's fog colour policy, packed into `clutter_fade.z` bits 4-6.
|
||
fog_policy: benilla_formats::FogPolicy,
|
||
/// The batch samples the generated sphere-map coordinate, not its vertex UVs.
|
||
env_map: bool,
|
||
/// The static terrain-shade selector, fixed per placement.
|
||
shade: ShadeSel,
|
||
/// Authored batch index + 1 (0 = unordered): the transparent sort bias of every batch, and for
|
||
/// WMO the clip-z nudge too (MOBA batches draw in strict file order, `0x6b4f10`/`0x6b5190`).
|
||
batch_order: u16,
|
||
/// The `Arc<UvAnim>` pointer, stable while its model is loaded: one material per UV loop.
|
||
uv_anim: Option<usize>,
|
||
/// The `Arc<RgbAnim>` pointer, keyed like [`Self::uv_anim`]; `None` is a static vertex tint.
|
||
rgb_anim: Option<usize>,
|
||
/// The WMO batch's MOBA section: an interior group's INT and TRANS batches light differently.
|
||
wmo_class: Option<WmoBatchClass>,
|
||
/// The WMO MOMT SIDN night-glow colour, gamma RGB bytes.
|
||
sidn: Option<[u8; 3]>,
|
||
/// The WMO MOMT WINDOW flag: an interior-group batch on the brighter midpoint light.
|
||
window: bool,
|
||
/// A depth-prime twin ([`zfill_material`], the reference's `M2UseZFill` clone at `0x707f7d`).
|
||
zfill: bool,
|
||
/// MONKEY (fix-wind): a classified tree/bush leaf batch's exile copy, marker bit 14
|
||
/// ([`FOLIAGE_WIND_MARKER`]); `wow_model.wgsl` sways only these.
|
||
foliage_wind: bool,
|
||
/// A WMO skybox batch ([`crate::skybox`]), with its own pipeline and sort rung.
|
||
sky_depth: bool,
|
||
/// The placement owning this material when its animated loop follows the instance's sequence
|
||
/// (the BRM lava bubbles' flipbook); `None` for the shared material.
|
||
instance: Option<Entity>,
|
||
}
|
||
|
||
/// The static terrain-shade selector in `sun_scale.x`, which the shader thresholds into the
|
||
/// reference's intensities (2.5 lit, 1.0 day/night, 0.5 shadowed; `[node+0xa4]`, `0x69e280`).
|
||
/// Entities are always [`ShadeSel::Lit`] and shade per instance by the `MeshTag` byte.
|
||
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||
pub enum ShadeSel {
|
||
/// Entity M2s only: the 2.5 and its 3.3333/s chase live on the WENTITY light node (vtable
|
||
/// `0x810810`, `[obj+0xe0]`), which [`crate::entity_shade`] ramps per instance into the
|
||
/// `MeshTag` byte for units, players and GameObjects alike.
|
||
Lit,
|
||
/// Lit ground at 1.0: exterior WMO MODD props and ADT map doodads, never the 2.5 site; a map
|
||
/// doodad's `CMapDoodadDef` `[+0xa4]` is only ever 0.0, 0.5 or 1.0.
|
||
Matte,
|
||
/// The base sits on MCSH-shadowed terrain: the reference's dim 0.5.
|
||
Shaded,
|
||
/// An authored M2 light rig (the glue create booth): the SH probe in slot 0 of the material's
|
||
/// own light buffer, on the reference's `Model2.bls` curve, plus up to three nearest point
|
||
/// lights; no sun or MCSH.
|
||
Rig,
|
||
}
|
||
|
||
impl ShadeSel {
|
||
/// The `sun_scale.x` value, matching `wow_model.wgsl`'s thresholds (1.5 rig, 0.85 lit,
|
||
/// 0.5 matte, else shaded).
|
||
pub fn selector(self) -> f32 {
|
||
match self {
|
||
ShadeSel::Lit => 1.0,
|
||
ShadeSel::Matte => 0.6,
|
||
ShadeSel::Shaded => 0.2,
|
||
ShadeSel::Rig => 2.0,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A material-dedup cache, one per model-spawning subsystem; entries expire by distance if swept.
|
||
pub type MaterialCache = benilla_assets::SpatialCache<MatKey, Handle<WowModelMaterial>>;
|
||
|
||
/// Build or fetch the deduped [`WowModelMaterial`] for one model batch. `fade_variant` marks the
|
||
/// blend twin every fade rides, built with the source blend, never `Blend`: the twin always blends
|
||
/// (`0x70c1fd`) but its alpha test keys on the stored mode (`0x70c237`), so AlphaKey keeps its
|
||
/// 224/255 cutout and Opaque has none.
|
||
pub fn model_material(
|
||
cache: &mut MaterialCache,
|
||
materials: &mut Assets<WowModelMaterial>,
|
||
texture: Option<Handle<Image>>,
|
||
blend: ModelBlend,
|
||
two_sided: bool,
|
||
is_wmo: bool,
|
||
is_interior: bool,
|
||
is_emissive: bool,
|
||
is_additive: bool,
|
||
fade_variant: bool,
|
||
no_depth_write: bool,
|
||
no_depth_test: bool,
|
||
fog_policy: benilla_formats::FogPolicy,
|
||
env_map: bool,
|
||
shade: ShadeSel,
|
||
batch_order: u16,
|
||
uv_anim: Option<&std::sync::Arc<benilla_formats::UvAnim>>,
|
||
rgb_anim: Option<&std::sync::Arc<benilla_formats::RgbAnim>>,
|
||
wmo_class: Option<WmoBatchClass>,
|
||
sidn: Option<[u8; 3]>,
|
||
window: bool,
|
||
sky_depth: bool,
|
||
light: &Buffer,
|
||
// MONKEY (torch shadows Phase 3A): the shared torch depth image + table buffer, cloned into the
|
||
// material beside `light` (not a key axis — one pair for the whole scene, like the light).
|
||
torch: &TorchBinds,
|
||
// The ONE placement this material belongs to, or `None` for the shared batch material every
|
||
// instance of the model reuses. `Some` only for a batch whose animated UV/tint loop depends on
|
||
// the sequence its instance is playing (decision 1408): the animated-material registries are
|
||
// keyed by material, so such a batch cannot share one and be right. See `MatKey::instance`.
|
||
instance: Option<Entity>,
|
||
) -> Handle<WowModelMaterial> {
|
||
model_material_impl(
|
||
cache,
|
||
materials,
|
||
texture,
|
||
blend,
|
||
two_sided,
|
||
is_wmo,
|
||
is_interior,
|
||
is_emissive,
|
||
is_additive,
|
||
fade_variant,
|
||
no_depth_write,
|
||
no_depth_test,
|
||
fog_policy,
|
||
env_map,
|
||
shade,
|
||
batch_order,
|
||
uv_anim,
|
||
rgb_anim,
|
||
wmo_class,
|
||
sidn,
|
||
window,
|
||
sky_depth,
|
||
light,
|
||
torch,
|
||
instance,
|
||
false,
|
||
)
|
||
}
|
||
|
||
/// MONKEY (fix-wind): [`model_material`] for a classified tree/bush leaf batch's exile copy: the
|
||
/// same material plus [`FOLIAGE_WIND_MARKER`], so the entity draw sways like the retained batch.
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub fn foliage_model_material(
|
||
cache: &mut MaterialCache,
|
||
materials: &mut Assets<WowModelMaterial>,
|
||
texture: Option<Handle<Image>>,
|
||
blend: ModelBlend,
|
||
two_sided: bool,
|
||
is_wmo: bool,
|
||
is_interior: bool,
|
||
is_emissive: bool,
|
||
is_additive: bool,
|
||
fade_variant: bool,
|
||
no_depth_write: bool,
|
||
no_depth_test: bool,
|
||
fog_policy: benilla_formats::FogPolicy,
|
||
env_map: bool,
|
||
shade: ShadeSel,
|
||
batch_order: u16,
|
||
uv_anim: Option<&std::sync::Arc<benilla_formats::UvAnim>>,
|
||
rgb_anim: Option<&std::sync::Arc<benilla_formats::RgbAnim>>,
|
||
wmo_class: Option<WmoBatchClass>,
|
||
sidn: Option<[u8; 3]>,
|
||
window: bool,
|
||
sky_depth: bool,
|
||
light: &Buffer,
|
||
// MONKEY (torch shadows Phase 3A): the shared torch depth image + table buffer, cloned into the
|
||
// material beside `light` (not a key axis — one pair for the whole scene, like the light).
|
||
torch: &TorchBinds,
|
||
// The ONE placement this material belongs to, or `None` for the shared batch material every
|
||
// instance of the model reuses. `Some` only for a batch whose animated UV/tint loop depends on
|
||
// the sequence its instance is playing (decision 1408): the animated-material registries are
|
||
// keyed by material, so such a batch cannot share one and be right. See `MatKey::instance`.
|
||
instance: Option<Entity>,
|
||
) -> Handle<WowModelMaterial> {
|
||
model_material_impl(
|
||
cache,
|
||
materials,
|
||
texture,
|
||
blend,
|
||
two_sided,
|
||
is_wmo,
|
||
is_interior,
|
||
is_emissive,
|
||
is_additive,
|
||
fade_variant,
|
||
no_depth_write,
|
||
no_depth_test,
|
||
fog_policy,
|
||
env_map,
|
||
shade,
|
||
batch_order,
|
||
uv_anim,
|
||
rgb_anim,
|
||
wmo_class,
|
||
sidn,
|
||
window,
|
||
sky_depth,
|
||
light,
|
||
torch,
|
||
instance,
|
||
true,
|
||
)
|
||
}
|
||
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn model_material_impl(
|
||
cache: &mut MaterialCache,
|
||
materials: &mut Assets<WowModelMaterial>,
|
||
texture: Option<Handle<Image>>,
|
||
blend: ModelBlend,
|
||
two_sided: bool,
|
||
is_wmo: bool,
|
||
is_interior: bool,
|
||
is_emissive: bool,
|
||
is_additive: bool,
|
||
fade_variant: bool,
|
||
no_depth_write: bool,
|
||
no_depth_test: bool,
|
||
fog_policy: benilla_formats::FogPolicy,
|
||
env_map: bool,
|
||
shade: ShadeSel,
|
||
batch_order: u16,
|
||
uv_anim: Option<&std::sync::Arc<benilla_formats::UvAnim>>,
|
||
rgb_anim: Option<&std::sync::Arc<benilla_formats::RgbAnim>>,
|
||
wmo_class: Option<WmoBatchClass>,
|
||
sidn: Option<[u8; 3]>,
|
||
window: bool,
|
||
sky_depth: bool,
|
||
light: &Buffer,
|
||
// MONKEY (torch shadows Phase 3A): the shared torch depth image + table buffer, cloned into the
|
||
// material beside `light` (not a key axis — one pair for the whole scene, like the light).
|
||
torch: &TorchBinds,
|
||
// The ONE placement this material belongs to, or `None` for the shared batch material every
|
||
// instance of the model reuses. `Some` only for a batch whose animated UV/tint loop depends on
|
||
// the sequence its instance is playing (decision 1408): the animated-material registries are
|
||
// keyed by material, so such a batch cannot share one and be right. See `MatKey::instance`.
|
||
instance: Option<Entity>,
|
||
foliage_wind: bool,
|
||
) -> Handle<WowModelMaterial> {
|
||
let key = MatKey {
|
||
light: light.id(),
|
||
texture: texture.as_ref().map(Handle::id),
|
||
blend,
|
||
two_sided,
|
||
is_wmo,
|
||
is_interior,
|
||
is_emissive,
|
||
is_additive,
|
||
fade_variant,
|
||
no_depth_write,
|
||
no_depth_test,
|
||
fog_policy,
|
||
env_map,
|
||
shade,
|
||
batch_order,
|
||
uv_anim: uv_anim.map(|a| std::sync::Arc::as_ptr(a) as usize),
|
||
rgb_anim: rgb_anim.map(|a| std::sync::Arc::as_ptr(a) as usize),
|
||
wmo_class,
|
||
sidn,
|
||
window,
|
||
zfill: false,
|
||
foliage_wind,
|
||
sky_depth,
|
||
instance,
|
||
};
|
||
if let Some(h) = cache.fetch(&key) {
|
||
return h;
|
||
}
|
||
// An additive batch is a (ONE, ONE) add of gamma-space premultiplied colour: the shader and
|
||
// `specialize` must key on the same `clutter_fade.z` bit 2.
|
||
let source_cutout = blend == ModelBlend::AlphaTest && !alphatest_disabled();
|
||
let alpha_mode = if is_additive || fade_variant {
|
||
// A fade twin always blends (`0x70c1fd`); its source blend only sets the cutout marker.
|
||
AlphaMode::Blend
|
||
} else {
|
||
match blend {
|
||
ModelBlend::Opaque => AlphaMode::Opaque,
|
||
ModelBlend::AlphaTest if source_cutout => AlphaMode::Mask(VANILLA_ALPHA_KEY_REF),
|
||
ModelBlend::AlphaTest => AlphaMode::Opaque,
|
||
// Mod/Mod2x multiply what is drawn, so they need the scene under them; `specialize`
|
||
// sets the multiply factors from the marker bits below.
|
||
ModelBlend::Blend | ModelBlend::Mod | ModelBlend::Mod2x => AlphaMode::Blend,
|
||
}
|
||
};
|
||
// Single-sided unless the M2's 0x04 flag is set.
|
||
let cull_mode = if two_sided { None } else { Some(Face::Back) };
|
||
// Transparent batches only: opaque passes never sort by it. Tying past index 899 is safe:
|
||
// the eps matters only for same-centre stacks (M2 item overlays), and WMO batches sort by
|
||
// their own centres. Skybox transparents take their own rung under the world's; its painted
|
||
// cube faces draw opaque, as in the reference.
|
||
let depth_bias = if !matches!(alpha_mode, AlphaMode::Blend) {
|
||
0.0
|
||
} else if sky_depth {
|
||
skybox_sort_bias(batch_order)
|
||
} else {
|
||
(f32::from(batch_order) * BATCH_ORDER_SORT_EPS).min(BATCH_ORDER_SORT_CAP)
|
||
};
|
||
let base = match texture {
|
||
Some(image) => StandardMaterial {
|
||
base_color_texture: Some(image),
|
||
alpha_mode,
|
||
double_sided: two_sided,
|
||
cull_mode,
|
||
depth_bias,
|
||
..default()
|
||
},
|
||
// No texture: the reference disables the texture stage and draws the flat vertex colour,
|
||
// a white modulate whose alpha test passes everywhere (`0x59baa0`). Blend and cull still
|
||
// apply, so an untextured fade twin keeps its alpha path.
|
||
None => StandardMaterial {
|
||
base_color: Color::WHITE,
|
||
alpha_mode,
|
||
double_sided: two_sided,
|
||
cull_mode,
|
||
depth_bias,
|
||
..default()
|
||
},
|
||
};
|
||
let handle = lazy::defer(
|
||
materials,
|
||
ExtendedMaterial {
|
||
base,
|
||
extension: WowModelExt {
|
||
// x, y, w are the clutter fade (0 here); z packs the markers `specialize` keys on.
|
||
clutter_fade: Vec4::new(
|
||
0.0,
|
||
0.0,
|
||
// Bits: 0 no depth write (M2 0x10), 1 no depth test (0x08), 2 additive,
|
||
// 3 opaque intent (the shader pins alpha to 1.0: Metal has bound opaque
|
||
// draws with blending on when an extra camera exists), 4-6 fog policy
|
||
// (`0x70baf0`), 7 Mod (DST_COLOR, ZERO) and 8 Mod2x (DST_COLOR, SRC_COLOR),
|
||
// exact because the framebuffer holds gamma as in the reference, 10 twin
|
||
// cutout (the source alpha-tests: 224/255 on the unfaded alpha; an Opaque
|
||
// source has no test, `0x70c237`), 12 env map, 13 sky depth.
|
||
f32::from(
|
||
u16::from(no_depth_write)
|
||
| (u16::from(no_depth_test) << 1)
|
||
| (u16::from(is_additive) << 2)
|
||
| (u16::from(
|
||
matches!(blend, ModelBlend::Opaque | ModelBlend::AlphaTest)
|
||
&& !fade_variant
|
||
&& !is_additive,
|
||
) << 3)
|
||
| (u16::from(fog_policy as u8) << 4)
|
||
| (u16::from(blend == ModelBlend::Mod) << 7)
|
||
| (u16::from(blend == ModelBlend::Mod2x) << 8)
|
||
| (u16::from(fade_variant && source_cutout) * TWIN_CUTOUT_MARKER)
|
||
| (u16::from(env_map) * ENV_MAP_MARKER)
|
||
| (u16::from(sky_depth) * SKY_DEPTH_MARKER)
|
||
| (u16::from(foliage_wind) * FOLIAGE_WIND_MARKER),
|
||
),
|
||
0.0,
|
||
),
|
||
// x WMO (FFP N·L × MOCV), y fade variant, z WMO interior group (sun off), w unlit:
|
||
// M2 UNLIT 0x01, or WMO UNLIT on an exterior group (`0x6b4f10`; the interior
|
||
// drawer `0x6b5190` ignores it). M2 Mod/Mod2x are always unlit, and additive is
|
||
// lit unless 0x01 is set (`DAT_00811fa8 = {1,1,1,1,1,0,0}`).
|
||
model_flags: Vec4::new(
|
||
if is_wmo { 1.0 } else { 0.0 },
|
||
if fade_variant { 1.0 } else { 0.0 },
|
||
if is_interior { 1.0 } else { 0.0 },
|
||
if is_emissive
|
||
|| (!is_wmo && matches!(blend, ModelBlend::Mod | ModelBlend::Mod2x))
|
||
{
|
||
1.0
|
||
} else {
|
||
0.0
|
||
},
|
||
),
|
||
// x the shade selector. y the WMO batch order, uniform data, not a pipeline key:
|
||
// the vertex stage scales clip z by (1 + y·2⁻²³), so a later coplanar batch wins
|
||
// in any draw order, as in MOBA order. zw the UV-animation offset at t = 0, the
|
||
// seed the per-frame table row is added to: `0x714260` adds the un-pivoted
|
||
// translation to the stage UVs, and no placed doodad rotates or scales them.
|
||
sun_scale: {
|
||
let uv0 = uv_anim.map_or([0.0, 0.0], |a| a.sample(0.0));
|
||
// WMO only: M2 coplanar layers pass GreaterEqual at equal depth, and their
|
||
// order is the sort bias.
|
||
let order = if !is_wmo || wmo_bias_off() {
|
||
0.0
|
||
} else {
|
||
f32::from(batch_order)
|
||
};
|
||
Vec4::new(shade.selector(), order, uv0[0], uv0[1])
|
||
},
|
||
// xyz the animated M2Color tint's first key (white when static: the vertex colours
|
||
// carry it). w the WMO interior class: INT batches draw unlit tex × MOCV and TRANS
|
||
// batches lerp lit to baked by the MOCV alpha (`0x6b5190`); exterior and M2 are 0.
|
||
tint: {
|
||
let t0 = rgb_anim.map_or([1.0, 1.0, 1.0], |a| a.sample(0.0));
|
||
let class_lane = match (is_interior && is_wmo, wmo_class) {
|
||
(true, Some(WmoBatchClass::Int)) => 1.0,
|
||
(true, Some(WmoBatchClass::Trans)) => 2.0,
|
||
_ => 0.0,
|
||
};
|
||
Vec4::new(t0[0], t0[1], t0[2], class_lane)
|
||
},
|
||
// xyz the SIDN emissive, ramped by the night fraction; w the WINDOW flag.
|
||
sidn: {
|
||
let c = sidn.unwrap_or([0, 0, 0]);
|
||
Vec4::new(
|
||
f32::from(c[0]) / 255.0,
|
||
f32::from(c[1]) / 255.0,
|
||
f32::from(c[2]) / 255.0,
|
||
if window { 1.0 } else { 0.0 },
|
||
)
|
||
},
|
||
// Zero until a sampler registers this material and bakes its table slot in once.
|
||
anim_slots: Vec4::ZERO,
|
||
// MONKEY (skybox): no second stage.
|
||
stage1: Vec4::ZERO,
|
||
stage1_texture: None,
|
||
light_buf: light.clone(),
|
||
// MONKEY (torch shadows Phase 3A): the shared torch receiver bindings.
|
||
torch_depth: torch.depth.clone(),
|
||
torch_buf: torch.table.clone(),
|
||
},
|
||
},
|
||
);
|
||
cache.insert(key, handle.clone());
|
||
handle
|
||
}
|
||
|
||
/// `clutter_fade.z` marker bit 9: this material is a depth-prime twin ([`zfill_material`]).
|
||
pub(crate) const ZFILL_MARKER: u16 = 1 << 9;
|
||
|
||
/// `clutter_fade.z` marker bit 10: the twin's source alpha-tests, so the shader keeps the 224/255
|
||
/// cutout while blending. Never on an Opaque source: mode 0 has no alpha test (`0x70c237`).
|
||
pub(crate) const TWIN_CUTOUT_MARKER: u16 = 1 << 10;
|
||
|
||
/// `clutter_fade.z` marker bit 12: texture coordinates from the view-space reflection vector, the
|
||
/// reference's `texture_unit_lookup > 2` env stage; fragment-only, so not a `WowModelKey` axis.
|
||
pub(crate) const ENV_MAP_MARKER: u16 = 1 << 12;
|
||
|
||
/// `clutter_fade.z` marker bit 13: a WMO skybox batch, whose pipeline pins the far depth at the
|
||
/// vertex with no raster bias; a `WowModelKey` axis, so every other draw keeps its real depth.
|
||
pub(crate) const SKY_DEPTH_MARKER: u16 = 1 << 13;
|
||
|
||
/// MONKEY (wind): `clutter_fade.z` marker bit 15: a classified tree/bush leaf batch's exile
|
||
/// copy, the only entity draw `wow_model.wgsl` applies `tree_offset` to. Vertex-only uniform data,
|
||
/// not a `WowModelKey` axis. (It replaced MeshTag bit 18, which aliases the lane weight and the
|
||
/// interior probe slot, so doorway units and interior props swayed.) Bit 14 is reserved for
|
||
/// downstream material extensions; sharing it makes faded foliage take their fragment paths.
|
||
pub(crate) const FOLIAGE_WIND_MARKER: u16 = 1 << 15;
|
||
|
||
/// [`BATCH_ORDER_SORT_EPS`] for the WMO skybox lane: at the −6e4 rung the f32 ulp is 0.0039, so
|
||
/// 1e-3 would round away; this step is 4 ulps. Every skybox batch pair ties, being eye-anchored.
|
||
pub(crate) const SKYBOX_ORDER_EPS: f32 = 0.015625;
|
||
|
||
/// The skybox band's ceiling: with [`FAR_KEY_PULL`] every batch lands on one pipeline-key integer.
|
||
/// It ties past index 57; the largest skybox, `CavernsOfTimeSky.m2`, has 21 batches.
|
||
pub(crate) const SKYBOX_ORDER_CAP: f32 = 0.9;
|
||
|
||
/// A batch's slot in the skybox band: the rung, pulled onto one pipeline key, plus its order.
|
||
fn skybox_sort_bias(batch_order: u16) -> f32 {
|
||
crate::sky_order::WMO_SKYBOX_BIAS - FAR_KEY_PULL
|
||
+ (f32::from(batch_order) * SKYBOX_ORDER_EPS).min(SKYBOX_ORDER_CAP)
|
||
}
|
||
|
||
/// The depth-prime twin's sort bias in yards (negative draws earlier), past the spread of one
|
||
/// model's part centres so all its twins draw before any colour part, as the reference's one sort
|
||
/// key per instance with twins first does (`cmd+0x14 = model+0x84`, `0x707db1`, `0x70ae10`). 8 yd
|
||
/// covers humanoids and mounts; a taller model can keep some self-overlap, and a transparent
|
||
/// sorting within 8 yd behind a fading body draws after its prime and clips where the body covers
|
||
/// it. As a raster depth bias it is ~2⁻²⁰ relative, away from the eye.
|
||
pub(crate) const ZFILL_SORT_BIAS: f32 = -8.0;
|
||
|
||
/// Build or fetch the depth-prime twin for one fadeable entity batch, the reference's
|
||
/// `M2UseZFill` clone (`0x707f7d`): while a model is translucent, a colour-masked, z-writing copy
|
||
/// of each depth-writing batch draws first, so only its nearest surface blends. `cutout` marks an
|
||
/// AlphaKey source, whose twin keeps its alpha test as the reference's does (mode 0 has none,
|
||
/// `0x70c237`); it must match what the colour pass discards, or the holes get depth walls.
|
||
/// Colour-only inputs are canonical, so twins dedupe.
|
||
pub fn zfill_material(
|
||
cache: &mut MaterialCache,
|
||
materials: &mut Assets<WowModelMaterial>,
|
||
texture: Option<Handle<Image>>,
|
||
two_sided: bool,
|
||
cutout: bool,
|
||
light: &Buffer,
|
||
torch: &TorchBinds,
|
||
) -> Handle<WowModelMaterial> {
|
||
let key = MatKey {
|
||
light: light.id(),
|
||
texture: texture.as_ref().map(Handle::id),
|
||
blend: if cutout {
|
||
ModelBlend::AlphaTest
|
||
} else {
|
||
ModelBlend::Blend
|
||
},
|
||
two_sided,
|
||
is_wmo: false,
|
||
is_interior: false,
|
||
is_emissive: false,
|
||
is_additive: false,
|
||
fade_variant: cutout,
|
||
no_depth_write: false,
|
||
no_depth_test: false,
|
||
fog_policy: benilla_formats::FogPolicy::Scene,
|
||
env_map: false,
|
||
shade: ShadeSel::Lit,
|
||
batch_order: 0,
|
||
uv_anim: None,
|
||
rgb_anim: None,
|
||
wmo_class: None,
|
||
sidn: None,
|
||
window: false,
|
||
zfill: true,
|
||
foliage_wind: false,
|
||
// No skybox batch has a twin: the sky writes no depth.
|
||
sky_depth: false,
|
||
instance: None,
|
||
};
|
||
if let Some(h) = cache.fetch(&key) {
|
||
return h;
|
||
}
|
||
let cull_mode = if two_sided { None } else { Some(Face::Back) };
|
||
let base = StandardMaterial {
|
||
base_color_texture: texture,
|
||
// Blend for the transparent pass's sort; `specialize` turns blend and colour writes off.
|
||
alpha_mode: AlphaMode::Blend,
|
||
depth_bias: ZFILL_SORT_BIAS,
|
||
double_sided: two_sided,
|
||
cull_mode,
|
||
..default()
|
||
};
|
||
let handle = lazy::defer(
|
||
materials,
|
||
ExtendedMaterial {
|
||
base,
|
||
extension: WowModelExt {
|
||
clutter_fade: Vec4::new(
|
||
0.0,
|
||
0.0,
|
||
f32::from(ZFILL_MARKER | if cutout { TWIN_CUTOUT_MARKER } else { 0 }),
|
||
0.0,
|
||
),
|
||
// `y` stays 0: the zfill pipeline forces its own depth write.
|
||
model_flags: Vec4::ZERO,
|
||
sun_scale: Vec4::new(ShadeSel::Lit.selector(), 0.0, 0.0, 0.0),
|
||
tint: Vec4::new(1.0, 1.0, 1.0, 0.0),
|
||
sidn: Vec4::ZERO,
|
||
anim_slots: Vec4::ZERO,
|
||
// MONKEY (skybox): no second stage.
|
||
stage1: Vec4::ZERO,
|
||
stage1_texture: None,
|
||
light_buf: light.clone(),
|
||
// MONKEY (torch shadows Phase 3A): the shared torch receiver bindings.
|
||
torch_depth: torch.depth.clone(),
|
||
torch_buf: torch.table.clone(),
|
||
},
|
||
},
|
||
);
|
||
cache.insert(key, handle.clone());
|
||
handle
|
||
}
|
||
|
||
/// `clutter_fade.z` marker bit 11: a far-side-of-water twin, whose −4e4 sort rung `specialize`
|
||
/// keeps out of the raster depth bias (a 0.5% pull that clips coplanar sheen layers).
|
||
pub(crate) const FAR_SIDE_MARKER: u16 = 1 << 11;
|
||
|
||
/// A transparent material's far-side twin, one water rung down so the water paints over it; the
|
||
/// app's pipeline warm pass builds through this too, so their keys cannot drift.
|
||
pub fn far_twin_of(near: &WowModelMaterial) -> WowModelMaterial {
|
||
let mut far = near.clone();
|
||
far.base.depth_bias = far_sort_bias(far.base.depth_bias);
|
||
far.extension.clutter_fade.z = far_markers(far.extension.clutter_fade.z);
|
||
far
|
||
}
|
||
|
||
/// The pull on every far-side sort slot, so every `near` in `[0, 0.99]` truncates to one
|
||
/// pipeline-key integer; a uniform shift, so nothing reorders.
|
||
const FAR_KEY_PULL: f32 = 0.99;
|
||
|
||
/// The twin's sort slot: one water rung under the source's, less [`FAR_KEY_PULL`].
|
||
fn far_sort_bias(near: f32) -> f32 {
|
||
near + crate::sky_order::FAR_SIDE_BIAS - FAR_KEY_PULL
|
||
}
|
||
|
||
/// The twin's `clutter_fade.z` word: [`FAR_SIDE_MARKER`] added, every other marker kept.
|
||
fn far_markers(z: f32) -> f32 {
|
||
f32::from(z as u16 | FAR_SIDE_MARKER)
|
||
}
|
||
|
||
/// The live far twins, by near id and back. The handles are strong: a pair lives until
|
||
/// [`classify_water_side`]'s sweep finds no batch carrying its near identity.
|
||
#[derive(Resource, Default)]
|
||
pub struct FarSideTwins {
|
||
to_far: std::collections::HashMap<AssetId<WowModelMaterial>, Handle<WowModelMaterial>>,
|
||
to_near: std::collections::HashMap<AssetId<WowModelMaterial>, Handle<WowModelMaterial>>,
|
||
}
|
||
|
||
impl FarSideTwins {
|
||
/// The live far twin of `near`; a miss means it is not built yet, and the caller keeps `near`.
|
||
pub(crate) fn far_of(
|
||
&self,
|
||
near: &Handle<WowModelMaterial>,
|
||
) -> Option<&Handle<WowModelMaterial>> {
|
||
self.to_far.get(&near.id())
|
||
}
|
||
|
||
/// The near identity of a far twin, for the mat-anim registry, which keys by near id.
|
||
pub(crate) fn near_of(
|
||
&self,
|
||
id: AssetId<WowModelMaterial>,
|
||
) -> Option<AssetId<WowModelMaterial>> {
|
||
self.to_near.get(&id).map(bevy::asset::Handle::id)
|
||
}
|
||
}
|
||
|
||
/// The handle a material owner writes: its pick, or the pick's far twin on a [`FarSideOfWater`]
|
||
/// entity, so every writer agrees on one handle per frame. An opaque pick has no twin and stands.
|
||
pub fn far_resolved<'a>(
|
||
want: &'a Handle<WowModelMaterial>,
|
||
far: bool,
|
||
twins: &'a FarSideTwins,
|
||
) -> &'a Handle<WowModelMaterial> {
|
||
if far {
|
||
twins.far_of(want).unwrap_or(want)
|
||
} else {
|
||
want
|
||
}
|
||
}
|
||
|
||
/// This batch draws on the eye's far side of the water plane; for a `DoodadFade` holder the
|
||
/// Visibility authority composes it into its own pick.
|
||
#[derive(Component)]
|
||
pub struct FarSideOfWater;
|
||
|
||
/// How long the twin GC may wait for a full walk after a part despawns: it bounds an orphaned
|
||
/// pair's life while walking's steady despawns cost ~0.5 full walks/s.
|
||
const GC_DEADLINE_SECS: f32 = 2.0;
|
||
|
||
/// Swap each transparent M2 batch onto or off its far-side twin: the reference draws a model's
|
||
/// transparents on the eye's far side before the water pass (`0x707680`), split per model, and
|
||
/// every batch carries its model's transform. An instance inside the `±r` band draws on both
|
||
/// sides, clipped at the waterline (`M2UseClipPlanes`, [`crate::straddle`]); WMO translucents
|
||
/// never classify. Only changed batches re-classify; the eye crossing the surface or surfaces
|
||
/// streaming walk them all.
|
||
#[allow(clippy::type_complexity)]
|
||
pub(crate) fn classify_water_side(
|
||
interleave: crate::particles::WaterInterleave,
|
||
mut twins: ResMut<FarSideTwins>,
|
||
mut materials: ResMut<Assets<WowModelMaterial>>,
|
||
mut near_edits: MessageReader<AssetEvent<WowModelMaterial>>,
|
||
mut commands: Commands,
|
||
// A ParamSet: the `Changed` query would conflict with the walk's `&mut`.
|
||
mut set: ParamSet<(
|
||
Query<
|
||
Entity,
|
||
(
|
||
With<GlobalTransform>,
|
||
Changed<MeshMaterial3d<WowModelMaterial>>,
|
||
),
|
||
>,
|
||
Query<
|
||
(
|
||
Entity,
|
||
&GlobalTransform,
|
||
Option<&bevy::camera::visibility::RenderLayers>,
|
||
&mut MeshMaterial3d<WowModelMaterial>,
|
||
Has<crate::model_fade::DoodadFade>,
|
||
Has<FarSideOfWater>,
|
||
Option<&MeshTag>,
|
||
Has<crate::straddle::StraddlesWater>,
|
||
),
|
||
// A straddle twin is the classification's output, never its input.
|
||
Without<crate::straddle::StraddleTwinOf>,
|
||
>,
|
||
)>,
|
||
moved: Query<
|
||
Entity,
|
||
(
|
||
With<MeshMaterial3d<WowModelMaterial>>,
|
||
Or<(
|
||
Changed<GlobalTransform>,
|
||
Changed<bevy::camera::visibility::RenderLayers>,
|
||
)>,
|
||
),
|
||
>,
|
||
reclaimed: Query<Entity, Changed<crate::wmo_portal::UnitWmoRoom>>,
|
||
// An instance entering or leaving its water band re-classifies its whole subtree.
|
||
rebanded: Query<Entity, Changed<crate::straddle::ModelWaterBand>>,
|
||
clips: Res<crate::straddle::WaterClips>,
|
||
children: Query<&Children>,
|
||
mut removed: RemovedComponents<MeshMaterial3d<WowModelMaterial>>,
|
||
time: Res<bevy::time::Time>,
|
||
mut eye_was_submerged: Local<Option<bool>>,
|
||
mut gc_due: Local<Option<f32>>,
|
||
) {
|
||
// Mirror a near-asset edit into its live twin. Animation needs none (the twin's clone shares
|
||
// the table slot), and twin writes touch only far ids, so this cannot feed back.
|
||
let edited: Vec<AssetId<WowModelMaterial>> = near_edits
|
||
.read()
|
||
.filter_map(|ev| match ev {
|
||
AssetEvent::Modified { id } if twins.to_far.contains_key(id) => Some(*id),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
for id in edited {
|
||
if let (Some(near), Some(far)) = (materials.get(id).cloned(), twins.to_far.get(&id)) {
|
||
let far = far.id();
|
||
// Cannot fail: `to_far` holds the twin's strong handle.
|
||
materials
|
||
.insert(far, far_twin_of(&near))
|
||
.expect("far twin slot is strongly held");
|
||
}
|
||
}
|
||
let eye = interleave.eye_submerged();
|
||
let now = time.elapsed_secs();
|
||
// `last()` drains the reader (`next()` would re-arm next frame), and the earliest despawn
|
||
// holds the deadline, so a steady stream cannot push the GC out forever.
|
||
if removed.read().last().is_some() {
|
||
gc_due.get_or_insert(now + GC_DEADLINE_SECS);
|
||
}
|
||
let full = *eye_was_submerged != Some(eye)
|
||
|| interleave.surfaces_changed()
|
||
|| gc_due.is_some_and(|due| now >= due);
|
||
*eye_was_submerged = Some(eye);
|
||
if full {
|
||
// Any full walk runs the GC's mark, which satisfies a pending deadline.
|
||
*gc_due = None;
|
||
}
|
||
|
||
if full {
|
||
let mut used: std::collections::HashSet<AssetId<WowModelMaterial>> =
|
||
std::collections::HashSet::new();
|
||
for item in set.p1().iter_mut() {
|
||
classify_part(
|
||
&interleave,
|
||
&clips,
|
||
&mut twins,
|
||
&mut materials,
|
||
&mut commands,
|
||
Some(&mut used),
|
||
item,
|
||
);
|
||
}
|
||
// Drop pairs whose near identity no live batch carries; a respawn rebuilds its pair lazily.
|
||
if !twins.to_far.is_empty() {
|
||
let stale: Vec<AssetId<WowModelMaterial>> = twins
|
||
.to_far
|
||
.keys()
|
||
.filter(|k| !used.contains(*k))
|
||
.copied()
|
||
.collect();
|
||
for k in stale {
|
||
if let Some(f) = twins.to_far.remove(&k) {
|
||
twins.to_near.remove(&f.id());
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
// Only the changed set; an entity dirty on two legs classifies twice, the second a no-op.
|
||
let handle_dirty: Vec<Entity> = set.p0().iter().collect();
|
||
let mut parts = set.p1();
|
||
for e in moved.iter().chain(handle_dirty) {
|
||
if let Ok(item) = parts.get_mut(e) {
|
||
classify_part(
|
||
&interleave,
|
||
&clips,
|
||
&mut twins,
|
||
&mut materials,
|
||
&mut commands,
|
||
None,
|
||
item,
|
||
);
|
||
}
|
||
}
|
||
// A changed room claim or straddle band re-classifies the holder's subtree: both live on
|
||
// the unit root, above its batches, and a claim can change with the unit standing still.
|
||
let mut stack: Vec<Entity> = reclaimed.iter().chain(rebanded.iter()).collect();
|
||
while let Some(e) = stack.pop() {
|
||
if let Ok(ch) = children.get(e) {
|
||
stack.extend(ch.iter());
|
||
}
|
||
if let Ok(item) = parts.get_mut(e) {
|
||
classify_part(
|
||
&interleave,
|
||
&clips,
|
||
&mut twins,
|
||
&mut materials,
|
||
&mut commands,
|
||
None,
|
||
item,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// One candidate batch for [`classify_part`]: entity, transform, layers, handle, whether the
|
||
/// Visibility authority owns the handle, marked far, tag (the straddle slot), marked straddling.
|
||
type PartItem<'a> = (
|
||
Entity,
|
||
&'a GlobalTransform,
|
||
Option<&'a bevy::camera::visibility::RenderLayers>,
|
||
Mut<'a, MeshMaterial3d<WowModelMaterial>>,
|
||
bool,
|
||
bool,
|
||
Option<&'a MeshTag>,
|
||
bool,
|
||
);
|
||
|
||
/// Classify one batch. `used` collects the live near identities for the twin GC's mark; the
|
||
/// reactive path passes `None`.
|
||
fn classify_part(
|
||
interleave: &crate::particles::WaterInterleave,
|
||
clips: &crate::straddle::WaterClips,
|
||
twins: &mut FarSideTwins,
|
||
materials: &mut Assets<WowModelMaterial>,
|
||
commands: &mut Commands,
|
||
used: Option<&mut std::collections::HashSet<AssetId<WowModelMaterial>>>,
|
||
(entity, gt, layers, mut mat, authority_owned, marked, tag, straddling): PartItem<'_>,
|
||
) {
|
||
use bevy::camera::visibility::RenderLayers;
|
||
// A booth-layered batch has its own camera and scene, with no world water.
|
||
if layers.is_some_and(|l| !l.intersects(&RenderLayers::default())) {
|
||
return;
|
||
}
|
||
let cur = mat.0.id();
|
||
let (near, swapped) = match twins.to_near.get(&cur) {
|
||
Some(n) => (n.clone(), true),
|
||
None => (mat.0.clone(), false),
|
||
};
|
||
// The current verdict: the marker, or the handle for the batches this system swaps itself.
|
||
let far_now = marked || swapped;
|
||
let qualifies = match materials.get(near.id()) {
|
||
Some(m) => {
|
||
matches!(m.base.alpha_mode, AlphaMode::Blend)
|
||
&& m.extension.model_flags.x <= 0.5
|
||
// A WMO skybox sits at the eye and the far depth, on no side of the water plane.
|
||
&& (m.extension.clutter_fade.z as u16) & SKY_DEPTH_MARKER == 0
|
||
}
|
||
None => false,
|
||
};
|
||
if !qualifies {
|
||
// A part back on its opaque cutout has no side until it blends again.
|
||
if marked {
|
||
commands.entity(entity).remove::<FarSideOfWater>();
|
||
}
|
||
if straddling {
|
||
commands
|
||
.entity(entity)
|
||
.remove::<crate::straddle::StraddlesWater>();
|
||
}
|
||
return;
|
||
}
|
||
if let Some(used) = used {
|
||
used.insert(near.id());
|
||
}
|
||
// A straddler keeps its near identity, clipped to the eye's half, and `sync_straddle_twins`
|
||
// hangs its far twin beside it; the verdict is the word the clip itself reads.
|
||
let straddles = tag.is_some_and(|t| clips.straddles(crate::mesh_tag::rig_of(t.0)));
|
||
if straddles != straddling {
|
||
trace_side(
|
||
if straddles { "straddle" } else { "unstraddle" },
|
||
entity,
|
||
gt,
|
||
);
|
||
if straddles {
|
||
commands
|
||
.entity(entity)
|
||
.insert(crate::straddle::StraddlesWater);
|
||
} else {
|
||
commands
|
||
.entity(entity)
|
||
.remove::<crate::straddle::StraddlesWater>();
|
||
}
|
||
}
|
||
if straddles {
|
||
// Built here, where the store is writable; the twin sync only reads the map.
|
||
far_twin(twins, materials, &near);
|
||
}
|
||
let far = !straddles
|
||
&& crate::particles::far_side_of_water(interleave, Some(entity), gt.translation());
|
||
if far == far_now {
|
||
return;
|
||
}
|
||
trace_side(if far { "far-side" } else { "near-side" }, entity, gt);
|
||
if far {
|
||
// Built for an authority-owned batch too: its owner's pick resolves through the map.
|
||
let far_h = far_twin(twins, materials, &near);
|
||
commands.entity(entity).insert(FarSideOfWater);
|
||
if !authority_owned {
|
||
mat.0 = far_h;
|
||
}
|
||
} else {
|
||
commands.entity(entity).remove::<FarSideOfWater>();
|
||
if !authority_owned {
|
||
mat.0 = near;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The far twin of `near`, built on first ask; `near` must be live, as `qualifies` proved.
|
||
fn far_twin(
|
||
twins: &mut FarSideTwins,
|
||
materials: &mut Assets<WowModelMaterial>,
|
||
near: &Handle<WowModelMaterial>,
|
||
) -> Handle<WowModelMaterial> {
|
||
if let Some(h) = twins.to_far.get(&near.id()) {
|
||
return h.clone();
|
||
}
|
||
let twin = far_twin_of(materials.get(near.id()).unwrap());
|
||
let h = materials.add(twin);
|
||
twins.to_far.insert(near.id(), h.clone());
|
||
twins.to_near.insert(h.id(), near.clone());
|
||
h
|
||
}
|
||
|
||
/// `WOW_MOVE_TRACE_TAGS=fx`: one line per side transition of one batch.
|
||
fn trace_side(what: &str, entity: Entity, gt: &GlobalTransform) {
|
||
if !benilla_assets::trace::enabled() {
|
||
return;
|
||
}
|
||
let p = gt.translation();
|
||
benilla_assets::trace::line(
|
||
"fx",
|
||
&format!(
|
||
"{what} mesh e={entity} at=[{:.1},{:.1},{:.1}]",
|
||
p.x, p.y, p.z
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Replace the fog-policy bits (4-6) of a packed `clutter_fade.z` word, keeping every other
|
||
/// marker; the mask lives beside the packer so no caller masks the word by hand.
|
||
pub fn replace_fog_policy(z: f32, policy: benilla_formats::FogPolicy) -> f32 {
|
||
f32::from((z as u16 & !(7 << 4)) | ((policy as u16) << 4))
|
||
}
|
||
|
||
/// The model-render lane's registrations.
|
||
pub fn plugin(app: &mut App) {
|
||
// Before `ModelVisSet`, so the authority's pick sees this frame's side, and after the fade
|
||
// resolve. The self feather is unordered: it composes the same marker and can lag one frame
|
||
// as the eye crosses the surface.
|
||
app.init_resource::<FarSideTwins>().add_systems(
|
||
Update,
|
||
classify_water_side
|
||
.after(crate::liquid::SubmersionVerdict)
|
||
.after(crate::model_fade::apply_render_fade)
|
||
.before(crate::model_render::ModelVisSet),
|
||
);
|
||
// The model-`Visibility` authority, the one writer, after the portal PVS it reads.
|
||
app.add_systems(
|
||
Update,
|
||
visibility::apply_model_visibility
|
||
.after(crate::wmo_portal::WmoPvsSet)
|
||
.in_set(ModelVisSet),
|
||
);
|
||
// `WOW_VIS_TRACE=<label>` traces one model's placements.
|
||
if let Some(trace) = visibility::VisTrace::from_env() {
|
||
app.insert_resource(trace);
|
||
}
|
||
app.add_systems(
|
||
Update,
|
||
visibility::trace_model_visibility.after(ModelVisSet),
|
||
);
|
||
// The material dedup cache and its evictors: by distance, and whole on a cross-map transition.
|
||
app.init_resource::<ModelMaterials>()
|
||
.add_systems(Update, (scope_model_materials, evict_model_materials));
|
||
// A material becomes an asset the frame something visible binds it: before the store
|
||
// publishes its queued `Added` event and before `VisibilityPropagate` clears what it reads.
|
||
app.add_systems(
|
||
PostUpdate,
|
||
lazy::realize_bound
|
||
.before(bevy::asset::AssetEventSystems)
|
||
.before(bevy::camera::visibility::VisibilitySystems::VisibilityPropagate),
|
||
);
|
||
// A long-hidden streamed part puts its `Mesh3d` down, on last frame's propagated verdict.
|
||
if park::enabled() {
|
||
app.add_systems(Update, park::park_hidden_parts.after(ModelVisSet));
|
||
}
|
||
}
|
||
|
||
/// Expire the material dedup by distance.
|
||
fn scope_model_materials(mut scope: crate::art_scope::ArtScope, mut mats: ResMut<ModelMaterials>) {
|
||
scope.apply(&mut mats.0, crate::art_scope::ArtSlot::ModelMats);
|
||
}
|
||
|
||
/// Drop the material dedup whole on a cross-map transition; entries rebuild on demand.
|
||
fn evict_model_materials(
|
||
mut changes: MessageReader<crate::world_map::MapChange>,
|
||
mut mats: ResMut<ModelMaterials>,
|
||
) {
|
||
if changes.is_empty() {
|
||
return;
|
||
}
|
||
changes.clear();
|
||
info!("model materials evicted: {}", mats.0.len());
|
||
mats.0.clear();
|
||
}
|
||
|
||
/// The index of a [`ModelKind`] in the panel's toggles and per-kind counts.
|
||
pub fn kind_index(kind: ModelKind) -> usize {
|
||
match kind {
|
||
ModelKind::Doodad => 0,
|
||
ModelKind::Wmo => 1,
|
||
ModelKind::Creature => 2,
|
||
ModelKind::GameObject => 3,
|
||
}
|
||
}
|
||
|
||
/// The index of a [`ModelBlend`] layer, as [`kind_index`] is for kinds.
|
||
pub fn blend_index(b: ModelBlend) -> usize {
|
||
match b {
|
||
ModelBlend::Opaque => 0,
|
||
ModelBlend::AlphaTest => 1,
|
||
ModelBlend::Blend => 2,
|
||
ModelBlend::Mod => 3,
|
||
ModelBlend::Mod2x => 4,
|
||
}
|
||
}
|
||
|
||
/// The world-model subsystem a spawned submesh belongs to, which the panel's toggles scope by.
|
||
#[derive(Component, Clone, Copy, PartialEq, Eq, Hash, Debug)]
|
||
pub enum ModelKind {
|
||
Doodad,
|
||
Wmo,
|
||
Creature,
|
||
GameObject,
|
||
}
|
||
|
||
/// A spawned model submesh's subsystem and blend layer, which the panel toggles on.
|
||
#[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)
|
||
}
|
||
}
|
||
|
||
/// **Why this submesh is an entity at all** — the world streamer's answer, one static label per
|
||
/// batch, for the census (`VIS_CENSUS … why …`). A city keeps thousands of hidden model
|
||
/// submeshes resident on the entity path while the retained pass draws the world (5.6k ADT
|
||
/// doodad submeshes at the Stormwind auction house, 1945), and every one of them rides every
|
||
/// per-`Mesh3d` sweep; which divert declined it is the first question about pricing them.
|
||
/// Absent on units, GameObjects and everything the app lane spawns.
|
||
#[derive(Component, Clone, Copy)]
|
||
pub struct EntityPathWhy(pub &'static str);
|
||
|
||
/// The model-`Visibility` authority's set; the first-person self hide runs after it and wins.
|
||
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||
pub struct ModelVisSet;
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::replace_fog_policy;
|
||
use benilla_formats::FogPolicy;
|
||
|
||
#[test]
|
||
fn replace_fog_policy_preserves_pipeline_markers() {
|
||
// Every marker bit (0-3, 7-8) set, fog policy Grey (3) in bits 4-6.
|
||
let z = f32::from(0b1_1000_1111u16 | (3 << 4));
|
||
let out = replace_fog_policy(z, FogPolicy::Off) as u16;
|
||
assert_eq!(out & 0b1_1000_1111, 0b1_1000_1111, "markers preserved");
|
||
assert_eq!((out >> 4) & 7, FogPolicy::Off as u16, "fog swapped");
|
||
// No stray bits invented.
|
||
let out2 = replace_fog_policy(f32::from(0u16), FogPolicy::Grey) as u16;
|
||
assert_eq!(out2, (FogPolicy::Grey as u16) << 4);
|
||
}
|
||
|
||
/// Too small and coplanar layers strobe; too large and effects interleave into their owner.
|
||
#[test]
|
||
fn batch_order_sort_eps_sits_between_f32_noise_and_the_effect_rung() {
|
||
// The capped product is the deepest any batch can bias; WMO roots index past 2000.
|
||
let deepest =
|
||
(super::BATCH_ORDER_SORT_EPS * f32::from(u16::MAX)).min(super::BATCH_ORDER_SORT_CAP);
|
||
assert!(
|
||
deepest < benilla_formats::owner_last_rung(0.0),
|
||
"a model's last batch must still sort before its own effects' rung"
|
||
);
|
||
let far_noise = 500.0_f32 * f32::EPSILON;
|
||
assert!(
|
||
super::BATCH_ORDER_SORT_EPS > 8.0 * far_noise,
|
||
"one order step must dominate f32 noise on a far sort distance"
|
||
);
|
||
// The pipeline key truncates the whole band to 0 for any u16 index.
|
||
assert_eq!(deepest as i32, 0);
|
||
const {
|
||
assert!(super::BATCH_ORDER_SORT_CAP < 1.0);
|
||
// A capped batch's far twin still truncates onto the far band's one key integer.
|
||
assert!(super::BATCH_ORDER_SORT_CAP < super::FAR_KEY_PULL);
|
||
}
|
||
assert_eq!(
|
||
super::far_sort_bias(super::BATCH_ORDER_SORT_CAP) as i32,
|
||
super::far_sort_bias(0.0) as i32,
|
||
"the far band stays one pipeline key across the whole capped eps range"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn the_skybox_band_orders_its_batches_on_one_pipeline_key() {
|
||
// `CavernsOfTimeSky.m2` is 21 batches; `order` is the index + 1.
|
||
let band: Vec<f32> = (0..=21).map(super::skybox_sort_bias).collect();
|
||
|
||
// 1. Strictly increasing: at −6e4 the ordinary 1e-3 eps would round onto one f32.
|
||
for pair in band.windows(2) {
|
||
assert!(
|
||
pair[1] > pair[0],
|
||
"skybox batch order does not resolve at the rung's magnitude: {pair:?} — \
|
||
SKYBOX_ORDER_EPS is under the f32 ulp there"
|
||
);
|
||
}
|
||
|
||
// 2. One pipeline key for the whole band (`depth_bias as i32` is a key axis), for any
|
||
// u16 order: the cap holds it.
|
||
let key = band[0] as i32;
|
||
for order in [0u16, 1, 21, 57, 58, 1000, u16::MAX] {
|
||
assert_eq!(
|
||
super::skybox_sort_bias(order) as i32,
|
||
key,
|
||
"batch order {order} mints a second skybox pipeline"
|
||
);
|
||
}
|
||
|
||
// 3. Under every world transparent, even with the skybox's shell radius (~94 yd for
|
||
// Caverns of Time) on its rung and a world draw at the far plane.
|
||
const FAR_PLANE: f32 = 3000.0;
|
||
const SHELL: f32 = 128.0;
|
||
let deepest_world = crate::sky_order::FAR_SIDE_BIAS - FAR_PLANE - super::FAR_KEY_PULL;
|
||
assert!(
|
||
band.last().unwrap() + SHELL < deepest_world,
|
||
"a skybox can sort in front of a world transparent — the painted sky would paint \
|
||
over it (sky_order::WMO_SKYBOX_BIAS)"
|
||
);
|
||
|
||
// 4. The rung stays within ten times the world band: margin, not drift.
|
||
const {
|
||
assert!(crate::sky_order::WMO_SKYBOX_BIAS > 10.0 * crate::sky_order::FAR_SIDE_BIAS);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn far_twin_keeps_the_source_identity_one_rung_down() {
|
||
assert_eq!(
|
||
super::far_sort_bias(super::ZFILL_SORT_BIAS),
|
||
crate::sky_order::FAR_SIDE_BIAS + super::ZFILL_SORT_BIAS - super::FAR_KEY_PULL,
|
||
"one rung down from wherever the source sat (less the key-collapse pull)"
|
||
);
|
||
assert!(
|
||
super::far_sort_bias(super::ZFILL_SORT_BIAS)
|
||
< super::far_sort_bias(super::BATCH_ORDER_SORT_EPS * 512.0),
|
||
"a far zfill twin still primes before its model's far colour batches"
|
||
);
|
||
// The whole batch-eps band truncates to one pipeline-key integer; the zfill twin keeps
|
||
// its own one bracket down.
|
||
assert_eq!(
|
||
super::far_sort_bias(0.0) as i32,
|
||
super::far_sort_bias(super::BATCH_ORDER_SORT_EPS * 512.0) as i32,
|
||
"batch-order far twins may never mint a second pipeline"
|
||
);
|
||
const {
|
||
assert!(super::FAR_KEY_PULL > super::BATCH_ORDER_SORT_EPS * 512.0);
|
||
}
|
||
assert_eq!(
|
||
super::far_sort_bias(super::ZFILL_SORT_BIAS) as i32,
|
||
crate::sky_order::FAR_SIDE_BIAS as i32 + super::ZFILL_SORT_BIAS as i32,
|
||
"the zfill far key stays put — the pull is sub-integer"
|
||
);
|
||
let src = super::ZFILL_MARKER | super::TWIN_CUTOUT_MARKER | (3 << 4);
|
||
let z = super::far_markers(f32::from(src)) as u16;
|
||
assert_ne!(z & super::FAR_SIDE_MARKER, 0, "the sort-only pipeline bit");
|
||
assert_eq!(
|
||
z & !super::FAR_SIDE_MARKER,
|
||
src,
|
||
"every other marker preserved"
|
||
);
|
||
// The marker word stays f32-exact with every bit up to `FAR_SIDE_MARKER` set.
|
||
let all = src | super::FAR_SIDE_MARKER | 0x0f;
|
||
assert_eq!(f32::from(all) as u16, all);
|
||
}
|
||
}
|