- corpses render: a corpse create spawned a real entity, latched its guid for the reclaim send, and drew nothing — one missing EntityKind variant folded it into the catch-all and four downstream lanes then failed by default, not even the debug cube fired; the reference forks once, on the bones flag: a fresh body builds a character composite from the corpse's OWN appearance bytes and 19 item slots through the same compositor a living player is dressed by (its own hide-helm/hide-cloak bits honoured, the ranged slot never dresses, and weapons never dress because the client looks a packed display word up as a guid — we reproduce the outcome, not the dead lookup), while a bone pile takes its race and sex's DeathSkeleton, sixteen of which ship; the pose is one settled arm — Dead, or Drowned when the body lies more than 0.66666669 yd under a liquid surface — and a corpse is not a unit, so clicking one no longer sends a selection for a guid no unit owns
- a lit particle takes its MODEL's committed light node, not the sky's: indoors the reference lights an object's particles from the object's own node — the footprint's baked floor colours on the fixed interior axis plus the room's placed lights — never the day/night sun; a particle quad carries exactly one normal, world up, one constant per draw, so the whole node collapses to three floats, published off the same fold the model's mesh renders from so a mesh and its own particles can never disagree about the room they stand in; in Onyxia's Lair the 78 lit falling-rock emitters on the 26 anchors that classify interior now take the room's light
- and that lit lane is FIXED FUNCTION, not the mesh's soft SH curve: the shipped client null-binds both shader slots for a particle batch and re-zeroes the shader mask at the head of every batch, so a model and its own particles share the same committed light words and legitimately differ in the curve applied to them — the mesh's wrapping lobe against the hardware's max(N·L, 0) is 0.900 vs 0.869 on the key axis, up to the three nearest room lights arrive diffuse-only under GL's own 1/(0.7d + 0.03d²) falloff (a light at or below the emitter cannot reach it at all), and the clamp sits on the product, never the multiplier, because it is GL that clamps the lit vertex colour
- the cinematic letterbox has no aspect test in it: CinematicFrame.lua's if guards a recompute, not whether there are bars — below 4:3 the reference's bar textures simply stand at their declared 1024x128 on the client's native 1024x768 sheet, which is height/6, the same formula evaluated at exactly 4:3 — so one expression covers every window shape, min((h − min(w/2, h))/2, h/6): a 2:1 picture when wide, bars at 4:3 too; and a shown keyboard frame swallows EVERY key, not the ten names the host used to feed it — you could walk around underneath your own intro; the proof is the reference's own handler, which has to RunBinding("SCREENSHOT") by hand to get one key back; ESC still skips, and a W already held when the shot starts keeps running, which is faithful too
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
695 lines
32 KiB
Rust
695 lines
32 KiB
Rust
//! The **shared effect stream** — the one CPU vertex stream every dynamic-effect family writes
|
||
//! into each frame (particles, ribbons, and — since 0733 — the decal family, water foam, and
|
||
//! precipitation), and the draw records that slice it (decisions 0732 P1/P2, 0733).
|
||
//!
|
||
//! Before this, every effect owned a `Mesh` asset rewritten per frame: ~145 mesh `Modified`
|
||
//! events/frame at the LBRS pin, each a full free+realloc in Bevy's mesh allocator (no partial
|
||
//! update exists — `allocator.rs:650`), and together they held the `AssetChanged<Mesh3d>`
|
||
//! short-circuit open so every material type's specialization probe ran over its whole
|
||
//! population every frame. One shared buffer + directly-constructed `Transparent3d` items
|
||
//! (the bevy_ui_render shape) converts the whole family from population-priced to
|
||
//! change-priced: one `write_buffer` per frame, zero mesh assets, zero material assets.
|
||
//!
|
||
//! The write protocol: a system calls [`EffectQuads::begin`], pushes **world-space** vertices —
|
||
//! whole quads (4 corners in perimeter order, closed by the `[0,1,2, 0,2,3]` pattern) or a
|
||
//! triangle list — then commits one draw for the range ([`EffectQuads::commit_quads`] /
|
||
//! [`EffectQuads::commit_tris`]). The render half rebases every draw's vertices against its
|
||
//! target view's camera position before upload (0733 §2 — absolute coordinates through the view
|
||
//! transform shear thin geometry apart far from the origin; the precip module learned this
|
||
//! empirically at ~9000 yd), builds the frame's index stream in sorted-item order, and merges
|
||
//! sort-adjacent draws that share (pipeline, texture, light, fog) into single draw calls
|
||
//! (0732 P2). The sort key is [`EffectDraw::anchor`] view-z + [`EffectDraw::bias`] — the ladder
|
||
//! rungs (owner-last 0719/0721, the decal biases, foam's water tie-break) moved from material
|
||
//! `depth_bias` into the item key (`sky_order`'s sign law); the rasterizer half of the old
|
||
//! material `depth_bias` lives on as [`EffectDraw::raster_bias`] (the coplanar decals need it).
|
||
//!
|
||
//! The rebase is a *late* subtraction, which is fine until a producer's geometry is smaller than
|
||
//! an f32 ULP at the position it writes — the rounding has already happened by then. The snow
|
||
//! flake reached that (millimetre quads at ~5600-yd coordinates), so a draw may declare
|
||
//! [`EffectDrawSpec::cam_relative`] and do the subtraction itself.
|
||
|
||
use std::ops::Range;
|
||
|
||
use benilla_formats::{ModelBlend, ParticleBlend};
|
||
use bevy::asset::AssetId;
|
||
use bevy::prelude::*;
|
||
use bevy::render::render_resource::Buffer;
|
||
|
||
/// One vertex of the shared lane. **World-space** position in the stream; the render-world
|
||
/// prepare pass rebases it camera-relative before upload (0733 §2), so instruments reading the
|
||
/// stream (depth probe, depth dump) always see world coordinates — with one declared exception,
|
||
/// [`EffectDrawSpec::cam_relative`], whose producer has already done the subtraction because its
|
||
/// geometry is too small to survive being written in absolute-world f32 at all.
|
||
#[repr(C)]
|
||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||
pub struct EffectVertex {
|
||
pub pos: [f32; 3],
|
||
pub uv: [f32; 2],
|
||
/// RAW authored gamma-space RGBA (the GAMMA LANE invariant, 0161) — alpha is the blend
|
||
/// weight, never encoded.
|
||
pub color: [f32; 4],
|
||
}
|
||
|
||
/// The lane's blend variants (0733 §4) — a superset of the file-format enums it serves:
|
||
/// [`ParticleBlend`]'s four, plus the multiplicative pair the decal family and rain need.
|
||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
|
||
pub enum EffectBlend {
|
||
/// `(SRC_ALPHA, ONE)` via premultiplied-alpha + the shader's gamma `rgb·a` fold (0160/0161).
|
||
Add,
|
||
/// Standard alpha blending.
|
||
Alpha,
|
||
/// No blend, depth-write ON — drawn in the transparent bracket at the owner rung (0719).
|
||
Opaque,
|
||
/// [`EffectBlend::Opaque`]'s state plus the fixed-function **alpha test** — the fragment is
|
||
/// discarded below `224/255` ([`benilla_assets`]'s `VANILLA_ALPHA_KEY_REF`, the same ref the
|
||
/// mesh path's cutouts take). EGxBlend 1, the debris/chip family; the byte chain that pins
|
||
/// blend-off + z-write-on + that ref is on [`ParticleBlend::AlphaKey`].
|
||
AlphaKey,
|
||
/// `dst · lerp(1, src, α)` — bevy's `AlphaMode::Multiply` state (`(Dst, 1−srcα)` + shader
|
||
/// premultiply, bevy_pbr mesh.rs:2486): the blob shadow's `GL_DST_COLOR/GL_ZERO`-with-fade,
|
||
/// and `ModelBlend::Mod` (0528) at α = 1.
|
||
Multiply,
|
||
/// `2·src·dst` — `(Dst, Src)`, 0528's factors; rain's verified state (rf-weather-render).
|
||
Mod2x,
|
||
}
|
||
|
||
impl From<ParticleBlend> for EffectBlend {
|
||
fn from(blend: ParticleBlend) -> Self {
|
||
match blend {
|
||
ParticleBlend::Add => EffectBlend::Add,
|
||
ParticleBlend::Alpha => EffectBlend::Alpha,
|
||
ParticleBlend::AlphaKey => EffectBlend::AlphaKey,
|
||
ParticleBlend::Opaque => EffectBlend::Opaque,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl EffectBlend {
|
||
/// The ground-fx mapping from a part's authored blend — `model_render.rs`'s law with the
|
||
/// lane's two named approximations: `AlphaTest` folds to `Alpha` (**deliberately not**
|
||
/// [`EffectBlend::AlphaKey`], which the particle side now takes — the groundscan census says
|
||
/// flat `Spells\` quads are blend batches, and a cutout ref on a soft-edged decal would bite
|
||
/// its fade), and the part renders unlit (spell fx are; a lit ground quad would differ — none
|
||
/// observed).
|
||
///
|
||
/// `additive` is a SECOND, non-optional input because [`ModelBlend`] cannot express additive:
|
||
/// M2 blend modes 3/4 fold into its `Blend` variant (see its own doc, "Alpha-blended /
|
||
/// additive"), and the material path recovers them from `model_render`'s separate
|
||
/// `is_additive` flag. Taking only the enum made this function *unable* to be right for an
|
||
/// additive batch — every `Spells\` ground quad is mode 4, so Arcane Explosion / Blast Wave /
|
||
/// Battle Shout drew their black-backed additive art alpha-blended: an opaque black tile
|
||
/// (decision 0748). Keeping it in the signature is what stops the next caller repeating it.
|
||
pub fn from_model(blend: ModelBlend, additive: bool) -> Self {
|
||
if additive {
|
||
// `BLEND_ADD` is byte-for-byte the material path's additive: the shader gamma-
|
||
// premultiplies (`rgb·α`) and returns α = 0, turning `(One, 1−srcα)` into pure
|
||
// addition — the same fold `specialize` gates on marker bit 2 (0160/0161).
|
||
return EffectBlend::Add;
|
||
}
|
||
match blend {
|
||
ModelBlend::Opaque => EffectBlend::Opaque,
|
||
ModelBlend::AlphaTest | ModelBlend::Blend => EffectBlend::Alpha,
|
||
ModelBlend::Mod => EffectBlend::Multiply,
|
||
ModelBlend::Mod2x => EffectBlend::Mod2x,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// How a draw's vertex range is indexed.
|
||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||
pub enum EffectTopology {
|
||
/// Whole quads: 4 perimeter-order corners each, indexed `[b, b+1, b+2, b, b+2, b+3]`.
|
||
Quads,
|
||
/// A plain triangle list: identity indices (the decal projector's fans, rain's streaks).
|
||
Tris,
|
||
}
|
||
|
||
/// The fog COLOUR policy for one draw — `params.x`/`params.y` of the effect shader; each
|
||
/// variant is one canonical row of the render-world params uniform.
|
||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
|
||
pub enum EffectFog {
|
||
/// File flag 0x8 ("unfogged") — fog disabled outright (params.x = 0). Also the decal
|
||
/// family's and foam's verified state.
|
||
Off,
|
||
/// The ordinary day-night scene fog (params.x = 1) — Alpha/Opaque blends.
|
||
Scene,
|
||
/// Fog toward BLACK (params.x = 2) — an Add-blend emitter fades under a veil instead of
|
||
/// gaining grey (the same per-blend fog table M2 batches take, `0x70baf0`).
|
||
Black,
|
||
/// Fog toward WHITE (params.x = 3) — the `0x70baf0` table's Mod policy (a multiplier fades
|
||
/// to the identity, not to the scene colour). Ground-fx decals with `ModelBlend::Mod`.
|
||
White,
|
||
/// Fog toward GREY-0.5 (params.x = 4) — the table's Mod2x policy (grey is 2·src·dst's
|
||
/// neutral). Ground-fx decals with `ModelBlend::Mod2x`.
|
||
Grey,
|
||
/// Rain's FORCED grey fog (scene fog off; params.y = 1 with zw = 70..75) — under Mod2x the
|
||
/// grey-0.5 fog colour is neutral, so this IS the streak/patter distance fade
|
||
/// (rf-weather-render Q3; the row values live with their law in `weather::precip`).
|
||
Rain,
|
||
}
|
||
|
||
impl EffectFog {
|
||
/// The policy for one particle/ribbon def — the exact table `particle_material` applied
|
||
/// (file flag 0x8 wins, then Add ⇒ black, else scene).
|
||
pub fn for_blend(flags: u32, blend: ParticleBlend) -> Self {
|
||
if flags & 0x8 != 0 {
|
||
EffectFog::Off
|
||
} else if matches!(blend, ParticleBlend::Add) {
|
||
EffectFog::Black
|
||
} else {
|
||
EffectFog::Scene
|
||
}
|
||
}
|
||
|
||
/// The `0x70baf0` fog policy baked into a model material's `clutter_fade.z` bits 4..7
|
||
/// (`wow_model.wgsl:761` decodes the same field): 0 scene, 1 black, 2 white, 3 grey,
|
||
/// 4 unfogged — the ground-fx decal reads its part's authored policy through this.
|
||
pub fn from_model_policy(policy: u32) -> Self {
|
||
match policy {
|
||
1 => EffectFog::Black,
|
||
2 => EffectFog::White,
|
||
3 => EffectFog::Grey,
|
||
4 => EffectFog::Off,
|
||
_ => EffectFog::Scene,
|
||
}
|
||
}
|
||
|
||
/// The slot index into the render-world params uniform (one canonical `vec4` per policy).
|
||
pub fn slot(self) -> u32 {
|
||
match self {
|
||
EffectFog::Off => 0,
|
||
EffectFog::Scene => 1,
|
||
EffectFog::Black => 2,
|
||
EffectFog::White => 3,
|
||
EffectFog::Grey => 4,
|
||
EffectFog::Rain => 5,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Where one draw's light comes from — the lane's third per-draw identity axis, beside
|
||
/// [`EffectBlend`] and [`EffectFog`].
|
||
///
|
||
/// The reference has no particle material: it synthesizes an `M2Material` from the emitter's file
|
||
/// record every draw and runs the ordinary per-batch state producer over it, so `GL_LIGHTING`
|
||
/// lands on a particle quad exactly as it does on a mesh submesh — and the light it lands with is
|
||
/// the **model's own light node**, not the scene's (wow-re `part-lit-normal-space.md` §5/§6). Which
|
||
/// node the model is on is what these three variants name.
|
||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Default)]
|
||
pub enum EffectLighting {
|
||
/// The authored colour burns as it is. The lane's default, and every family but one: ribbons,
|
||
/// decals, the rings/reticle and precipitation are all authored to burn at their own colour,
|
||
/// as is an M2 emitter that SETS the file's unlit bit 0x1 (the fire and spell corpus, which is
|
||
/// why it stays bright at night).
|
||
#[default]
|
||
None,
|
||
/// The **scene's** light, applied per view in the fragment shader against the world up axis —
|
||
/// the outdoor lane, and the one a booth's own light buffer overrides. 400 of the corpus's
|
||
/// 7792 emitters clear the unlit bit and land here, nearly all of them `World\` environment
|
||
/// sheets: waterfall spray, chimney smoke, blown dust, snow, which read as full-white cutouts
|
||
/// against shaded terrain when the term is missing (the Zul'Gurub waterfall foam).
|
||
/// [`EffectDraw::lit`] is this variant and only this variant.
|
||
Scene,
|
||
/// The emitter's model carries its **own committed light**: it stands inside a WMO room, where
|
||
/// the reference lights the object from its light node's footprint-MOCV words on a fixed axis
|
||
/// plus the room's MOLT points, and never from the day/night sun (`part-lit-normal-space.md`
|
||
/// §6.7, the WENTITY provider `0x6a7300`'s interior leg).
|
||
///
|
||
/// A particle quad carries exactly one normal, so that whole node collapses to three floats
|
||
/// ([`crate::interior::ParticleLight`]) and the producer has **already folded them into this
|
||
/// draw's vertex colours** — which is why this shares a pipeline with [`Self::None`]: there is
|
||
/// nothing left for the shader to do. Per-draw constants belong in the vertex stream on a lane
|
||
/// whose whole design is one shared buffer of them.
|
||
Committed,
|
||
}
|
||
|
||
/// A per-emitter light-buffer override: this emitter's fragment reads THIS `WowLight` blob
|
||
/// instead of the world's shared one. The glue-scene booths are the one author (decision 0539
|
||
/// §5 — their braziers are fogged by the SCENE's own light buffer, the ModelFFX fog that
|
||
/// covers the whole backdrop model); the call site inserts it on the spawned emitter entity.
|
||
#[derive(Component, Clone)]
|
||
pub struct EffectLightOverride(pub Buffer);
|
||
|
||
/// One draw of the shared lane: a contiguous vertex range, its texture/blend/fog identity, and
|
||
/// the sort point the render-world queue keys it by.
|
||
pub struct EffectDraw {
|
||
/// The MAIN-world camera entity whose view this draw belongs to (the world camera, or a
|
||
/// booth camera for a booth-layered emitter — the sim already resolves this per emitter).
|
||
/// The render-world phase lookup keys on the retained view's main entity, so no
|
||
/// `RenderLayers` plumbing is needed render-side.
|
||
pub cam: Entity,
|
||
pub(crate) texture: AssetId<Image>,
|
||
pub(crate) blend: EffectBlend,
|
||
pub(crate) topology: EffectTopology,
|
||
pub(crate) fog: EffectFog,
|
||
/// Does the SHADER multiply this draw's RGB by the scene's light? True for
|
||
/// [`EffectLighting::Scene`] alone — a pipeline-key axis, so the merge walk separates lit from
|
||
/// unlit runs through the pipeline id. A [`EffectLighting::Committed`] draw is `false`: its
|
||
/// light is already in the vertices.
|
||
pub lit: bool,
|
||
/// The cloud's sort point — the emitter anchor / ribbon head node / decal center, exactly
|
||
/// the sort point the material path used.
|
||
pub(crate) anchor: Vec3,
|
||
/// The ladder rung added to the view-space sort distance — owner-last (0719/0721) for
|
||
/// emitters, the decal constants (ring/ground-fx 8192, shadow 4096), foam's +1 water
|
||
/// tie-break; `sky_order`'s sign law (positive draws later).
|
||
pub(crate) bias: f32,
|
||
/// The rasterizer `DepthBiasState` constant for this draw's pipeline (0733 §4): the
|
||
/// coplanar decals keep the depth-offset half their materials carried (projected verts are
|
||
/// exact sub-pieces of drawn ground — clip-interpolated vertices land within ULPs of it);
|
||
/// everything free-floating passes 0. Nonzero ALSO selects the decal transform (0781):
|
||
/// the draw's verts skip the cam-relative rebase and run the world-mesh `clip_from_world`,
|
||
/// so the depth tie the bias settles is against the same arithmetic.
|
||
pub(crate) raster_bias: i32,
|
||
/// Are this draw's vertices **already camera-relative**? See [`EffectDrawSpec::cam_relative`].
|
||
pub(crate) cam_relative: bool,
|
||
/// Vertex range in [`EffectQuads::verts`] (a multiple of 4 for quads, 3 for tris).
|
||
pub range: Range<u32>,
|
||
/// The producing entity — the phase probe's identity for this item (`item.entity.1`, so a
|
||
/// phase line still names the pool that produced it).
|
||
pub main_entity: Entity,
|
||
/// [`EffectLightOverride`]'s buffer, when the producer carries one (`None` = the world's
|
||
/// shared light buffer).
|
||
pub(crate) light: Option<Buffer>,
|
||
}
|
||
|
||
/// The frame's shared stream. Cleared at the top of `PostUpdate`'s effect set
|
||
/// ([`begin_effect_frame`]), filled by the family systems, copied to the render world in
|
||
/// `ExtractSchedule`.
|
||
#[derive(Resource)]
|
||
pub struct EffectQuads {
|
||
pub verts: Vec<EffectVertex>,
|
||
pub draws: Vec<EffectDraw>,
|
||
/// Has [`begin_effect_frame`] run yet **this** frame? A writer that commits while this is
|
||
/// `false` ran BEFORE the clear, so everything it pushed is about to be erased — a silent,
|
||
/// total loss of that lane's frame, with its arithmetic entirely correct.
|
||
///
|
||
/// This is the shape of B161: the chain beam declared its pose dependencies but not
|
||
/// `.after(begin_effect_frame)`, the clear carried one extra `.after` the beam did not, and
|
||
/// the beam became runnable first. Nothing warned; the lane simply never drew. The ordering
|
||
/// is unenforceable by the type system (any writer can forget an edge), so the protocol is
|
||
/// asserted here instead — [`Self::commit`] refuses a pre-clear write in debug builds, and
|
||
/// [`clear_effect_frame_flag`] arms it again in `Last`.
|
||
cleared_this_frame: bool,
|
||
}
|
||
|
||
impl Default for EffectQuads {
|
||
fn default() -> Self {
|
||
Self {
|
||
verts: Vec::new(),
|
||
draws: Vec::new(),
|
||
// Armed, not tripped: a fixture that commits into a bare stream (most of the family's
|
||
// unit tests) is testing geometry, not schedule order, and has no clear to run. The
|
||
// `Last` re-arm is what makes the flag mean anything, and it only exists in an app
|
||
// carrying `ParticlePlugin` — where the very first frame's clear precedes any writer
|
||
// regardless, and a mis-ordered writer trips on frame two and every frame after.
|
||
cleared_this_frame: true,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Everything about one draw except its vertex range — the argument bundle `commit_quads` /
|
||
/// `commit_tris` close a range with.
|
||
pub struct EffectDrawSpec {
|
||
pub cam: Entity,
|
||
pub texture: AssetId<Image>,
|
||
pub blend: EffectBlend,
|
||
pub fog: EffectFog,
|
||
/// Which light this draw's authored colour meets — see [`EffectLighting`]. The scene's term is
|
||
/// `clamp(ambient + diffuse·max(N·L,0))` against the **world up axis**, the same shape
|
||
/// `wow_model.wgsl` applies to a mesh: the reference's quad writer uploads one constant normal
|
||
/// for the whole draw — world +Z carried into eye space, against a light carried into the same
|
||
/// frame, so the product is the view-invariant `worldUp · worldLightDir` (wow-re
|
||
/// `part-lit-normal-space.md`; decision 1696 supersedes 0975's camera-facing reading). The
|
||
/// gate on whether an emitter takes any of it is the file's unlit bit
|
||
/// ([`benilla_formats::ParticleEmitterDef::lit`], byte law there).
|
||
pub lighting: EffectLighting,
|
||
pub anchor: Vec3,
|
||
pub bias: f32,
|
||
pub raster_bias: i32,
|
||
/// Set when the producer has **already** written camera-relative vertices, so
|
||
/// [`super::render`]'s rebase must skip this draw. The default (`false`) is the lane's
|
||
/// contract: producers write ABSOLUTE world positions and the rebase subtracts the camera
|
||
/// on the upload copy.
|
||
///
|
||
/// It exists for **precision**, and precisely one family needs it. `EffectVertex::pos` is
|
||
/// f32, WoW world coordinates run to ±17066 yd, and the rebase happens *after* the producer's
|
||
/// rounding — so a vertex offset smaller than an ULP at the writing position is simply lost.
|
||
/// Every other family's geometry is centimetre-scale or bigger and never notices. The snow
|
||
/// flake is the lane's first **sub-centimetre** geometry: reproducing the reference's point
|
||
/// sprite (14 px at the eye) puts a near flake's half-extent at ~1.6 mm, which at Kharanos's
|
||
/// ~5600-yd coordinates is **3 ULPs** and at a map corner **0.8** — measured: a 14 px sprite
|
||
/// 0.3 yd from the eye loses 2.4 px of width there and 6+ px at the map edge, i.e. visible
|
||
/// size flicker and dropouts on exactly the flakes the eye is drawn to. Writing the offsets
|
||
/// camera-relative keeps every term small and the arithmetic exact.
|
||
///
|
||
/// The sort [`Self::anchor`] stays absolute either way — it is not a vertex.
|
||
pub cam_relative: bool,
|
||
pub main_entity: Entity,
|
||
pub light: Option<Buffer>,
|
||
}
|
||
|
||
impl EffectQuads {
|
||
/// Open a draw: remember where its vertices start.
|
||
pub fn begin(&self) -> u32 {
|
||
self.verts.len() as u32
|
||
}
|
||
|
||
/// Close a quad draw over everything pushed since `begin`. A range that gained no vertices
|
||
/// commits nothing — the idle steady state costs zero here (the a5521180 law, structural).
|
||
pub fn commit_quads(&mut self, start: u32, spec: EffectDrawSpec) {
|
||
debug_assert_eq!((self.verts.len() as u32 - start) % 4, 0, "whole quads only");
|
||
self.commit(start, EffectTopology::Quads, spec);
|
||
}
|
||
|
||
/// Close a triangle-list draw over everything pushed since `begin`.
|
||
pub fn commit_tris(&mut self, start: u32, spec: EffectDrawSpec) {
|
||
debug_assert_eq!(
|
||
(self.verts.len() as u32 - start) % 3,
|
||
0,
|
||
"whole triangles only"
|
||
);
|
||
self.commit(start, EffectTopology::Tris, spec);
|
||
}
|
||
|
||
fn commit(&mut self, start: u32, topology: EffectTopology, spec: EffectDrawSpec) {
|
||
debug_assert!(
|
||
self.cleared_this_frame,
|
||
"effect-stream write before `begin_effect_frame`: this draw is about to be erased. \
|
||
The producing system needs `.after(crate::particles::buffer::begin_effect_frame)` \
|
||
(see `EffectQuads::cleared_this_frame` — this is B161's failure mode)."
|
||
);
|
||
let end = self.verts.len() as u32;
|
||
if end > start {
|
||
self.draws.push(EffectDraw {
|
||
cam: spec.cam,
|
||
texture: spec.texture,
|
||
blend: spec.blend,
|
||
topology,
|
||
fog: spec.fog,
|
||
lit: spec.lighting == EffectLighting::Scene,
|
||
anchor: spec.anchor,
|
||
bias: spec.bias,
|
||
raster_bias: spec.raster_bias,
|
||
cam_relative: spec.cam_relative,
|
||
range: start..end,
|
||
main_entity: spec.main_entity,
|
||
light: spec.light,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Clear the stream for a new frame — scheduled before every family's writer, so the writer
|
||
/// order between them stays free.
|
||
pub fn begin_effect_frame(mut quads: ResMut<EffectQuads>) {
|
||
quads.verts.clear();
|
||
quads.draws.clear();
|
||
quads.cleared_this_frame = true;
|
||
}
|
||
|
||
/// Disarm the write-order tripwire for the next frame — `Last`, after the render world has
|
||
/// extracted this frame's stream. See [`EffectQuads::cleared_this_frame`].
|
||
pub fn clear_effect_frame_flag(mut quads: ResMut<EffectQuads>) {
|
||
quads.cleared_this_frame = false;
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// The additive flag OVERRIDES the folded enum (decision 0748). `ModelBlend::Blend` means
|
||
/// "alpha-blended **or** additive" — M2 modes 2/3/4 all land there — so a mapping that reads
|
||
/// only the enum cannot be right. Every `Spells\` flat ground quad in the 1.12.1 corpus is
|
||
/// mode 4 (`m2batch`: ArcaneExplosion_Base, BattleShout_Cast_Base, …), and drawing those
|
||
/// alpha-blended painted their black-backed additive art as an opaque black tile.
|
||
#[test]
|
||
fn additive_wins_over_the_folded_blend_enum() {
|
||
for blend in [
|
||
ModelBlend::Opaque,
|
||
ModelBlend::AlphaTest,
|
||
ModelBlend::Blend,
|
||
ModelBlend::Mod,
|
||
ModelBlend::Mod2x,
|
||
] {
|
||
assert_eq!(
|
||
EffectBlend::from_model(blend, true),
|
||
EffectBlend::Add,
|
||
"{blend:?} + additive must reach the pure-add state, not {:?}",
|
||
EffectBlend::from_model(blend, false),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// The non-additive law is unchanged — `model_render.rs`'s mapping, with `AlphaTest` folded
|
||
/// to `Alpha` (the lane has no Mask variant; flat `Spells\` quads are blend batches).
|
||
#[test]
|
||
fn non_additive_keeps_the_material_paths_law() {
|
||
assert_eq!(
|
||
EffectBlend::from_model(ModelBlend::Opaque, false),
|
||
EffectBlend::Opaque
|
||
);
|
||
assert_eq!(
|
||
EffectBlend::from_model(ModelBlend::AlphaTest, false),
|
||
EffectBlend::Alpha
|
||
);
|
||
assert_eq!(
|
||
EffectBlend::from_model(ModelBlend::Blend, false),
|
||
EffectBlend::Alpha
|
||
);
|
||
assert_eq!(
|
||
EffectBlend::from_model(ModelBlend::Mod, false),
|
||
EffectBlend::Multiply
|
||
);
|
||
assert_eq!(
|
||
EffectBlend::from_model(ModelBlend::Mod2x, false),
|
||
EffectBlend::Mod2x
|
||
);
|
||
}
|
||
|
||
/// **B161, as an executable record.** The chain beam shipped with correct arithmetic and no
|
||
/// `.after(begin_effect_frame)` edge, and drew nothing at all — for weeks, silently.
|
||
///
|
||
/// The mechanism is not "the writers race": it is that the clear carries one dependency the
|
||
/// writer does not (`face_billboards`), so the writer becomes *runnable first* and the clear
|
||
/// lands on top of it. This pair pins both halves — the failure mode, and the edge that fixes
|
||
/// it. The graph below is the real registration shape of `ParticlePlugin` (the clear) and
|
||
/// `EntitiesPlugin` (the beam sim), transcribed.
|
||
mod write_order {
|
||
use super::*;
|
||
|
||
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||
struct Place;
|
||
|
||
fn joint_palette() {}
|
||
fn finalize_rigs() {}
|
||
fn face_billboards() {}
|
||
|
||
fn writer(mut quads: ResMut<EffectQuads>) {
|
||
let start = quads.begin();
|
||
for _ in 0..4 {
|
||
quads.verts.push(EffectVertex {
|
||
pos: [0.0; 3],
|
||
uv: [0.0; 2],
|
||
color: [1.0; 4],
|
||
});
|
||
}
|
||
quads.commit_quads(
|
||
start,
|
||
EffectDrawSpec {
|
||
cam: Entity::PLACEHOLDER,
|
||
texture: AssetId::default(),
|
||
blend: EffectBlend::Add,
|
||
fog: EffectFog::Off,
|
||
lighting: EffectLighting::None,
|
||
anchor: Vec3::ZERO,
|
||
bias: 0.0,
|
||
raster_bias: 0,
|
||
cam_relative: false,
|
||
main_entity: Entity::PLACEHOLDER,
|
||
light: None,
|
||
},
|
||
);
|
||
}
|
||
|
||
/// `edged`: whether the writer declares the load-bearing `.after(begin_effect_frame)`.
|
||
fn run(edged: bool) -> App {
|
||
let mut app = App::new();
|
||
app.init_resource::<EffectQuads>();
|
||
app.add_systems(
|
||
PostUpdate,
|
||
(joint_palette, finalize_rigs, face_billboards)
|
||
.chain()
|
||
.in_set(Place),
|
||
);
|
||
let w = writer
|
||
.in_set(Place)
|
||
.after(joint_palette)
|
||
.after(finalize_rigs);
|
||
app.add_systems(
|
||
PostUpdate,
|
||
if edged {
|
||
w.after(begin_effect_frame)
|
||
} else {
|
||
w
|
||
},
|
||
);
|
||
app.add_systems(
|
||
PostUpdate,
|
||
begin_effect_frame
|
||
.in_set(Place)
|
||
.after(joint_palette)
|
||
.after(finalize_rigs)
|
||
// The extra dependency the beam sim lacked — the whole mechanism.
|
||
.after(face_billboards),
|
||
);
|
||
app.add_systems(Last, clear_effect_frame_flag);
|
||
// Two frames: the first arms the tripwire (a bare stream defaults to armed, and the
|
||
// `Last` re-arm has not run yet), the second is any ordinary frame.
|
||
app.update();
|
||
app.update();
|
||
app
|
||
}
|
||
|
||
/// With the edge, the writer's draw survives the frame and reaches extract.
|
||
#[test]
|
||
fn the_edge_keeps_the_draw() {
|
||
let app = run(true);
|
||
let quads = app.world().resource::<EffectQuads>();
|
||
assert_eq!(quads.draws.len(), 1, "the draw must survive to extract");
|
||
assert_eq!(quads.verts.len(), 4);
|
||
}
|
||
|
||
/// Without it, the write precedes the clear — the tripwire that now names the bug.
|
||
#[test]
|
||
#[should_panic(expected = "effect-stream write before `begin_effect_frame`")]
|
||
fn without_the_edge_the_tripwire_names_it() {
|
||
let _ = run(false);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// **Draw a batch of quads or triangles in the effect lane** — the stream's face for everything
|
||
/// that is not a particle.
|
||
///
|
||
/// Six lanes push into this buffer and every one of them wrote the same eleven-field
|
||
/// [`EffectDrawSpec`] literal, of which three fields are the same value at every gameplay site
|
||
/// (`lighting: None`, `cam_relative: false`, `light: None` — the exceptions are all engine-internal:
|
||
/// M2 emitters light, the snow slab writes camera-relative, the booths bind their own buffer).
|
||
/// A caller was choosing eight things and restating three.
|
||
///
|
||
/// The builder is what keeps [`EffectBlend`] and [`EffectFog`] off the doorway: a lane says
|
||
/// `.additive()` rather than naming an enum, and the defaults it does not mention are the lane's
|
||
/// contract rather than a line it had to copy correctly.
|
||
#[derive(bevy::ecs::system::SystemParam)]
|
||
pub struct WorldEffectDraw<'w> {
|
||
quads: ResMut<'w, EffectQuads>,
|
||
}
|
||
|
||
impl<'w> WorldEffectDraw<'w> {
|
||
/// Open a batch drawn through `cam` with `texture`. Nothing is committed until [`EffectBatch`]
|
||
/// is closed with `tris()` or `quads()`, and a batch that gained no vertices commits nothing.
|
||
pub fn batch(&mut self, cam: Entity, texture: AssetId<Image>) -> EffectBatch<'_> {
|
||
let start = self.quads.begin();
|
||
EffectBatch {
|
||
start,
|
||
spec: EffectDrawSpec {
|
||
cam,
|
||
texture,
|
||
blend: EffectBlend::Alpha,
|
||
fog: EffectFog::Off,
|
||
lighting: EffectLighting::None,
|
||
anchor: Vec3::ZERO,
|
||
bias: 0.0,
|
||
raster_bias: 0,
|
||
cam_relative: false,
|
||
main_entity: Entity::PLACEHOLDER,
|
||
light: None,
|
||
},
|
||
quads: &mut self.quads,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// One open batch. Field-free by construction: a caller holds it just long enough to push
|
||
/// vertices and close it, so the type never has to be named.
|
||
pub struct EffectBatch<'a> {
|
||
quads: &'a mut EffectQuads,
|
||
start: u32,
|
||
spec: EffectDrawSpec,
|
||
}
|
||
|
||
impl EffectBatch<'_> {
|
||
/// Add this batch's colour to the framebuffer — glows, rings, beams.
|
||
pub fn additive(mut self) -> Self {
|
||
self.spec.blend = EffectBlend::Add;
|
||
self
|
||
}
|
||
|
||
/// Multiply it into what is already drawn — the blob shadow's darkening.
|
||
pub fn multiply(mut self) -> Self {
|
||
self.spec.blend = EffectBlend::Multiply;
|
||
self
|
||
}
|
||
|
||
/// An explicit blend, for a lane whose authored data carries one.
|
||
pub fn blend(mut self, blend: EffectBlend) -> Self {
|
||
self.spec.blend = blend;
|
||
self
|
||
}
|
||
|
||
/// An explicit fog policy; the default is [`EffectFog::Off`], which is what every decal and
|
||
/// overlay wants (they burn at their own colour).
|
||
pub fn fog(mut self, fog: EffectFog) -> Self {
|
||
self.spec.fog = fog;
|
||
self
|
||
}
|
||
|
||
/// Where this batch sorts from — a world point, not a vertex. Transparents are ordered by the
|
||
/// distance to this.
|
||
pub fn anchored(mut self, at: Vec3) -> Self {
|
||
self.spec.anchor = at;
|
||
self
|
||
}
|
||
|
||
/// This batch's rung on the draw-order ladder: the transparent sort bias and the rasterizer
|
||
/// depth-bias constant (`crate::sky_order::Rung`).
|
||
pub fn rung(mut self, sort: f32, raster: i32) -> Self {
|
||
self.spec.bias = sort;
|
||
self.spec.raster_bias = raster;
|
||
self
|
||
}
|
||
|
||
/// The entity this draw belongs to, for the render world's per-item bookkeeping.
|
||
pub fn owner(mut self, entity: Entity) -> Self {
|
||
self.spec.main_entity = entity;
|
||
self
|
||
}
|
||
|
||
/// Push vertices already built elsewhere — the decal lanes project once and cache.
|
||
pub fn vertices(&mut self, verts: &[EffectVertex]) {
|
||
self.quads.verts.extend_from_slice(verts);
|
||
}
|
||
|
||
/// Push vertices from an iterator.
|
||
pub fn extend(&mut self, verts: impl IntoIterator<Item = EffectVertex>) {
|
||
self.quads.verts.extend(verts);
|
||
}
|
||
|
||
/// The vertex sink, for a producer that writes in place rather than yielding — the chain
|
||
/// beam builds each strand into it directly.
|
||
pub fn verts_mut(&mut self) -> &mut Vec<EffectVertex> {
|
||
&mut self.quads.verts
|
||
}
|
||
|
||
/// Close the batch as a triangle list.
|
||
pub fn tris(self) {
|
||
self.quads.commit_tris(self.start, self.spec);
|
||
}
|
||
|
||
/// Close it as quads.
|
||
pub fn quads(self) {
|
||
self.quads.commit_quads(self.start, self.spec);
|
||
}
|
||
}
|