mesh_tag: an interior prop with no probe slot is still indoors
The SH-probe table is 13 tag bits wide and nothing can widen it — the probe field is the last of the u32 — so a dense interior can ask for more slots than exist. His session hit it (`live 8192, peak 8192`), and every prop that missed out was then tagged as plain exterior: furniture lit by the sky, which in a Dun Morogh snowstorm reads as the weather coming indoors. A prop that misses a slot is still *indoors*, and saying so costs no bits. `PropSeat` names the three states a spawner can be in — `Exterior`, `Probe(slot)` and `Matte` — and `Matte` is the exterior payload with `MATTE_INDOOR_BIT` and a saturated lane: the room's day/night law, which is exactly what `interior::AppliedLaw::Matte` already gives an entity whose classification finds the table full. A dim room instead of a sunlit one. The seat also replaces the bare `Option<u16>` the assembler took, so a caller now says which of the three it means rather than leaving `None` to stand for both "sky-lit" and "wanted a probe and lost". Still true: the table can fill, and the props past it lose their room's *baked* light. Lowering that demand (sharing by a quantized fold, or scoping the allocation) is the next lever and wants a measurement first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
@@ -216,33 +216,42 @@ pub(super) fn spawn_wmo_gameobject_props(
|
||||
let (radius, center) = m2_fade(&m.bounds, prop.local.scale.x);
|
||||
let anim_bound = m2_anim_bound(&m.bounds);
|
||||
// Fold an interior prop's light once, through the host's current pose (module docs).
|
||||
let interior_slot = prop.interior.as_ref().and_then(|lane| {
|
||||
let ref_point = host_world.mul_transform(prop.local).transform_point(center);
|
||||
let world_lights: Vec<PropLobeLight> = lane
|
||||
.lights
|
||||
.iter()
|
||||
.map(|&(pos, color_i, atten_start, atten_end)| PropLobeLight {
|
||||
pos: host_world.transform_point(pos),
|
||||
color_i,
|
||||
atten_start,
|
||||
atten_end,
|
||||
})
|
||||
.collect();
|
||||
let slot = probes.alloc(fold_interior_probe(
|
||||
lane.ambient,
|
||||
lane.diffuse,
|
||||
ref_point,
|
||||
&world_lights,
|
||||
));
|
||||
if slot.is_none() {
|
||||
let (live, peak) = probes.occupancy();
|
||||
warn_once!(
|
||||
"interior-prop probe table full (live {live}, peak {peak}); \
|
||||
cabin props fall back to exterior light"
|
||||
let seat = prop.interior.as_ref().map_or(
|
||||
benilla_world::mesh_tag::PropSeat::Exterior,
|
||||
|lane| {
|
||||
let ref_point = host_world.mul_transform(prop.local).transform_point(center);
|
||||
let world_lights: Vec<PropLobeLight> = lane
|
||||
.lights
|
||||
.iter()
|
||||
.map(|&(pos, color_i, atten_start, atten_end)| PropLobeLight {
|
||||
pos: host_world.transform_point(pos),
|
||||
color_i,
|
||||
atten_start,
|
||||
atten_end,
|
||||
})
|
||||
.collect();
|
||||
let coeffs = fold_interior_probe(
|
||||
lane.ambient,
|
||||
lane.diffuse,
|
||||
ref_point,
|
||||
&world_lights,
|
||||
);
|
||||
}
|
||||
slot
|
||||
});
|
||||
match probes.alloc(coeffs) {
|
||||
Some(slot) => benilla_world::mesh_tag::PropSeat::Probe(slot),
|
||||
None => {
|
||||
// Still indoors, just unseated: the room's day/night law beats the sky
|
||||
// inside a cabin.
|
||||
let (live, peak) = probes.occupancy();
|
||||
warn_once!(
|
||||
"interior-prop probe table full (live {live}, peak {peak}); \
|
||||
cabin props take the room's day/night law instead of their own \
|
||||
folded probe"
|
||||
);
|
||||
benilla_world::mesh_tag::PropSeat::Matte
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
// The identity only names the kind and the parts: a pick on the ship answers with the
|
||||
// GameObject's own `WorldObject`.
|
||||
let object = std::sync::Arc::new(benilla_world::interact::WorldObject {
|
||||
@@ -254,7 +263,7 @@ pub(super) fn spawn_wmo_gameobject_props(
|
||||
.unwrap_or_default(),
|
||||
id: 0,
|
||||
// The prop's lane and probe, as a terrain prop's identity names them.
|
||||
detail: match (&prop.interior, interior_slot) {
|
||||
detail: match (&prop.interior, seat.slot()) {
|
||||
(Some(lane), Some(slot)) => format!(
|
||||
"WMO gameobject prop · interior amb {} dif {} · {} MOLR · probe slot {slot}",
|
||||
hex_word(lane.ambient),
|
||||
@@ -262,7 +271,8 @@ pub(super) fn spawn_wmo_gameobject_props(
|
||||
lane.lights.len(),
|
||||
),
|
||||
(Some(_), None) => {
|
||||
"WMO gameobject prop · interior, NO PROBE SLOT (table full — sky-lit)".into()
|
||||
"WMO gameobject prop · interior, NO PROBE SLOT (table full — matte indoor)"
|
||||
.into()
|
||||
}
|
||||
(None, _) => "WMO gameobject prop · sky-lit".into(),
|
||||
},
|
||||
@@ -282,7 +292,7 @@ pub(super) fn spawn_wmo_gameobject_props(
|
||||
prop.local, // doodad-local; the parent composes the world pose
|
||||
&object,
|
||||
shade,
|
||||
interior_slot,
|
||||
seat,
|
||||
// No draw-set gate, whose `EmitterFade` measures from a fixed world point; the
|
||||
// assembler builds its sphere from `radius` and `center` instead.
|
||||
None,
|
||||
@@ -301,7 +311,7 @@ pub(super) fn spawn_wmo_gameobject_props(
|
||||
);
|
||||
commands.entity(entity).add_children(&ents);
|
||||
// The slot's component hook returns it to the table whoever despawns the prop.
|
||||
if let (Some(slot), Some(&first)) = (interior_slot, ents.first()) {
|
||||
if let (Some(slot), Some(&first)) = (seat.slot(), ents.first()) {
|
||||
commands.entity(first).insert(PropProbeSlot(slot));
|
||||
interior += 1;
|
||||
}
|
||||
|
||||
@@ -120,6 +120,54 @@ pub(crate) fn probe_bits(slot: u16) -> u32 {
|
||||
INTERIOR_FOG_BIT | (u32::from(slot) << PROBE_SHIFT) | alpha_bits(1.0)
|
||||
}
|
||||
|
||||
/// **Where an M2 prop's interior light comes from**, which is also what its spawn tag encodes.
|
||||
///
|
||||
/// The probe table is 13 tag bits wide and nothing can widen it ([`PROBE_MASK`] is the last of the
|
||||
/// u32), so a dense interior can ask for more slots than exist. A prop that misses out is still
|
||||
/// *indoors*, and saying so costs no bits: [`Self::Matte`] is the room's day/night law in the
|
||||
/// exterior payload, the same fallback `interior::AppliedLaw::Matte` gives an entity whose
|
||||
/// classification finds the table full. The difference it buys is a dim room instead of one lit
|
||||
/// like the weather outside.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
|
||||
pub enum PropSeat {
|
||||
/// Not an interior prop: the sky lights it.
|
||||
#[default]
|
||||
Exterior,
|
||||
/// The prop's folded SH probe, at this table slot.
|
||||
Probe(u16),
|
||||
/// An interior prop the probe table had no slot for.
|
||||
Matte,
|
||||
}
|
||||
|
||||
impl PropSeat {
|
||||
/// The table slot, for the holders that own and free it (`PropProbeSlot`, the merge and
|
||||
/// retained-pass sites).
|
||||
pub fn slot(self) -> Option<u16> {
|
||||
match self {
|
||||
Self::Probe(slot) => Some(slot),
|
||||
Self::Exterior | Self::Matte => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the prop stands in a room at all, slot or no slot.
|
||||
pub(crate) fn is_interior(self) -> bool {
|
||||
!matches!(self, Self::Exterior)
|
||||
}
|
||||
|
||||
/// The batch's spawn tag: the probe payload for a seated prop, the saturated matte-indoor lane
|
||||
/// for an unseated one, else the plain opaque exterior payload.
|
||||
pub(crate) fn spawn_tag(self) -> u32 {
|
||||
match self {
|
||||
Self::Probe(slot) => probe_bits(slot),
|
||||
// The spawn-time twin of [`with_matte_indoor`]: indoors from its first frame, with no
|
||||
// slot to name and no ramp to wait for. The fog bit is left to [`with_interior_fog`],
|
||||
// as it is for every other matte part.
|
||||
Self::Matte => with_matte_indoor(alpha_bits(1.0), LANE_MAX),
|
||||
Self::Exterior => alpha_bits(1.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The alpha field a whole-payload rewrite carries, the untagged `0` (which [`alpha_bits`] never
|
||||
/// writes) made opaque: it lets the classifier re-lane a part mid-fade.
|
||||
fn carried_alpha(tag: u32) -> u32 {
|
||||
@@ -352,6 +400,35 @@ mod tests {
|
||||
assert_eq!(shade_of(t, ext()), 10);
|
||||
}
|
||||
|
||||
/// The three prop seats, and the one that matters: a prop the table had no slot for must still
|
||||
/// read as indoors, or a room full of furniture is lit by the weather outside.
|
||||
#[test]
|
||||
fn an_unseated_interior_prop_spawns_on_the_room_lane_not_the_sky() {
|
||||
let seated = PropSeat::Probe(4321).spawn_tag();
|
||||
assert_eq!((seated & PROBE_MASK) >> PROBE_SHIFT, 4321);
|
||||
assert_eq!(seated & INTERIOR_FOG_BIT, INTERIOR_FOG_BIT);
|
||||
|
||||
let unseated = PropSeat::Matte.spawn_tag();
|
||||
assert_eq!(unseated & MATTE_INDOOR_BIT, MATTE_INDOOR_BIT, "indoors");
|
||||
assert_eq!(
|
||||
lane_of(unseated),
|
||||
LANE_MAX,
|
||||
"fully on the room lane, no ramp"
|
||||
);
|
||||
assert_eq!(unseated & ALPHA_MASK, ALPHA_MASK, "opaque");
|
||||
assert_eq!(unseated & PROBE_MASK & !(MATTE_INDOOR_BIT | LANE_MASK), 0);
|
||||
|
||||
let sky = PropSeat::Exterior.spawn_tag();
|
||||
assert_eq!(sky & MATTE_INDOOR_BIT, 0);
|
||||
assert_eq!(lane_of(sky), 0);
|
||||
assert_eq!(sky & ALPHA_MASK, ALPHA_MASK);
|
||||
|
||||
// The shade ramp is the busiest writer over a prop's tag; it must not eat the lane.
|
||||
let shaded = with_shade(unseated, 200, ext());
|
||||
assert_eq!(shaded & MATTE_INDOOR_BIT, MATTE_INDOOR_BIT);
|
||||
assert_eq!(lane_of(shaded), LANE_MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_bits_compose_with_the_alpha_field() {
|
||||
let t = probe_bits(6660);
|
||||
|
||||
@@ -12,7 +12,6 @@ use bevy::render::render_resource::Buffer;
|
||||
|
||||
use crate::billboard::BillboardCard;
|
||||
use crate::doodad_anim::DoodadAnimHost;
|
||||
use crate::mesh_tag::alpha_bits;
|
||||
use crate::model_fade::DoodadFade;
|
||||
use crate::model_render::{model_material, MaterialCache, ShadeSel};
|
||||
use crate::model_render::{ModelKind, ModelPart};
|
||||
@@ -69,8 +68,9 @@ pub fn spawn_model_entities(
|
||||
object: &Arc<crate::interact::WorldObject>,
|
||||
// The MCSH terrain shade at the base; WMO group geometry and interior props ignore it.
|
||||
shade: ShadeSel,
|
||||
// An interior M2 prop's SH-probe slot, carried in `MeshTag` on every batch, cards included.
|
||||
interior_slot: Option<u16>,
|
||||
// Where an interior M2 prop's light comes from, carried in `MeshTag` on every batch, cards
|
||||
// included: its SH-probe slot, or the room's day/night lane when the table had none.
|
||||
seat: crate::mesh_tag::PropSeat,
|
||||
// The placement's draw-set gate, shared with its emitters, ribbons, lights and anim host: a
|
||||
// meshless prop's only verdict. `None` on the entity-hosted lane, as a mover has no baked
|
||||
// world point to measure from; the mesh lane then builds a bare sphere from `radius`.
|
||||
@@ -153,13 +153,13 @@ pub fn spawn_model_entities(
|
||||
// a WMO batch also takes it into the clip-z nudge, as the reference draws MOBA batches in
|
||||
// strict file order (`0x6b4f10`/`0x6b5190`).
|
||||
let batch_order = u16::try_from(batch_idx + 1).unwrap_or(u16::MAX);
|
||||
let interior = sub.interior || interior_slot.is_some();
|
||||
let interior = sub.interior || seat.is_interior();
|
||||
// A billboard card culls by its `0x04` flag like any batch: the bone turns +X to the
|
||||
// viewer and the cull is GL_BACK/CCW (`0x70c2b3`), so a −X card is meant to go unseen.
|
||||
let two_sided = sub.two_sided;
|
||||
// An interior M2 prop's batches carry its SH-probe slot, cards included: the reference
|
||||
// shades every batch of an object through one light node.
|
||||
let interior_probe = !is_wmo && interior_slot.is_some();
|
||||
let interior_probe = !is_wmo && seat.slot().is_some();
|
||||
// Only a non-card interior batch is steady; a card still fades with its lamp.
|
||||
let steady_interior_prop = interior_probe && sub.billboard.is_none();
|
||||
// A batch whose UV or tint loop differs by sequence gets a material keyed by this
|
||||
@@ -531,14 +531,18 @@ pub fn spawn_model_entities(
|
||||
);
|
||||
}
|
||||
}
|
||||
// `MeshTag`: an interior prop's probe slot through `mesh_tag::probe_bits`, else the fade
|
||||
// alpha at 1.0. One predicate feeds both the tag and `InteriorProbePayload` below, so the
|
||||
// payload's writer and readers cannot disagree.
|
||||
let probe_slot = interior_slot.filter(|_| interior_probe);
|
||||
let mesh_tag = match probe_slot {
|
||||
Some(slot) => MeshTag(crate::mesh_tag::probe_bits(slot)),
|
||||
None => MeshTag(alpha_bits(1.0)),
|
||||
// `MeshTag`: the seat's own encoding ([`crate::mesh_tag::PropSeat::spawn_tag`]) — the
|
||||
// probe payload for a seated interior prop, the matte-indoor room lane for one the table
|
||||
// had no slot for, the plain opaque payload for anything else. WMO group geometry lights
|
||||
// itself from its vertex colours and takes no seat. `probe_slot` feeds both the tag and
|
||||
// `InteriorProbePayload` below, so the payload's writer and readers cannot disagree.
|
||||
let seat = if is_wmo {
|
||||
crate::mesh_tag::PropSeat::Exterior
|
||||
} else {
|
||||
seat
|
||||
};
|
||||
let probe_slot = seat.slot();
|
||||
let mesh_tag = MeshTag(seat.spawn_tag());
|
||||
// A billboard card's transform is the billboard system's; it still fades with its doodad,
|
||||
// from its pivot, where the mesh is centred (`ZERO`).
|
||||
let (entity, fade_center) = if let Some(info) = &sub.billboard {
|
||||
|
||||
@@ -197,7 +197,7 @@ pub(super) fn spawn_loaded_placements(
|
||||
p.transform,
|
||||
&object,
|
||||
shade,
|
||||
None, // map doodad: exterior sky lighting (no interior probe)
|
||||
crate::mesh_tag::PropSeat::Exterior, // a map doodad is sky-lit
|
||||
Some(&fade),
|
||||
radius,
|
||||
center,
|
||||
@@ -407,7 +407,8 @@ pub(super) fn spawn_loaded_placements(
|
||||
p.transform,
|
||||
&object,
|
||||
ShadeSel::Matte, // unread: WMO lights on the FFP N·L path
|
||||
None, // WMO groups carry their own per-submesh interior flag + batch class
|
||||
// WMO groups carry their own per-submesh interior flag + batch class.
|
||||
crate::mesh_tag::PropSeat::Exterior,
|
||||
Some(&fade),
|
||||
f32::INFINITY,
|
||||
Vec3::ZERO,
|
||||
@@ -711,8 +712,8 @@ pub(super) fn spawn_loaded_placements(
|
||||
// An interior prop's light, folded once into its SH probe: the reference refolds at
|
||||
// each light commit, but every input here is static. The reference point is the placed
|
||||
// M2 box centre, the `[def+0x5c]` anchor of `0x6952a0`/`0x713680`.
|
||||
let interior_slot = match &d.light {
|
||||
PropLight::Exterior => None,
|
||||
let seat = match &d.light {
|
||||
PropLight::Exterior => crate::mesh_tag::PropSeat::Exterior,
|
||||
PropLight::Interior {
|
||||
ambient,
|
||||
diffuse,
|
||||
@@ -720,16 +721,21 @@ pub(super) fn spawn_loaded_placements(
|
||||
} => {
|
||||
let ref_point = d.transform.transform_point(center);
|
||||
let coeffs = fold_interior_probe(*ambient, *diffuse, ref_point, lights);
|
||||
let slot = probes.alloc(coeffs);
|
||||
if slot.is_none() {
|
||||
// Once per burst, not per prop: a city WMO can flood thousands in a frame.
|
||||
let (live, peak) = probes.occupancy();
|
||||
warn_once!(
|
||||
"interior-prop probe table full (live {live}, peak {peak}); \
|
||||
overflowing props fall back to exterior light"
|
||||
);
|
||||
match probes.alloc(coeffs) {
|
||||
Some(slot) => crate::mesh_tag::PropSeat::Probe(slot),
|
||||
None => {
|
||||
// Once per burst, not per prop: a city WMO can flood thousands in a
|
||||
// frame. The prop keeps the room's day/night law — it is still
|
||||
// indoors, and only its *probe* is missing.
|
||||
let (live, peak) = probes.occupancy();
|
||||
warn_once!(
|
||||
"interior-prop probe table full (live {live}, peak {peak}); \
|
||||
overflowing props take the room's day/night law instead of their \
|
||||
own folded probe"
|
||||
);
|
||||
crate::mesh_tag::PropSeat::Matte
|
||||
}
|
||||
}
|
||||
slot
|
||||
}
|
||||
};
|
||||
let object = Arc::new(WorldObject {
|
||||
@@ -760,7 +766,7 @@ pub(super) fn spawn_loaded_placements(
|
||||
d.transform,
|
||||
&object,
|
||||
shade,
|
||||
interior_slot, // interior props light off their folded probe, not the sky
|
||||
seat, // interior props light off their folded probe, not the sky
|
||||
Some(&fade),
|
||||
radius,
|
||||
center,
|
||||
@@ -776,7 +782,7 @@ pub(super) fn spawn_loaded_placements(
|
||||
MergeSite::Prop {
|
||||
uid: unique_id,
|
||||
groups: &d.groups,
|
||||
slot: interior_slot,
|
||||
slot: seat.slot(),
|
||||
},
|
||||
)),
|
||||
// The retained-pass region is the building's instance, its PVS key and lifetime;
|
||||
@@ -787,7 +793,7 @@ pub(super) fn spawn_loaded_placements(
|
||||
crate::static_gx::GxSite::Prop {
|
||||
instance,
|
||||
groups: &d.groups,
|
||||
slot: interior_slot,
|
||||
slot: seat.slot(),
|
||||
},
|
||||
)),
|
||||
(None, Some(gx)) => {
|
||||
@@ -799,7 +805,7 @@ pub(super) fn spawn_loaded_placements(
|
||||
);
|
||||
// A despawn hook returns the slot to the table. A prop whose every batch merged spawns
|
||||
// a bare carrier for it, since a blob cannot own one prop's slot.
|
||||
if let Some(slot) = interior_slot {
|
||||
if let Some(slot) = seat.slot() {
|
||||
let owner = match ents.first() {
|
||||
Some(&first) => first,
|
||||
None => {
|
||||
|
||||
Reference in New Issue
Block a user