Files
benilla-octo/crates/benilla/src/particles/model.rs
T
SamandClaude Fable 5 422e1b08e7 the cooldown sweep is pinned, and the shoulder armor calms down
- the GCD system now matches the real client end to end: opening a chest no longer eats the running global cooldown, and a running sweep's start stops wobbling between frames
- the mounted left shoulder's wild swing is fixed at the bone: a bone flagged to ignore its parent's rotation now does, decided before any camera-facing runs
- a gameobject effect with several animation sequences plays the right one: Molten Core's rune flame spreads and dances instead of collapsing into a bright dot
- the targeting cursor asks the thing under it and greys by default like the real client, and the ground-target reticle keeps its own location rules

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 18:54:26 +07:00

220 lines
11 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.
//! **3-D MODEL particles** (wow-re `part-model-particles.md`, VERIFIED): an emitter whose
//! record names a geometry model renders each live particle as a tiny 3-D instance of that
//! model — quaternion-oriented, tumbling, over-life scaled and tinted — instead of a billboard
//! quad (Whirlwind's blades, Cone of Cold's shards, the cyclones, Death Wish). The sim
//! integrates position + spin ([`super::sim`]); this module owns the DRAW side: a per-emitter
//! pool of instance entities grown on demand, positioned after the sim each frame.
//!
//! Draw law (`0x7b4840` → `0x7b4510`): per particle, the instance transform is the particle
//! quaternion (× the emitter frame) at the particle position, scaled by the over-life SIZE
//! ramp; over-life COLOR rides a per-instance tint clone (`WowModelExt::tint`, the fx-tint
//! mechanism) and over-life ALPHA rides the per-instance `MeshTag` alpha field. The
//! reference's optional per-emitter depth sort (rt+0x1ac & 0x10) is folded into the pool-order
//! simplification named in decision 0148. The reference draws each instance through its
//! generic model-render pass; our instances are the geometry model's submeshes with their own
//! materials — mini-model *animation* (a rigged geometry model) is not run: every spell-corpus
//! geometry target probed is a static mesh, and a rigged one would surface as a visibly stiff
//! instance, the recorded trigger to extend this.
use benilla_assets::coords::wow_to_bevy;
use benilla_assets::M2Model;
use bevy::camera::visibility::NoFrustumCulling;
use bevy::mesh::MeshTag;
use bevy::prelude::*;
use crate::model_render::{model_material, MaterialCache, ShadeSel};
use crate::terrain::WowModelMaterial;
use super::{ChildDraw, ParticleEmitter};
/// A hard cap on one emitter's instance pool — far above any authored steady state
/// (rate ≤ ~25/s × life ≤ 3 s across the corpus); particles past it simply aren't drawn.
const MAX_INSTANCES: usize = 128;
/// One pooled instance slot: the geometry model's submeshes as world-root [`ChildDraw`]
/// entities (flat — no hierarchy, so the sim-frame transform write is exact), each with its
/// per-instance tint-clone material.
pub(super) struct ModelInstance {
pub(super) meshes: Vec<(Entity, Handle<WowModelMaterial>)>,
}
/// Grow + position each model-particle emitter's instance pool from its freshly-simulated
/// pool. Runs after [`super::sim::simulate_particles`] in the same set, so instances land on
/// this frame's positions (the anchored-exactness rule).
#[allow(clippy::too_many_arguments)] // a Bevy system: each param is one resource, the app's convention
pub(super) fn update_model_particles(
mut commands: Commands,
models: Res<Assets<M2Model>>,
mut forms: ResMut<crate::model_forms::ModelForms>,
mut mesh_assets: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<WowModelMaterial>>,
light: Res<crate::lighting::SharedLightBuffer>,
mut emitters: Query<&mut ParticleEmitter>,
mut draws: Query<
(
&mut Transform,
&mut GlobalTransform,
&mut Visibility,
&mut MeshTag,
),
With<ChildDraw>,
>,
) {
for mut emitter in &mut emitters {
let Some(geometry) = emitter.geometry.clone() else {
continue;
};
let Some(model) = models.get(&geometry) else {
continue; // still loading — particles simulate meanwhile, nothing draws yet
};
// The geometry model's render forms, built NOW rather than paced (decision 0834): a
// spell's visual must not lag its cast, and a particle-geometry model is a handful of
// tiny batches — the booth-lane exception, not the streaming rule.
let key = crate::model_forms::ModelKey::from(&geometry);
forms.ensure_now(
key,
crate::model_forms::WANT_STATIC,
&model.submeshes,
&mut mesh_assets,
);
// Grow the pool to the live count (bounded).
let rung = emitter.owner_rung();
let want = emitter.particles.len().min(MAX_INSTANCES);
while emitter.model_instances.len() < want {
let stat_forms = forms.static_meshes(key).unwrap_or(&[]);
let meshes = model
.submeshes
.iter()
.enumerate()
.map(|(pi, sub)| {
// A fresh (never-deduped) material per instance — its `tint` is mutated
// every frame by the over-life ramp, so instances must not share.
let mut throwaway = MaterialCache::default();
let material = model_material(
&mut throwaway,
&mut materials,
sub.texture.clone(),
sub.blend,
sub.two_sided,
false,
false,
sub.emissive,
sub.additive,
false,
sub.no_depth_write,
sub.no_depth_test,
sub.fog_policy,
// The same LIT lane as every entity M2 (the §9 chain).
ShadeSel::Lit,
0,
None,
None, // over-life tint owns the channel — never the authored M2Color loop
None,
None,
false,
&light.0,
);
// The owner-last draw-order rung, stamped on after the fact: a 3-D model
// particle is one of its owner's emitters exactly like the quad cloud beside
// it, and the reference draws them in one bracket after that model's batches
// (`ParticleEmitter::owner_rung`). It goes here rather than through
// `model_material`, which is the shared M2-batch recipe: every batch in the
// world would have to carry a particle-only argument and a cache-key field to
// move one number. These instance materials are per-instance throwaways
// (their `tint` is already mutated every frame), so nothing else sees it.
//
// BUCKETED, and transparent-pass only (decision 0945). On a material the rung
// is also a *pipeline-key axis* (bevy folds `depth_bias as i32` into the key
// — 0837's law), and 32 integer rungs × every blend state is an open key
// space no warm pass can pre-compile: each first-seen combination was a
// synchronous render-thread pipeline compile mid-spell-cast.
// `owner_last_rung_bucket` snaps UP (0721's blessed direction) to the closed
// set `pipe_warm`'s menagerie compiles behind the loading cover; the bucket
// is ≥ the exact rung, so a shard never sorts under its sibling quad cloud
// (which keeps the exact value — its rung is queue-side, never a material).
// Opaque/mask shard batches take no rung at all: sort bias is a
// transparent-pass concept, and on an opaque material it would only mint an
// unwarmable pipeline variant.
if let Some(m) = materials.get_mut(&material) {
if m.base.alpha_mode == AlphaMode::Blend {
m.base.depth_bias = benilla_formats::owner_last_rung_bucket(rung);
}
}
let entity = commands
.spawn((
Mesh3d(
stat_forms
.get(pi)
.map(|(h, _)| h.clone())
.unwrap_or_default(),
),
MeshMaterial3d(material.clone()),
Transform::IDENTITY,
Visibility::Hidden,
NoFrustumCulling,
MeshTag(crate::mesh_tag::alpha_bits(1.0)),
ChildDraw,
))
.id();
(entity, material)
})
.collect();
emitter.model_instances.push(ModelInstance { meshes });
}
// Position live slots; hide the rest.
let anchored = !emitter.def.model_space();
let inst_scale = if emitter.def.scale_size_by_instance() {
emitter.placement.scale.x.max(1e-4)
} else {
1.0
};
for (i, slot) in emitter.model_instances.iter().enumerate() {
let Some(p) = emitter.particles.get(i) else {
for (e, _) in &slot.meshes {
if let Ok((_, _, mut vis, _)) = draws.get_mut(*e) {
if *vis != Visibility::Hidden {
*vis = Visibility::Hidden;
}
}
}
continue;
};
let u = (p.age / p.life).clamp(0.0, 1.0);
// 3-D model particles carry no texture atlas — only colour and size reach them.
let ol = emitter.def.over_life.sample(u);
let (mut rgba, size) = (ol.color, ol.size);
// The owning model's render alpha, same fold as the quad lane (decision 0827) — here
// into the instance's `MeshTag` alpha, which is where a 3-D particle carries it.
rgba[3] *= emitter.render_alpha();
let tf = if anchored {
Transform {
translation: emitter.anchor_pos + emitter.attach_rot * p.pos,
rotation: emitter.attach_rot * p.quat,
scale: Vec3::splat(size * inst_scale),
}
} else {
Transform {
translation: emitter
.placement
.transform_point(wow_to_bevy(p.pos.to_array())),
rotation: emitter.placement.rotation * p.quat,
scale: Vec3::splat(size * inst_scale),
}
};
for (e, mat) in &slot.meshes {
if let Ok((mut t, mut g, mut vis, mut tag)) = draws.get_mut(*e) {
*t = tf;
*g = GlobalTransform::from(tf);
if *vis != Visibility::Inherited {
*vis = Visibility::Inherited;
}
*tag = MeshTag(crate::mesh_tag::alpha_bits(rgba[3]));
}
if let Some(m) = materials.get_mut(mat) {
m.extension.tint = Vec4::new(rgba[0], rgba[1], rgba[2], 1.0);
}
}
}
}
}