MONKEY: portal lane cross-fade for entities
An entity crossing a WMO portal no longer pops between the exterior day/night result and the dynamic room lane in one frame. The classifier ramps a per-anchor lane weight at 2/s (first resolve snaps, so a unit streaming in indoors never fades up from dark) and, while ramping, writes its parts in exterior material mode with the weight in tag bits 15..=18 beside the MATTE_INDOOR bit; the Bake material commits only at full weight. wow_model.wgsl mixes exterior-lit -> room-lit by that weight (forced to 1 in interior material mode, where those bits are the probe slot). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
693d45ac84
commit
23576eaf50
@@ -1278,14 +1278,27 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
|
||||
let matte_indoor = !is_interior && (fade_tag & 0x4000u) != 0u;
|
||||
if ((is_interior || matte_indoor) && !is_wmo && !is_rig && wow_light.wmo_fog_params.w > 0.5) {
|
||||
let room = interior_room_light(in.world_position.xyz, n_lit);
|
||||
lit_rgb = albedo * inst_tint * (vec3<f32>(1.0) - exp(-room * wow_light.point_count.w));
|
||||
var room_rgb = albedo * inst_tint * (vec3<f32>(1.0) - exp(-room * wow_light.point_count.w));
|
||||
let idbg = u32(max(wow_light.wmo_fog_params.w - 1.0, 0.0) + 0.5);
|
||||
if (idbg == 2u) {
|
||||
// The entity's OWN cube-map sampling as greyscale (the shared stub would show white).
|
||||
lit_rgb = vec3<f32>(torch_entity_debug_factor(in.world_position.xyz, n_lit));
|
||||
room_rgb = vec3<f32>(torch_entity_debug_factor(in.world_position.xyz, n_lit));
|
||||
} else {
|
||||
lit_rgb = shadow_hook::interior_debug_override(idbg, lit_rgb, in.world_position, n_lit);
|
||||
room_rgb = shadow_hook::interior_debug_override(idbg, room_rgb, in.world_position, n_lit);
|
||||
}
|
||||
// MONKEY (portal lane fade): CROSSFADE, not a switch. `lit_rgb` still holds this part's
|
||||
// EXTERIOR result (day/night × its ground-shade byte) — the "outside" colour — and the
|
||||
// classifier ramps tag bits 15..=18 from 0 to 15 over ~half a second as the entity's
|
||||
// anchor crosses the portal, so the two lanes blend the way the surfaces at the seam
|
||||
// already do. Without it a character stepping through a doorway went from night-dark to
|
||||
// torch-warm in ONE frame while the wall beside it faded.
|
||||
//
|
||||
// Forced to 1.0 in INTERIOR material mode: there those bits are the middle of the SH probe
|
||||
// slot (`mesh_tag`'s two payload modes), so reading them as a weight would fade an indoor
|
||||
// baked unit by its probe INDEX. The classifier only ever commits the interior material at
|
||||
// full weight anyway — every frame of an actual blend is written in exterior mode.
|
||||
let lane_w = select(f32((fade_tag >> 15u) & 0xfu) / 15.0, 1.0, is_interior);
|
||||
lit_rgb = mix(lit_rgb, room_rgb, lane_w);
|
||||
}
|
||||
// The fullbright/UNLIT path takes the tint too, unlike the highlight: with GL_LIGHTING off the
|
||||
// same gx state (SetState(1)) is a plain `glColor` modulate on the texture, while GL_EMISSION is
|
||||
|
||||
@@ -66,6 +66,17 @@ use benilla_assets::materials::WowModelMaterial;
|
||||
/// position compare and nothing else.
|
||||
const RESAMPLE_DIST_SQ: f32 = 1e-4;
|
||||
|
||||
/// MONKEY (portal lane fade): how fast an anchor's crossfade weight travels between the exterior
|
||||
/// lane (`0`) and the room lane (`1`), in weight-units/s. The same 2.0/s the ground-shade node
|
||||
/// ramps its intensity at ([`crate::entity_shade`]'s ambient chase, the binary's `[0x810804]`) —
|
||||
/// deliberately, because the two are the SAME transition seen from two sides: crossing a portal
|
||||
/// re-seeds the node's ambient chase at the moment it re-lanes the part, and a half-second lane
|
||||
/// blend under a quarter-second light chase (or vice versa) reads as two events, not one.
|
||||
///
|
||||
/// Surfaces already blend across a portal seam by geometry; entities had nothing, so an NPC
|
||||
/// stepping through a doorway swapped from night-dark to torch-warm in ONE frame.
|
||||
const LANE_RAMP_PER_SEC: f32 = 2.0;
|
||||
|
||||
/// Which WMO placements are resident — a generation counter the classifier re-evaluates entities on
|
||||
/// (a building streaming in under a standing NPC must re-light it even though it didn't move).
|
||||
/// Rebuilt each frame by the streamer ([`crate::terrain_stream`]) from its live placements; the
|
||||
@@ -254,9 +265,54 @@ pub struct InteriorAnchor {
|
||||
/// joining a matte-resolved anchor must force a re-resolve (the reauthor drain checks this),
|
||||
/// or it would ride the matte fallback until the anchor next moves.
|
||||
kind_bake: bool,
|
||||
/// MONKEY (portal lane fade): the crossfade weight this anchor's parts currently render at —
|
||||
/// `0` fully on the exterior result, `1` fully on the room result, chasing
|
||||
/// [`Self::lane_target`] at [`LANE_RAMP_PER_SEC`]. It lives on the ANCHOR and not per part for
|
||||
/// the same reason the law does: a body must never split across the blend (a half-faded head
|
||||
/// on a fully-faded torso is worse than the pop it replaces), and one `f32` per light node is
|
||||
/// nothing beside the `Vec3` position it already keeps.
|
||||
///
|
||||
/// The FIRST resolve snaps it to the target instead of ramping (see the insert site): an NPC
|
||||
/// or GameObject that streams in already standing indoors has no "outside" to come from, and
|
||||
/// fading it up from the exterior night result would make every indoor spawn flash dark —
|
||||
/// which is the very artifact this ramp exists to remove, dressed as its cure.
|
||||
lane: f32,
|
||||
}
|
||||
|
||||
impl InteriorAnchor {
|
||||
/// Where the lane weight is heading: the room result for either indoor law, the exterior one
|
||||
/// outside. Both indoor laws share it because the shader gives them the SAME room lighting
|
||||
/// (`wow_model.wgsl` routes `is_interior || matte_indoor` through `interior_room_light`), so a
|
||||
/// `Bake`↔`Matte` flicker at a doorway must not restart the blend.
|
||||
fn lane_target(law: AppliedLaw) -> f32 {
|
||||
match law {
|
||||
AppliedLaw::Exterior => 0.0,
|
||||
AppliedLaw::Matte | AppliedLaw::Bake(_) => 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Step the crossfade toward this anchor's STANDING law's target, answering whether it moved —
|
||||
/// which is what puts the anchor's parts back in the write path for a frame (the walk's
|
||||
/// `changed` disjunct). Exact at both ends: the clamp lands the weight ON the target, so the
|
||||
/// frame after a blend finishes reports `false` and the anchor is silent again.
|
||||
fn step_lane(&mut self, dt: f32) -> bool {
|
||||
let target = Self::lane_target(self.law);
|
||||
if self.lane == target {
|
||||
return false;
|
||||
}
|
||||
let step = dt * LANE_RAMP_PER_SEC;
|
||||
self.lane = match self.lane < target {
|
||||
true => (self.lane + step).min(target),
|
||||
false => (self.lane - step).max(target),
|
||||
};
|
||||
true
|
||||
}
|
||||
|
||||
/// The weight as the tag's 4-bit field ([`crate::mesh_tag::LANE_MAX`] steps).
|
||||
fn lane_bits(&self) -> u8 {
|
||||
lane_bits(self.lane)
|
||||
}
|
||||
|
||||
/// One line naming the lane this anchor's parts render under — the inspect card's light
|
||||
/// readout (`crate::interact`). "Which lane is this object on?" was the exact question decision
|
||||
/// 0776 was found by, and answering it took a rebuild with `WOW_INTERIOR_LOG` plus an offline
|
||||
@@ -271,6 +327,15 @@ impl InteriorAnchor {
|
||||
}
|
||||
}
|
||||
|
||||
/// MONKEY (portal lane fade): quantize a `0..=1` crossfade weight onto the tag's 4-bit lane field.
|
||||
/// Rounds rather than truncates, so a settled weight of exactly `1.0` reaches the saturated
|
||||
/// [`crate::mesh_tag::LANE_MAX`] — a truncating quantizer would leave every indoor entity one step
|
||||
/// short of the room lane forever.
|
||||
fn lane_bits(lane: f32) -> u8 {
|
||||
let max = f32::from(crate::mesh_tag::LANE_MAX);
|
||||
(lane * max).round().clamp(0.0, max) as u8
|
||||
}
|
||||
|
||||
/// Parts whose material/tag need re-authoring from their anchor's current law — the classifier's
|
||||
/// convergence queue (0734), replacing the per-part sweep's repair duty. Fed by the
|
||||
/// [`InteriorLit`] `on_add` hook (a fresh part joining a settled anchor), the fade-latch observer
|
||||
@@ -301,6 +366,12 @@ pub struct InteriorLit {
|
||||
/// by whether that room is on the camera's chain this frame — decision 1792 §4), so a part can
|
||||
/// need a rewrite for one with the other unchanged. Meaningless while `applied` is `None`.
|
||||
fogged: bool,
|
||||
/// MONKEY (portal lane fade): the tag's lane field as last written, the third component of the
|
||||
/// write gate. It has to be recorded separately from `applied` because two writes can name the
|
||||
/// same EFFECTIVE law and differ only in their blend weight — which is exactly what every
|
||||
/// frame of a portal crossing looks like — and a gate that saw only the law would write the
|
||||
/// first step of the blend and then freeze the entity mid-fade.
|
||||
lane: u8,
|
||||
}
|
||||
|
||||
impl InteriorLit {
|
||||
@@ -355,6 +426,7 @@ impl InteriorLit {
|
||||
exterior,
|
||||
applied: None,
|
||||
fogged: false,
|
||||
lane: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,6 +444,7 @@ impl InteriorLit {
|
||||
exterior,
|
||||
applied: Some(AppliedLaw::Bake(0)),
|
||||
fogged: true,
|
||||
lane: crate::mesh_tag::LANE_MAX,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -551,9 +624,21 @@ pub fn classify_entity_interior(
|
||||
// thing a settled anchor still re-reads is this bit. A compare per anchor, and a
|
||||
// part write only on the frames the verdict actually moves.
|
||||
let fog = anchor_room_fogged(state.room, &instances);
|
||||
if state.fog != fog {
|
||||
// MONKEY (portal lane fade): the settled gate above is MOVEMENT, and the blend is
|
||||
// TIME — a unit that steps through a doorway and stops dead in it (or is stopped
|
||||
// by the server's next position update landing on the same spot) still owes the
|
||||
// rest of its crossfade. So the lane steps here too, and a lane in motion puts the
|
||||
// anchor's parts back through the write loop for exactly the frames it moves.
|
||||
let lane_moved = state.step_lane(time.delta_secs());
|
||||
if state.fog != fog || lane_moved {
|
||||
state.fog = fog;
|
||||
n_written += write_anchor_parts(state.law, fog, lit_parts, &mut parts);
|
||||
n_written += write_anchor_parts(
|
||||
state.law,
|
||||
fog,
|
||||
state.lane_bits(),
|
||||
lit_parts,
|
||||
&mut parts,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -606,19 +691,31 @@ pub fn classify_entity_interior(
|
||||
resolve_us += _r.elapsed().as_secs_f32() * 1e6;
|
||||
let fog = anchor_room_fogged(room, &instances);
|
||||
let kind_bake = bake.is_some();
|
||||
let changed = match state.as_deref_mut() {
|
||||
// MONKEY (portal lane fade): the law flip and the blend step are two DIFFERENT reasons to
|
||||
// rewrite this anchor's parts, kept apart because only the first is an event — the second
|
||||
// is the ~half-second of frames after it, and folding them into one `changed` would spam
|
||||
// `WOW_INTERIOR_LOG` with 30 identical lines per doorway.
|
||||
let (law_changed, lane_moved, lane) = match state.as_deref_mut() {
|
||||
Some(state) => {
|
||||
let changed = state.law != law || state.fog != fog;
|
||||
let law_changed = state.law != law || state.fog != fog;
|
||||
state.law = law;
|
||||
state.room = room;
|
||||
state.fog = fog;
|
||||
state.last_pos = pos;
|
||||
state.generation = residency.generation;
|
||||
state.kind_bake = kind_bake;
|
||||
changed
|
||||
// Stepped AFTER the law lands, so the blend chases the verdict resolved THIS
|
||||
// frame — on the flip frame it moves one step off the lane it was on, never a
|
||||
// frame of the old target.
|
||||
let lane_moved = state.step_lane(time.delta_secs());
|
||||
(law_changed, lane_moved, state.lane_bits())
|
||||
}
|
||||
None => {
|
||||
// `try_insert`: the anchor may carry a same-frame despawn already queued.
|
||||
// The first resolve SNAPS the blend to its target rather than ramping into it
|
||||
// (see [`InteriorAnchor::lane`]): an entity that streams in already indoors has
|
||||
// no exterior state to come from, and fading it up would flash it dark.
|
||||
let lane = InteriorAnchor::lane_target(law);
|
||||
commands.entity(anchor).try_insert(InteriorAnchor {
|
||||
law,
|
||||
room,
|
||||
@@ -626,13 +723,15 @@ pub fn classify_entity_interior(
|
||||
last_pos: pos,
|
||||
generation: residency.generation,
|
||||
kind_bake,
|
||||
lane,
|
||||
});
|
||||
true
|
||||
(true, false, lane_bits(lane))
|
||||
}
|
||||
};
|
||||
// Write the parts only when the law actually changed, so re-testing a moving NPC mid-room
|
||||
// doesn't churn the render extraction.
|
||||
if !changed {
|
||||
// Write the parts only when the law actually changed (so re-testing a moving NPC mid-room
|
||||
// doesn't churn the render extraction) — or while its blend is still travelling, which is
|
||||
// a handful of parts on the few entities actually mid-portal.
|
||||
if !law_changed && !lane_moved {
|
||||
continue;
|
||||
}
|
||||
// `WOW_INTERIOR_LOG=1`: print interior classifications — the live-probe instrument for
|
||||
@@ -647,7 +746,9 @@ pub fn classify_entity_interior(
|
||||
// lighting like it": a node that resolves exterior and stays there is otherwise invisible
|
||||
// to this instrument, and looks identical to a node the walk never visited.
|
||||
let log = std::env::var("WOW_INTERIOR_LOG").ok();
|
||||
// `law_changed` and not the write condition: the blend's frames are not classifications.
|
||||
if log.is_some()
|
||||
&& law_changed
|
||||
&& (law != AppliedLaw::Exterior || had_state || log.as_deref() == Some("all"))
|
||||
{
|
||||
// The PART COUNT is load-bearing, not decoration: a lane readout says which law the
|
||||
@@ -697,7 +798,7 @@ pub fn classify_entity_interior(
|
||||
}
|
||||
);
|
||||
}
|
||||
n_written += write_anchor_parts(law, fog, lit_parts, &mut parts);
|
||||
n_written += write_anchor_parts(law, fog, lane, lit_parts, &mut parts);
|
||||
}
|
||||
// Drain the convergence queue: each entry re-authors from its anchor's standing law. Forced
|
||||
// through the part's change gate — the enqueuing edges (fade latch, zoom release) mean a
|
||||
@@ -723,9 +824,13 @@ pub fn classify_entity_interior(
|
||||
if matches!(lit.kind, InteriorKind::Bake { .. }) && !state.kind_bake {
|
||||
commands.entity(edge.0).try_remove::<InteriorAnchor>();
|
||||
}
|
||||
// MONKEY (portal lane fade): the anchor's CURRENT blend weight, so a part that converges
|
||||
// mid-crossing lands on exactly what the walk wrote its siblings this frame — a fresh
|
||||
// gear part authored at the settled weight would pop while the body it hangs off faded.
|
||||
n_written += usize::from(write_part_law(
|
||||
state.law,
|
||||
state.fog,
|
||||
state.lane_bits(),
|
||||
&mut lit,
|
||||
&mut material,
|
||||
&mut tag,
|
||||
@@ -790,6 +895,7 @@ type PartWrite<'w, 's> = Query<
|
||||
fn write_anchor_parts(
|
||||
law: AppliedLaw,
|
||||
fog: bool,
|
||||
lane: u8,
|
||||
lit_parts: Option<&LitParts>,
|
||||
parts: &mut PartWrite,
|
||||
) -> usize {
|
||||
@@ -800,6 +906,7 @@ fn write_anchor_parts(
|
||||
written += usize::from(write_part_law(
|
||||
law,
|
||||
fog,
|
||||
lane,
|
||||
&mut lit,
|
||||
&mut material,
|
||||
&mut tag,
|
||||
@@ -838,20 +945,31 @@ fn write_anchor_parts(
|
||||
/// `mesh_tag::describe` exists to catch a violation of (0355 broke exactly this way). Skipping it
|
||||
/// would leave a part that classifies indoors while still *pending* carrying a probe slot on the
|
||||
/// exterior material, where the shader decodes those bits as a ground-shade byte.
|
||||
///
|
||||
/// `lane` is the anchor's crossfade weight as tag bits (MONKEY, portal lane fade) and it is the
|
||||
/// reason this function has an EFFECTIVE law distinct from its argument — see [`effective_law`].
|
||||
fn write_part_law(
|
||||
law: AppliedLaw,
|
||||
fog: bool,
|
||||
lane: u8,
|
||||
lit: &mut InteriorLit,
|
||||
material: &mut MeshMaterial3d<WowModelMaterial>,
|
||||
tag: &mut MeshTag,
|
||||
force: bool,
|
||||
fade: Option<&crate::model_fade::FadeMaterials>,
|
||||
) -> bool {
|
||||
if lit.applied == Some(law) && lit.fogged == fog && !force {
|
||||
let law = effective_law(law, lane);
|
||||
// The gate is the full triple (law, fog, lane): two consecutive frames of a crossing name the
|
||||
// same effective law and differ only in the weight, so a law+fog gate would write the blend's
|
||||
// first step and freeze there. Exactly-gated in the other direction too — the weight reaches
|
||||
// its target exactly (`InteriorAnchor::step_lane` clamps onto it), so a settled part still
|
||||
// costs zero writes however long it stands there.
|
||||
if lit.applied == Some(law) && lit.fogged == fog && lit.lane == lane && !force {
|
||||
return false;
|
||||
}
|
||||
lit.applied = Some(law);
|
||||
lit.fogged = fog;
|
||||
lit.lane = lane;
|
||||
let want = match fade {
|
||||
Some(fm) => fm.material_for(Some(&*lit), true).clone(),
|
||||
None => lit.steady_material().clone(),
|
||||
@@ -863,14 +981,47 @@ fn write_part_law(
|
||||
AppliedLaw::Bake(slot) => crate::mesh_tag::with_interior_probe(tag.0, slot),
|
||||
// MONKEY (torch shadows Phase 3A): the Matte law keeps the exterior material but flags
|
||||
// the part INDOORS, so the shader's dynamic-interior lane holds across a Bake↔Matte
|
||||
// flicker (the down-ray marginally hitting the baked floor at one spot).
|
||||
AppliedLaw::Matte => crate::mesh_tag::with_matte_indoor(tag.0),
|
||||
// flicker (the down-ray marginally hitting the baked floor at one spot). Since the portal
|
||||
// lane fade it also carries the blend WEIGHT the shader mixes the two lanes by.
|
||||
AppliedLaw::Matte => crate::mesh_tag::with_matte_indoor(tag.0, lane),
|
||||
AppliedLaw::Exterior => crate::mesh_tag::with_exterior_reset(tag.0),
|
||||
};
|
||||
tag.0 = crate::mesh_tag::with_interior_fog(payload, fog);
|
||||
true
|
||||
}
|
||||
|
||||
/// MONKEY (portal lane fade): the law a part is actually AUTHORED under, given its anchor's
|
||||
/// standing law and the blend weight that law is being reached at.
|
||||
///
|
||||
/// **The blend only ever exists in exterior material mode.** The exterior half of the crossfade is
|
||||
/// the day/night result the exterior material computes, so both halves have to be available in one
|
||||
/// fragment — and `Bake` is a different MATERIAL (interior mode, the probe-slot payload), which
|
||||
/// cannot produce an exterior day/night result to fade from. So a part heading for `Bake` is
|
||||
/// written as `Matte` — the exterior material, flagged indoors, at the current weight — for the
|
||||
/// whole of the ramp, and commits the interior material and its probe slot only on the frame the
|
||||
/// weight saturates. There is no visual seam at the commit: the shader routes `is_interior` and
|
||||
/// the matte-indoor flag through the *same* `interior_room_light`, so the two agree at weight 1.
|
||||
///
|
||||
/// The mirror case is leaving: the standing law is already `Exterior` while the weight is still
|
||||
/// unwinding, and until it reaches zero the part must keep the flag (and the exterior material it
|
||||
/// already has) so the shader still has a room lane to fade OUT of. At zero it becomes the plain
|
||||
/// exterior reset, which is what clears the flag and the field together.
|
||||
///
|
||||
/// The probe-slot lifecycle (`seat_probe_slot`/`BakeState`/`ParticleLight`) deliberately does NOT
|
||||
/// wait on this: it turns over at the law flip, so a slot is seated (and the SH fold running)
|
||||
/// slightly before the part starts reading it, and released slightly after it stops. That is the
|
||||
/// harmless order — the alternative, a part on the interior material naming a slot the anchor has
|
||||
/// already released, is the stuck-black-unit bug (0734).
|
||||
fn effective_law(law: AppliedLaw, lane: u8) -> AppliedLaw {
|
||||
match law {
|
||||
// Mid-ramp toward the room: hold the exterior material and blend on the tag.
|
||||
AppliedLaw::Bake(_) if lane < crate::mesh_tag::LANE_MAX => AppliedLaw::Matte,
|
||||
// Mid-ramp back out: the verdict is outdoors, the blend is not there yet.
|
||||
AppliedLaw::Exterior if lane > 0 => AppliedLaw::Matte,
|
||||
settled => settled,
|
||||
}
|
||||
}
|
||||
|
||||
/// **Is this anchor's room on the camera's interior-fog chain this frame?** — the `[P+0x98]` half
|
||||
/// of a unit's fog lane (decision 1792 §4), resolved through the same chain the room-visibility
|
||||
/// leg walks: anchor record → placement instance → the flood's per-group gate.
|
||||
@@ -1154,6 +1305,10 @@ mod tests {
|
||||
last_pos: Vec3::ZERO, // matches the transform: the settled gate sees NO movement
|
||||
generation,
|
||||
kind_bake: true,
|
||||
// MONKEY (portal lane fade): a SETTLED anchor — the blend already sits on its
|
||||
// law's target, so `step_lane` is a no-op and the walk reads exactly as it did
|
||||
// before the crossfade existed. Every fixture below is settled the same way.
|
||||
lane: 1.0,
|
||||
},
|
||||
))
|
||||
.id();
|
||||
@@ -1272,6 +1427,7 @@ mod tests {
|
||||
last_pos: Vec3::ZERO,
|
||||
generation,
|
||||
kind_bake: false,
|
||||
lane: 1.0,
|
||||
},
|
||||
))
|
||||
.id();
|
||||
@@ -1318,6 +1474,7 @@ mod tests {
|
||||
last_pos: Vec3::ZERO, // settled: the walk never re-rays this anchor
|
||||
generation,
|
||||
kind_bake: false,
|
||||
lane: 1.0,
|
||||
},
|
||||
))
|
||||
.id();
|
||||
@@ -1387,6 +1544,7 @@ mod tests {
|
||||
last_pos: Vec3::ZERO,
|
||||
generation,
|
||||
kind_bake: true,
|
||||
lane: 1.0,
|
||||
},
|
||||
))
|
||||
.id();
|
||||
@@ -1457,6 +1615,7 @@ mod tests {
|
||||
last_pos: Vec3::ZERO,
|
||||
generation,
|
||||
kind_bake: false,
|
||||
lane: 0.0,
|
||||
},
|
||||
))
|
||||
.id();
|
||||
@@ -1546,6 +1705,7 @@ mod tests {
|
||||
last_pos: Vec3::ZERO, // settled: the walk skips the ray, the drain does the work
|
||||
generation,
|
||||
kind_bake: true,
|
||||
lane: 1.0,
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
@@ -51,8 +51,12 @@
|
||||
//! ([`MATTE_INDOOR_BIT`], MONKEY torch shadows Phase 3A): the classifier's `Matte` law — an
|
||||
//! entity standing INDOORS whose material nonetheless stays in exterior mode (day/night, no
|
||||
//! bake). `wow_model.wgsl` routes such a part to the dynamic-interior lane on it, so a unit
|
||||
//! whose verdict flickers `Bake`↔`Matte` at one spot keeps its lane. Bits 15..=18 are
|
||||
//! reserved (0).
|
||||
//! whose verdict flickers `Bake`↔`Matte` at one spot keeps its lane. Bits 15..=18 are the
|
||||
//! **lane weight** ([`LANE_MASK`], MONKEY portal lane fade): a 4-bit crossfade from the
|
||||
//! exterior result (`0`) to the room result (`15`), so an entity walking through a portal
|
||||
//! blends between the two lanes over the anchor's ramp instead of popping in one frame. It is
|
||||
//! meaningful only alongside bit 14 — [`with_matte_indoor`] writes the pair, and the shader
|
||||
//! mixes by it.
|
||||
//! - **Interior probe slot** (interior M2 props/entities — material in interior mode,
|
||||
//! `model_flags.z` set, not a WMO): bits 6..=18 carry the SH-probe TABLE SLOT (see
|
||||
//! [`crate::lighting::PropProbes`]; 8192 slots = 13 bits), alpha and rig ride their fixed
|
||||
@@ -146,6 +150,19 @@ const SHADE_SHIFT: u32 = 6;
|
||||
/// Sits in the exterior payload's reserved bits, so [`with_shade`]/[`with_alpha`]/[`with_rig`]
|
||||
/// carry it through and the shader's 8-bit shade decode never sees it.
|
||||
pub(crate) const MATTE_INDOOR_BIT: u32 = 0x0000_4000;
|
||||
/// Bits 15..=18 of the exterior payload (MONKEY, portal lane fade): the **lane weight**, a 4-bit
|
||||
/// crossfade `0` (fully exterior-lit) … [`LANE_MAX`] (fully room-lit) that the shader mixes the
|
||||
/// two lanes by. It rides beside [`MATTE_INDOOR_BIT`] — the bit says "this part is on the room
|
||||
/// lane at all", the field says how far — and like it, sits above the shade byte so
|
||||
/// [`with_shade`]/[`with_alpha`]/[`with_rig`]/[`with_interior_fog`] carry it through and
|
||||
/// [`with_exterior_reset`] clears it. 16 steps is past perceptual for a half-second ramp (the
|
||||
/// same reasoning as the 6-bit alpha), and the four bits are exactly what the exterior payload
|
||||
/// had left over.
|
||||
const LANE_MASK: u32 = 0x0007_8000;
|
||||
const LANE_SHIFT: u32 = 15;
|
||||
/// The saturated lane weight — the quantization step count the ramp's owner
|
||||
/// (`crate::interior::InteriorAnchor`) rounds its `0..=1` fraction onto.
|
||||
pub(crate) const LANE_MAX: u8 = 15;
|
||||
/// Bits 6..=18 of the interior payload: the SH-probe table slot (13 bits ⇔ 8192 slots).
|
||||
const PROBE_MASK: u32 = 0x0007_ffc0;
|
||||
const PROBE_SHIFT: u32 = 6;
|
||||
@@ -207,11 +224,24 @@ pub(crate) fn with_exterior_reset(tag: u32) -> u32 {
|
||||
(tag & RIG_MASK) | carried_alpha(tag)
|
||||
}
|
||||
|
||||
/// Rewrite a tag as a fresh exterior payload flagged MATTE-INDOOR ([`MATTE_INDOOR_BIT`]),
|
||||
/// preserving the rig and alpha fields: the classifier's `Matte`-law write (MONKEY, torch shadows
|
||||
/// Phase 3A). The shade writer re-asserts its byte the same frame and carries the bit through.
|
||||
pub(crate) fn with_matte_indoor(tag: u32) -> u32 {
|
||||
with_exterior_reset(tag) | MATTE_INDOOR_BIT
|
||||
/// Rewrite a tag as a fresh exterior payload flagged MATTE-INDOOR ([`MATTE_INDOOR_BIT`]) at lane
|
||||
/// weight `lane`, preserving the rig and alpha fields: the classifier's `Matte`-law write (MONKEY,
|
||||
/// torch shadows Phase 3A) and — since the portal lane fade — every write of a part whose anchor's
|
||||
/// crossfade is still in motion, whichever indoor law it is heading for. The shade writer
|
||||
/// re-asserts its byte the same frame and carries both the bit and the field through.
|
||||
///
|
||||
/// The two travel together on purpose: the shader reads the field ONLY under the bit, so a lane
|
||||
/// weight can never be mistaken for the reserved zeros of a plain exterior payload. `lane` is
|
||||
/// clamped to [`LANE_MAX`] rather than masked — a caller's rounding overshoot must saturate at
|
||||
/// "fully room-lit", not wrap to "fully exterior".
|
||||
pub(crate) fn with_matte_indoor(tag: u32, lane: u8) -> u32 {
|
||||
with_exterior_reset(tag) | MATTE_INDOOR_BIT | (u32::from(lane.min(LANE_MAX)) << LANE_SHIFT)
|
||||
}
|
||||
|
||||
/// Read back the lane weight of an exterior-payload tag (`0`..=[`LANE_MAX`]) — the probe readout
|
||||
/// and the tests. Meaningless on an interior payload, where these bits are part of the probe slot.
|
||||
pub(crate) fn lane_of(tag: u32) -> u8 {
|
||||
((tag & LANE_MASK) >> LANE_SHIFT) as u8
|
||||
}
|
||||
|
||||
/// **A spawned part's initial `MeshTag`** — its rig slot and its starting render alpha, the two
|
||||
@@ -339,10 +369,14 @@ pub fn describe(tag: u32) -> String {
|
||||
(false, true) => " fog",
|
||||
(false, false) => "",
|
||||
};
|
||||
// MONKEY (portal lane fade): the lane weight is printed with the exterior reading (`shade`),
|
||||
// because it shares the same law — on an interior payload these bits are inside the `slot`
|
||||
// beside it, exactly as the shade byte is.
|
||||
format!(
|
||||
"{tag:#010x}{flags} α {:.3} shade {} / slot {} rig {}",
|
||||
"{tag:#010x}{flags} α {:.3} shade {} lane {} / slot {} rig {}",
|
||||
alpha_of(tag),
|
||||
shade_of(tag),
|
||||
lane_of(tag),
|
||||
(tag & PROBE_MASK) >> PROBE_SHIFT,
|
||||
rig_of(tag),
|
||||
)
|
||||
@@ -472,22 +506,47 @@ mod tests {
|
||||
assert_eq!((probe_bits(6660) & PROBE_MASK) >> PROBE_SHIFT, 6660);
|
||||
}
|
||||
|
||||
/// MONKEY (torch shadows Phase 3A): the Matte-law indoor flag rides the exterior payload's
|
||||
/// reserved bit 14 — the per-frame shade/alpha writers carry it, the shade decode never sees
|
||||
/// it, and the exterior reclaim clears it.
|
||||
/// MONKEY (torch shadows Phase 3A + portal lane fade): the Matte-law indoor flag rides the
|
||||
/// exterior payload's reserved bit 14 and the lane weight bits 15..=18 — the per-frame
|
||||
/// shade/alpha/rig/fog writers carry BOTH, the shade decode never sees either, and the
|
||||
/// exterior reclaim clears both. The lane field is what makes the flag a crossfade instead of
|
||||
/// a switch, so anything that carried the bit and dropped the field would silently snap an
|
||||
/// entity to fully room-lit the first frame `entity_shade` re-asserted its byte.
|
||||
#[test]
|
||||
fn matte_indoor_bit_survives_the_field_writers_and_clears_on_exterior() {
|
||||
let t = with_matte_indoor(rig_bits(9) | alpha_bits(0.5));
|
||||
let t = with_matte_indoor(rig_bits(9) | alpha_bits(0.5), 6);
|
||||
assert_eq!(t & MATTE_INDOOR_BIT, MATTE_INDOOR_BIT);
|
||||
assert_eq!(lane_of(t), 6);
|
||||
assert_eq!(rig_of(t), 9);
|
||||
assert_eq!(t & ALPHA_MASK, alpha_bits(0.5));
|
||||
assert_eq!(with_shade(t, 255) & MATTE_INDOOR_BIT, MATTE_INDOOR_BIT);
|
||||
assert_eq!(lane_of(with_shade(t, 255)), 6);
|
||||
assert_eq!(shade_of(with_shade(t, 255)), 255);
|
||||
assert_eq!(with_alpha(t, 0.25) & MATTE_INDOOR_BIT, MATTE_INDOOR_BIT);
|
||||
assert_eq!(lane_of(with_alpha(t, 0.25)), 6);
|
||||
assert_eq!(with_rig(t, 3) & MATTE_INDOOR_BIT, MATTE_INDOOR_BIT);
|
||||
assert_eq!(lane_of(with_rig(t, 3)), 6);
|
||||
assert_eq!(with_interior_fog(t, true) & MATTE_INDOOR_BIT, MATTE_INDOOR_BIT);
|
||||
assert_eq!(lane_of(with_interior_fog(t, true)), 6);
|
||||
assert_eq!(with_exterior_reset(t) & MATTE_INDOOR_BIT, 0);
|
||||
assert_eq!(lane_of(with_exterior_reset(t)), 0);
|
||||
assert_eq!(shade_of(t), 0, "the flag is outside the shade byte");
|
||||
// …and the lane is outside it too, at BOTH ends of its range: a saturated lane must not
|
||||
// read back as a shade byte of 240 (the failure the pair of decodes is here to catch).
|
||||
let full = with_matte_indoor(alpha_bits(1.0), LANE_MAX);
|
||||
assert_eq!(lane_of(full), LANE_MAX);
|
||||
assert_eq!(shade_of(full), 0);
|
||||
assert_eq!(shade_of(with_shade(full, 128)), 128);
|
||||
assert_eq!(lane_of(with_shade(full, 128)), LANE_MAX);
|
||||
// An overshooting caller SATURATES rather than wrapping into the probe/rig neighbourhood.
|
||||
let over = with_matte_indoor(alpha_bits(1.0), 200);
|
||||
assert_eq!(lane_of(over), LANE_MAX);
|
||||
assert_eq!(over & (RIG_MASK | SHADE_MASK), 0, "no spill into its neighbours");
|
||||
// Lane 0 under the flag is legal (the first tick of a ramp) and is NOT the exterior reset:
|
||||
// the bit is what routes the shader into the mix, the field is only its weight.
|
||||
let zero = with_matte_indoor(alpha_bits(1.0), 0);
|
||||
assert_eq!(lane_of(zero), 0);
|
||||
assert_ne!(zero & MATTE_INDOOR_BIT, 0);
|
||||
}
|
||||
|
||||
/// Decision 0755: the classifier's whole-payload rewrites carry the tag's ALPHA field, which
|
||||
|
||||
Reference in New Issue
Block a user