Skip to content

fix(player): a mask writer switched off with MASK=0 writes nothing - #305

Merged
Naruto merged 1 commit into
developfrom
fix/a-mask-writer-switched-off-writes-nothing
Sep 11, 2026
Merged

fix(player): a mask writer switched off with MASK=0 writes nothing#305
Naruto merged 1 commit into
developfrom
fix/a-mask-writer-switched-off-writes-nothing

Conversation

@Naruto

@Naruto Naruto commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

What was wrong

_build_mask_writers decided what writes the coverage bitmap from static PartData alone (draw_as_mask / mask_write), so a writer was active on every frame of every animation. Rule_Mask.md §2-2 puts half of that decision in FrameData:

For the masking side (the writer) to work, both of the following conditions must hold: mask != 0Hide == false

and it says explicitly that both checks belong to the Player.

mask did reach the coverage shader, as the cutout threshold — ss_mask_write.gdshader documents the intent: "mask == 0 maps to threshold 1.0 so nothing is written (empty mask)". But the shape branch overrides the threshold to -1.0 (no_cutout), correctly, because a shape mask samples no texture and its coverage is its whole geometry whatever the alpha says. So the one writer kind that has no other way to be switched off was exactly the kind whose switch was bypassed.

Authoring a MASK of 0 on a shape mask therefore covered the screen instead of covering nothing, and every visibleInsideMask target passed everywhere.

Found on a Spine clipping converted to shape masks: the polygon becomes N pieces keyed MASK=255 plus one rectangle covering the canvas that is keyed MASK=0 and stands in for "not clipping on this frame". That rectangle wrote coverage over everything, so the clipped sprite was visible on every frame instead of only inside the shape.

The change

One test in _build_mask_writers:

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;

Both placements matter:

  • After pure_mask_flags[p_idx] = 1 — a writer that writes nothing must still draw no colour of its own, the same reasoning the MAX_MASK_WRITERS overflow already relies on.
  • Before the bit budget — a switched-off writer costs none of the 24 coverage bits.

A part with no PartState this frame was not evaluated and writes nothing, which is what _bake_coverage_geometry decides about it anyway.

_bubble_child_clip_writers and _has_visible_clip_bubbling both read the child's _mask_writers, so the single test covers the instance carry-over path too. Header comments that said the classification comes from static PartData are updated.

Verification

  • scripts/build-extension.sh — clean.
  • scripts/run-tests.sh35 cases, 101 assertions, 0 failed, 0 skipped.
  • Behaviour measured headless against the converted clipping, with a temporary probe on the writer decision: the 19 pieces (mask=255) stay writers, the stand-in rectangle (mask=0) is now skipped. Before the change it was a writer covering ~9 canvases.
  • No regression on the spec fixture: the nine writers in tests/overall/Mask.ssae are keyed 255 (one 254) and all stay writers. No writer anywhere in that project is keyed 0, which is why the suite never caught this — worth a case if the fixture ever grows one.

Not in this PR

  • The same gap exists in SSPlayerForUnity, SpriteStudioMaskPlanner.BuildWriters (its doc comment says mirrors _build_mask_writers) with the matching DrawKind == Shape ? -1f threshold override.
  • With no writer at all this frame, _resolve_part_mask returns an unmasked decision, so a visibleInsideMask target draws unclipped where the Editor would drop it. Pre-existing, unchanged here.
  • The coverage bitmap holds 24 writers and silently masks nothing past that; a clipping cut into more pieces than that is only partly masked.

`_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.
@Naruto
Naruto merged commit 32bde2b into develop Sep 11, 2026
0 of 2 checks passed
@Naruto
Naruto deleted the fix/a-mask-writer-switched-off-writes-nothing branch September 11, 2026 07:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant