Skip to content

metal: flip + clamp scissor against the active render pass extent#643

Open
benface wants to merge 1 commit into
not-fl3:masterfrom
benface:metal-scissor-rect-height
Open

metal: flip + clamp scissor against the active render pass extent#643
benface wants to merge 1 commit into
not-fl3:masterfrom
benface:metal-scissor-rect-height

Conversation

@benface

@benface benface commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

apply_scissor_rect was using crate::window::screen_size() to derive the Y-flip pivot, which only matches the active render pass's height for the default drawable. Two separate problems fall out of that:

1. Off-screen targets at high DPI

When the active pass is an off-screen render target with different pixel dimensions — typical at high DPI, where logical render-target sizes are smaller than the physical drawable — the pivot is wrong and the resulting MTLScissorRect lands entirely outside the target. Every fragment then gets scissor-clipped, silently dropping all draws to the off-screen target.

Surfaced by trying to render a high-DPI MSAA off-screen target on macOS; uniformly nothing reached the resolve texture.

2. Mid-rotation race on iOS

During an iPad / iPhone rotation animation, the caller's cached view of screen_size (updated via Resize events) can lead MTKView's actual currentDrawable by a frame. On the in-flight frame the caller asks for a scissor sized against the new dimensions while the encoder is still bound to the previous-size drawable. Metal validation fires:

-[MTLDebugRenderCommandEncoder setScissorRect:]: failed assertion
`Set Scissor Rect Validation
(rect.y(137) + rect.height(2491))(2628) must be <= render pass height(2622)'

Reproduced on iPhone 16 Pro by rotating mid-frame on a debug-validated build; release silently clips and you don't notice unless you launch from Xcode.

Fix

  • begin_pass now reads the active pass's width + height straight from the descriptor's color-attachment texture (not screen_size() — the cache that's wrong during the rotation race). Stored in current_pass_width / current_pass_height.
  • apply_scissor_rect flips Y against current_pass_height (this is the off-screen fix), then clamps the resulting rect into [0, pass_size] on both axes (the rotation-race defensive band-aid).

The default-drawable case with a correctly-cached screen_size is functionally unchanged. The off-screen high-DPI case gets the right pivot and draws land in the target. The mid-rotation case clips at most one frame of over-large scissor to the actual pass extent instead of asserting.

The mid-rotation race itself is upstream of the renderer (MTKView.drawableSize updates before currentDrawable.texture is recreated, and apps caching screen size from Resize notifications observe the new size one frame ahead). Fixing the race at the source would require either deferring the Resize notification until the drawable actually resizes, or having apps read the drawable size at draw time. Both are larger surface-area changes than the clamp here, which gets the validation green at the cost of one frame of scissor truncation during the transition.

@benface benface force-pushed the metal-scissor-rect-height branch 2 times, most recently from 5c75f29 to 4ade8f2 Compare June 13, 2026 23:37
`apply_scissor_rect` was using `crate::window::screen_size()` to
derive the Y-flip pivot, which only matches the active render
pass's height for the default drawable. Two separate problems:

1. **Off-screen targets at high DPI.** When the active pass is an
   off-screen render target with different pixel dimensions —
   typical at high DPI, where logical render-target sizes are
   smaller than the physical drawable — the pivot is wrong and
   the resulting `MTLScissorRect` lands entirely outside the
   target. Every fragment then gets scissor-clipped, silently
   dropping all draws to the off-screen target. Surfaced by
   trying to render a high-DPI MSAA off-screen target on macOS;
   uniformly nothing reached the resolve texture.

2. **Mid-rotation race on iOS.** During an iPad / iPhone rotation
   animation, the caller's view of `screen_size` (cached via
   `Resize` events) can lead MTKView's actual `currentDrawable`
   by a frame, and on the in-flight frame the caller asks for a
   scissor sized against the new dimensions while the encoder is
   still bound to the previous-size drawable. Metal validation
   then fires:

       -[MTLDebugRenderCommandEncoder setScissorRect:]: failed
       assertion `Set Scissor Rect Validation
       (rect.y(137) + rect.height(2491))(2628) must be <=
       render pass height(2622)'

   Reproduced on iPhone 16 Pro by rotating mid-frame; only the
   debug-validated build asserts, release silently clips.

Track the active pass's pixel width + height in `begin_pass`,
read straight from the descriptor's color attachment texture
(not `screen_size()` — which is the cache that's wrong in case
2). Then in `apply_scissor_rect`, clamp the resulting rect into
`[0, pass_size]` on both axes. The default-drawable case for a
correctly-cached size is unchanged; the off-screen high-DPI case
gets the right pivot; the mid-rotation case clips an at-most one
frame of over-large scissor to the actual pass extent instead
of asserting.
@benface benface force-pushed the metal-scissor-rect-height branch from 4ade8f2 to 30d4724 Compare June 13, 2026 23:58
@benface benface changed the title metal: flip scissor against active render pass height, not the screen's metal: flip + clamp scissor against the active render pass extent Jun 13, 2026
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