Files
benilla-octo/crates/benilla/src/particles/buffer.rs
T
SamandClaude Fable 5 994be22f5c outdoor lava flows, and Undercity gets its lost corridor back
- lava renders outside Blackrock at last: the mesher was dropping every open-world lava and slime surface, and 28 river mouths get their missing sea back in the same sweep
- Undercity's broken corridor returns, geometry and color both: a one-byte file overrun dropped the whole room, and a paint job one record short was being thrown away entire
- collision goes one-sided like the real client: walls stop you from the front and let you pass from behind
- the Booty Bay entrance is wood and rope again instead of a black cutout, glass picks up a real reflection, and models far from the map's center stop trembling

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 19:02:47 +07:00

499 lines
22 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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).
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.
#[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 three, 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,
/// `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::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` (no Mask variant here; the
/// groundscan census says flat `Spells\` quads are blend batches), 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,
}
}
}
/// 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 texture: AssetId<Image>,
pub blend: EffectBlend,
pub topology: EffectTopology,
pub fog: EffectFog,
/// Does the scene's light multiply this draw's RGB? See [`EffectDrawSpec::lit`].
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 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 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 raster_bias: i32,
/// 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 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,
/// Multiply this draw's RGB by the scene's matte light — `clamp(ambient + diffuse·max(N·L,0))`
/// against a camera-facing normal, the same term `wow_model.wgsl` applies to a mesh.
///
/// The lane is unlit by default and every family but one passes `false`: ribbons, decals, the
/// rings/reticle and precipitation are all authored to burn at their own colour. **M2 particle
/// emitters are the exception** — the reference has no particle material, it synthesizes an
/// `M2Material` from the emitter's file record every draw and runs the ordinary batch state
/// producer over it, so `GL_LIGHTING` lands on a particle exactly as on a mesh
/// ([`benilla_formats::ParticleEmitterDef::lit`], byte law there). 400 of the corpus's 7792
/// emitters clear the unlit bit, 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).
pub lit: bool,
pub anchor: Vec3,
pub bias: f32,
pub raster_bias: i32,
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.lit,
anchor: spec.anchor,
bias: spec.bias,
raster_bias: spec.raster_bias,
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,
lit: false,
anchor: Vec3::ZERO,
bias: 0.0,
raster_bias: 0,
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);
}
}
}