Skip to content

fix: draw mask coverage in the sampled frame, and keep Permanent part overrides across animation changes - #242

Merged
MasamiYitsuse merged 2 commits into
developfrom
feature/fix-mask-delay-1frame
Jul 27, 2026
Merged

MasamiYitsuse merged 2 commits into
developfrom
feature/fix-mask-delay-1frame

Conversation

@MasamiYitsuse

Copy link
Copy Markdown
Collaborator

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_viewports orders 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:

  • the fresh flag 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 sample
  • with them, the known one-frame gap while zooming across a size-class boundary

Pooling 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_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 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 on animation = ..., contradicting docs/ja/api/player.md, which documents Permanent as persisting for as long as the same .ssab plays.

_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.

The runtime 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 simply had nothing left to keep, because the bind ahead of it had cleared the table.

Re-bind only when the resource itself changed. setSSABResource flags it — covering 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. 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.

Verification

Both builds are green (GDExtension and custom module); no new warnings.

Checked with examples/mask_uv_test under --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

Case Result
D_Translate (20 px/frame) No uncovered hole. Statistics identical to A_Static (5355 / 41468) on every sampled frame — the alignment no longer depends on how fast the mask moves
B_Rotate / C_ScaleXY No uncovered hole; the distortion is gone
Full scale sweep (0.3⇔2.5, crossing several size classes) No dropped frame after removing fresh / _mask_prev
First frame of masking Correct from the first rendered frame

A 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:

Case Before After
No animation change (control) (41,107,217) kept (41,107,217) kept
One animation = ... (0,0,0) lost (41,107,217) kept
Resource re-assigned (0,0,0) dropped, by design; the part keeps drawing

The mask captures are bit-identical across the second fix, so it carries no regression into the first.

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • This change requires a documentation update

Documentation needs no update: docs/ja/api/player.md already describes the Permanent behaviour this now implements, and the mask guide never documented the latency.

Checklist:

  • My code follows the style guidelines of this project
  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • Any dependent changes have been merged and published in downstream modules

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.

YITSUSE, Masami 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.
@MasamiYitsuse
MasamiYitsuse merged commit 87a7cb8 into develop Jul 27, 2026
0 of 2 checks passed
@MasamiYitsuse
MasamiYitsuse deleted the feature/fix-mask-delay-1frame branch July 27, 2026 09:26
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