Skip to content

fix: correct mask writer mixing and instance-hierarchy mask composition - #251

Merged
MasamiYitsuse merged 3 commits into
developfrom
feature/fix_masking-conflict
Aug 1, 2026
Merged

fix: correct mask writer mixing and instance-hierarchy mask composition#251
MasamiYitsuse merged 3 commits into
developfrom
feature/fix_masking-conflict

Conversation

@MasamiYitsuse

Copy link
Copy Markdown
Collaborator

Two masking defects, both confirmed against SS7.5 by SSPlayerForUnity first and ported here. Companion to SpriteStudio-SDK#341, which fixes the guide sample this player had been written from (already merged; the submodule bump is included).

1. Invert and increment writers shared one accumulator

Invert and IncrementWrap are each their own inverse in isolation, but they do not commute. The CBP replay folded both into a single 8-bit stencil, so wherever writers of differing mask_influence overlapped, coverage depended on the order the writers were emitted in — increment then invert landed on 254 (covered) where invert then increment wrapped back to 0 (not covered).

Split into a parity accumulator (mask_influence == 1) and a counter (mask_influence == 0), unioned at the end. Two inverted masks cancel, three cover (parity — not "exactly one"), and any increment writer covers outright.

Unlike a GPU stencil, the replay divides no bit budget, so ForUnity's "7 overlapping masks" ceiling (a consequence of URP's reserved bits) does not apply here.

2. Instance settings replaced the callee's instead of composing

SpriteStudio composes mask settings down an instance hierarchy: visible_inside_mask with OR, mask_influence with AND. The player decided both from the instance part's own flags and stamped one polarity across every material the child owned, so the sub-animation's parts never took part. It disagreed with SS7.5 in both directions — a callee opting out with mask_influence == 0 was masked anyway, and a callee's visible_inside_mask was flattened to the caller's.

The composed context is now pushed down before the child builds, the child bakes the resulting polarity into its own per-part materials, and the coverage owner binds only coverage / meta / UV / rank. The identity (influence = true, visible_inside = false) leaves a top-level player resolving to its parts' own flags, so unnested playback is bit-identical to before.

Godot-specific wrinkle: whether the mask reaches an instance is known only after the coverage is rasterized, which happens after the children build. That gate stays where it was; beforehand it is predicted from static data (does the part tree hold any writer). Over-predicting costs a per-part material that is then disabled; under-predicting would drop the mask outright.

This also uncovered a gap the blanket stamp had been hiding: the three emit paths gated the decision on this player owning coverage, so a sub-animation with no mask parts of its own could not be an inherited target at all. They now accept an inherited context too.

Verification

Visually checked in the editor against SS7.5 using tests/overall/Mask.ssae: mixed mask_influence overlap now unions, same-influence pairs still cancel, and a callee's own settings survive composition. Both builds green (GDExtension + custom module).

Known limitations (unchanged / documented)

  • Multi-level instance nesting is unverified against SS7.5 — as it is in the SDK guide (2-6) and in ForUnity. The implementation chains by passing the composed value down a level at a time.
  • A child that owns masks and is inherited-masked still resolves to the inherited one. Pre-existing (one coverage sampler), and treated as a separate matter by the guide.

Notes

The submodule bump carries documentation only — no code, schema or header changes, so no rebuild is required for it.

YITSUSE, Masami added 3 commits August 2, 2026 03:47
`Invert` and `IncrementWrap` are each their own inverse in isolation, but they
do not commute. The CBP replay folded both into one 8-bit `stencil`
accumulator, so wherever writers of differing `mask_influence` overlapped,
coverage depended on the order the writers were emitted in: `increment then
invert` landed on 254 (covered) where `invert then increment` wrapped back to
0 (not covered).

Split the accumulator in two - parity for `mask_influence == 1`, a counter for
`mask_influence == 0` - and take the union. That is what SpriteStudio
specifies where writers of differing `mask_influence` overlap: two inverted
masks cancel, three cover (parity, not "exactly one"), and any increment
writer covers outright.

A GPU stencil has to divide its `WriteMask` bits to achieve the same thing
(SDK masking guide 3-1-4), which is what caps ForUnity's counter at 7
overlapping masks under URP. The replay uses ordinary shader variables, so
nothing is split and no such ceiling applies here.

The defect was inherited from SS6PlayerForUnity and fixed in SSPlayerForUnity
first; the SDK guide's own coverage-bitmap sample carried it too and has been
corrected alongside.
A mask reaching an instance part applies to the whole sub-animation, but the
calling part's settings do not replace the callee's - SpriteStudio composes
them: `visible_inside_mask` with OR, `mask_influence` with AND (SDK masking
guide 2-6). The player decided both from the instance part's own flags and
stamped a single polarity across every material the child owned, so the
sub-animation's own parts never took part in the decision. It disagreed with
SS7.5 in both directions: a callee opting out with `mask_influence == 0` was
masked anyway, and a callee's `visible_inside_mask` was flattened to the
caller's.

Compose before the child builds. `_update_instance_children` pushes the
composed context down, the child bakes the resulting polarity into its own
per-part materials, and the coverage owner then binds only the coverage, meta,
UV and rank - it no longer overwrites the polarity. The identity
(influence = true, visible_inside = false) leaves a top-level player resolving
to its parts' own flags, so unnested playback is bit-identical to before.

Whether the mask reaches an instance at all is known only once the coverage is
rasterized, which happens after the children build, so that gate stays where
it was; beforehand it is predicted from static data (does the part tree hold
any writer). Over-predicting costs a per-part material that is then disabled,
while under-predicting would drop the mask outright.

The three emit paths also gated the decision on this player owning coverage,
so a sub-animation with no mask parts of its own could not be an inherited
target at all - the blanket stamp had been covering for that. They now accept
an inherited context as well.

Nested instances chain by passing the composed value down one level at a time,
with the owner's bind walking the sub-tree. Multi-level nesting is unverified
against SS7.5, as it is in the SDK guide and in ForUnity. A child that owns
masks and is also inherited-masked still resolves to the inherited one, which
is unchanged behaviour.
@MasamiYitsuse
MasamiYitsuse merged commit 94e33d3 into develop Aug 1, 2026
0 of 2 checks passed
@MasamiYitsuse
MasamiYitsuse deleted the feature/fix_masking-conflict branch August 1, 2026 23:01
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