fix: draw mask coverage in the sampled frame, and keep Permanent part overrides across animation changes - #242
Merged
Conversation
added 2 commits
July 27, 2026 17:07
The coverage viewport was created without a parent viewport, so the server treated it as a root and drew it after the window it belongs to (_sort_active_viewports orders children first, parents last, and falls back to creation order for roots). Maskable parts therefore sampled the coverage rasterized on the previous frame. Parent the coverage target to the viewport its borrowing player renders into. The server then draws it ahead of that viewport, so the coverage a part samples is the one rendered this frame, and both the latency and the distortion it caused are gone. The UV transform handed to maskable shaders must move with it. It was held back a frame to match the bbox the sampled texels were actually rasterized with; now that the texels are this frame's, this frame's transform is the matching one, so publish it immediately. Keeping the hold would offset the lookup by a frame in the opposite direction. Same-frame rendering also retires the machinery that covered for the latency: the `fresh` flag that skipped masking for one frame after a (re)acquire, the `_mask_prev` target held one extra frame so a size-class transition could sample the outgoing coverage, and with them the one-frame gap while zooming across a class boundary. A freshly acquired target now carries this frame's coverage, from the first frame of masking onward. Targets are pooled and shared between players that may live under different viewports, so the parent link is re-established on every acquire, and on any host change while a target is borrowed. `viewport_set_parent_viewport` only assigns the parent - it does not flag the sorted viewport list dirty - so activation is toggled to force the re-sort. The link is also cleared on release: freeing a viewport does not detach its children, and a viewport whose parent no longer resolves is dropped from the render order entirely. Verified with examples/mask_uv_test under --fixed-fps 60, classifying pixels rather than eyeballing: D_Translate (20px/frame) now produces the same hole/reference statistics as A_Static on every sampled frame, so the alignment no longer depends on how fast the mask moves. B_Rotate and C_ScaleXY show no uncovered hole either, a sweep across the full scale range crosses size classes without dropping a frame, and masking is correct from the first rendered frame.
Changing animation re-borrowed and re-bound the SSAB: _fetchAnimation destroyed the resource handle, created a fresh borrow of the same buffer and called ss_runtime_bind_resource, on every setAnimation. Binding tells the runtime the part identities may be new, so it drops every part override - correct for a new SSAB, but it also ran for a move between two animations of the same one, taking the Permanent-priority overrides with it. docs/ja/api/player.md documents those as persisting for as long as the same .ssab plays. The runtime's own handling was already right: setup_animation_by_hash calls retain_permanent_overrides_on_setup, which clears Visibility and the NON/NOW Color/Cell overrides and keeps the Permanent ones. It just never had anything left to keep, because the bind ahead of it had cleared the whole table. Re-bind only when the resource itself changed. setSSABResource flags it - covering both a different resource and the same one reloaded from disk through onSSABReloaded, whose buffer may have moved - and _fetchAnimation clears the flag once it has re-bound. An animation change within one SSAB now keeps the borrow. Nothing else is skipped: setup_animation_by_hash collapses to the primary source and resizes every layer (which clears the decode cursors) on its own, and the FlatBuffer builder is reset per frame-data build. Verified with examples/mask_uv_test/perm_override_test.gd, sampling the same pixel in both builds: with a Permanent override set and one `animation = ...`, the part read (0,0,0) before and reads the override colour (41,107,217) after, while the control that never switches reads the override colour in both. Re-assigning the resource still drops the override, by design, and the part keeps drawing. The mask captures are bit-identical across this change.
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.
Description
Two player-side fixes in the same area. No SDK change is required for either — the submodule pin is untouched.
1. Mask coverage is rendered in the frame it is sampled (
f6718a2)The coverage viewport was created without a parent viewport, so the server treated it as a root and drew it after the window it belongs to.
_sort_active_viewportsorders children first and parents last, falling back to creation order for roots, and the coverage target is created after the main window. Maskable parts therefore sampled the coverage rasterized on the previous frame.Parenting the coverage target to the viewport its borrowing player renders into puts it ahead of that viewport, so both the one-frame latency and the distortion it caused are gone.
The UV transform handed to maskable shaders moves with it. It was held back a frame (#239) to match the bbox the sampled texels were actually rasterized with; now that the texels are this frame's, this frame's transform is the matching one. Keeping the hold would offset the lookup by a frame in the opposite direction — the two are one dial, not two independent fixes.
Same-frame rendering also retires the machinery that compensated for the latency:
freshflag that skipped masking for one frame after a (re)acquire_mask_prev, the outgoing target held one extra frame so a size-class transition had something valid to samplePooling notes: targets are shared between players that may live under different viewports, so the parent link is re-established on every acquire and on any host change while a target is borrowed.
viewport_set_parent_viewportonly assigns the parent — it does not flag the sorted viewport list dirty — so activation is toggled to force the re-sort. The link is cleared on release as well: freeing a viewport does not detach its children, and a viewport whose parent no longer resolves is dropped from the render order entirely.2. Permanent part overrides survive an animation change (
8717c2c)set_part_color_override(..., priority = 2)was lost onanimation = ..., contradictingdocs/ja/api/player.md, which documents Permanent as persisting for as long as the same.ssabplays._fetchAnimationdestroyed the resource handle, created a fresh borrow of the same buffer and calledss_runtime_bind_resourceon everysetAnimation. Binding tells the runtime the part identities may be new, so it drops every part override — correct for a new SSAB, but it also ran for a move between two animations of the same one.The runtime was already right:
setup_animation_by_hashcallsretain_permanent_overrides_on_setup, which clears Visibility and the NON/NOW Color/Cell overrides and keeps the Permanent ones. It simply had nothing left to keep, because the bind ahead of it had cleared the table.Re-bind only when the resource itself changed.
setSSABResourceflags it — covering a different resource and the same one reloaded from disk throughonSSABReloaded, whose buffer may have moved — and_fetchAnimationclears the flag once it has re-bound. Nothing else is skipped:setup_animation_by_hashcollapses to the primary source and resizes every layer (which clears the decode cursors) on its own, and the FlatBuffer builder is reset per frame-data build.Verification
Both builds are green (GDExtension and custom module); no new warnings.
Checked with
examples/mask_uv_testunder--fixed-fps 60, classifying pixels rather than judging by eye. The project places a reference part over the mask, so an aligned hole produces a fixed relationship between the two.Mask coverage
D_Translate(20 px/frame)A_Static(5355 / 41468) on every sampled frame — the alignment no longer depends on how fast the mask movesB_Rotate/C_ScaleXYfresh/_mask_prevA residual one-frame lag would show up as roughly 7600 extra pixels on this metric, so it is sensitive enough to catch what was fixed.
Permanent override — sampling the same pixel before and after:
animation = ...The mask captures are bit-identical across the second fix, so it carries no regression into the first.
Type of change
Documentation needs no update:
docs/ja/api/player.mdalready describes the Permanent behaviour this now implements, and the mask guide never documented the latency.Checklist:
Note for reviewers
Visual sign-off in the editor is still outstanding — the verification above is capture-based.
This supersedes item 3 of #239 (
_mask_local_to_uv_pending). That hold was the right call for the old draw order; with the coverage drawn in the same frame, the correct side flips, which is why its removal is part of this change rather than a separate one.