fix: correct mask writer mixing and instance-hierarchy mask composition - #251
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
InvertandIncrementWrapare each their own inverse in isolation, but they do not commute. The CBP replay folded both into a single 8-bitstencil, so wherever writers of differingmask_influenceoverlapped, coverage depended on the order the writers were emitted in —increment then invertlanded on 254 (covered) whereinvert then incrementwrapped 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_maskwith OR,mask_influencewith 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 withmask_influence == 0was masked anyway, and a callee'svisible_inside_maskwas 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: mixedmask_influenceoverlap 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)
Notes
The submodule bump carries documentation only — no code, schema or header changes, so no rebuild is required for it.