From f84fa9eda821fa67923ac7ce1eff642c4edd567f Mon Sep 17 00:00:00 2001 From: Naruto TAKAHASHI Date: Thu, 10 Sep 2026 13:25:56 +0900 Subject: [PATCH] fix(player): a mask writer switched off with MASK=0 writes nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_build_mask_writers` decided what writes the coverage bitmap from static PartData alone, so a writer stayed active on every frame of every animation. Rule_Mask.md §2-2 puts half of that decision in FrameData: a writer writes only where its per-frame `mask` is non-zero and it is not hidden, and both checks belong to the Player. `mask` did reach the coverage shader as the cutout threshold, where `mask == 0` maps to 1.0 and discards every texel — but the shape branch overrides the threshold to -1.0, because a shape mask samples no texture and its coverage is its whole geometry. So the one writer kind that has no other way to be switched off was exactly the kind whose switch was bypassed. An authored `MASK` of 0 on a shape mask covered the screen instead of covering nothing, and every `visibleInsideMask` target passed everywhere. The test goes after the pure-mask flag, so a writer that writes nothing still draws no colour of its own, and before the 24-bit budget, so it costs no bit. Checked against a converted Spine clipping (19 pieces keyed 255 plus the switched-off rectangle that stands in for "not clipping"): the pieces stay writers and the rectangle no longer does. The nine writers in `tests/overall/Mask.ssae` are all keyed 255 and are unaffected, which is also why the suite never caught this. --- ss_player/ss_internal_player.cpp | 12 ++++++++++++ ss_player/ss_internal_player.h | 13 ++++++++----- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/ss_player/ss_internal_player.cpp b/ss_player/ss_internal_player.cpp index 2ec044c..8796a75 100644 --- a/ss_player/ss_internal_player.cpp +++ b/ss_player/ss_internal_player.cpp @@ -978,6 +978,18 @@ bool SsInternalPlayer::_build_mask_writers(const DrawFrame& f) { // drawing its own colour over the scene. if (pure_mask) pure_mask_flags[p_idx] = 1; + // A writer only writes where its per-frame mask is non-zero and it is not + // hidden (Rule_Mask.md §2-2). Both live in FrameData, so this is the one + // half of the classification PartData cannot answer: an authored MASK of 0 + // is how a writer is switched off over a range of frames, and the cutout + // threshold cannot stand in for it — a shape mask samples no texture, so + // its coverage is its whole geometry whatever the threshold says. A part + // with no PartState this frame was not evaluated and writes nothing, which + // is what `_bake_coverage_geometry` would decide about it anyway. + // Tested before the bitmap budget so a switched-off writer costs no bit. + const auto* ps = (p_idx < (int)_parts_by_idx.size()) ? _parts_by_idx[p_idx] : nullptr; + if (!ps || ps->mask() == 0.0f || ps->hide()) continue; + // Bitmap holds 24 writers; keep scanning past that so the pure masks that // did not fit are still flagged above. if ((int)_mask_writers.size() >= MAX_MASK_WRITERS) continue; diff --git a/ss_player/ss_internal_player.h b/ss_player/ss_internal_player.h index 83d6f97..d5349b5 100644 --- a/ss_player/ss_internal_player.h +++ b/ss_player/ss_internal_player.h @@ -555,7 +555,8 @@ class SsInternalPlayer { // ---- CBP masking (clever bit packing) ---------------------------------- // One entry per part that writes the mask this frame. Rebuilt by - // `_build_mask_writers` each frame from draw_order + static PartData. + // `_build_mask_writers` each frame from draw_order, static PartData, and the + // per-frame mask / hide that say whether a writer writes at all. // `bit` is the writer's slot in the coverage bitmap (0..MAX_MASK_WRITERS-1). // `op_invert` comes from PartData.mask_influence (false=increment / true= // invert). `is_clipping` selects the scope direction: a pure Mask part @@ -575,11 +576,13 @@ class SsInternalPlayer { Vector _mask_writers; // Per part index (parallel to `_parts_by_idx`): 1 when the part is a "pure" // mask this frame. Rebuilt with `_mask_writers` so the emit paths can test it - // once per part without walking the writer list. + // once per part without walking the writer list. A pure mask keeps this flag + // when it writes nothing — switched off, hidden, or past the bit budget — so + // that it still draws no colour of its own. LocalVector _part_pure_mask; - // Populate `_mask_writers` from this frame's draw_order + static PartData. - // Returns true if at least one writer is present (i.e., masking is active - // this frame). Only the top-root player owns the mask state. + // Populate `_mask_writers` from this frame's draw_order, static PartData and + // per-frame mask / hide. Returns true if at least one writer is present (i.e., + // masking is active this frame). Only the top-root player owns the mask state. bool _build_mask_writers(const DrawFrame& f); // ---- CBP coverage bitmap (offscreen RGBA8 = 32 mask bits) --------------