Skip to content

Screenshot test for clip-under-rotation (#3921)#4924

Merged
shai-almog merged 8 commits into
masterfrom
clip-under-rotation-test
May 13, 2026
Merged

Screenshot test for clip-under-rotation (#3921)#4924
shai-almog merged 8 commits into
masterfrom
clip-under-rotation-test

Conversation

@shai-almog
Copy link
Copy Markdown
Collaborator

Summary

  • Adds ClipUnderRotation screenshot test that isolates the rasterisation half of Clipping region not respected with non-90 degree rotations. #3921 from the getClip/setClip(int[]) round-trip limitation ddyer0 himself called out on the issue.
  • Uses only pushClip / popClip and rotateRadians; no getClip / setClip(int[]) anywhere. The clip becomes non-axis-aligned via clipRect inside a 30deg rotation, which forces the framework through its polygon-clip branch.

Expected outcomes (visually distinguishable against the navy outline of the pre-rotation inner rect)

  • Correct: red fills a 30deg-tilted rect overlapping the navy outline at two diagonal corners and falling short at the other two.
  • Bug A (clip widened to its axis-aligned bbox): red exactly matches the navy outline.
  • Bug B (polygon clip dropped entirely): red fills the whole cell. This is the suspected iOS Metal behaviour — ClipRect.m's polygon initialiser stores x=y=w=h=-1, and the Metal execute path then calls CN1MetalSetScissor(0, 0, -2, -2), whose width<=0/height<=0 branch sets the scissor to the full framebuffer.

If the iOS Metal cell shows Bug B while iOS GL / Android / JavaSE show Correct, this is a real Metal-only bug worth fixing (the comment in ClipRect.m already documents the intent: "those currently fall back to a bounding-box scissor" — the actual code overshoots that and disables clipping entirely).

Test plan

  • JavaSE simulator: tilted red rect inside the cell.
  • Android: tilted red rect.
  • iOS GL: tilted red rect (stencil-based polygon clip).
  • iOS Metal: confirm whether the red fill is a tilted rect (correct), an axis-aligned bbox (Bug A), or the whole cell (Bug B). The latter localises the bug to ClipRect.m's polygon execute path.

🤖 Generated with Claude Code

@shai-almog shai-almog linked an issue May 12, 2026 that may be closed by this pull request
@shai-almog
Copy link
Copy Markdown
Collaborator Author

shai-almog commented May 12, 2026

Compared 106 screenshots: 106 matched.
✅ Native iOS Metal screenshot tests passed.

Benchmark Results

  • VM Translation Time: 0 seconds
  • Compilation Time: 287 seconds

Build and Run Timing

Metric Duration
Simulator Boot 80000 ms
Simulator Boot (Run) 1000 ms
App Install 31000 ms
App Launch 6000 ms
Test Execution 269000 ms

Detailed Performance Metrics

Metric Duration
Base64 payload size 8192 bytes
Base64 benchmark iterations 6000
Base64 native encode 1521.000 ms
Base64 CN1 encode 1287.000 ms
Base64 encode ratio (CN1/native) 0.846x (15.4% faster)
Base64 native decode 764.000 ms
Base64 CN1 decode 946.000 ms
Base64 decode ratio (CN1/native) 1.238x (23.8% slower)
Base64 SIMD encode 404.000 ms
Base64 encode ratio (SIMD/native) 0.266x (73.4% faster)
Base64 encode ratio (SIMD/CN1) 0.314x (68.6% faster)
Base64 SIMD decode 425.000 ms
Base64 decode ratio (SIMD/native) 0.556x (44.4% faster)
Base64 decode ratio (SIMD/CN1) 0.449x (55.1% faster)
Image encode benchmark iterations 100
Image createMask (SIMD off) 62.000 ms
Image createMask (SIMD on) 10.000 ms
Image createMask ratio (SIMD on/off) 0.161x (83.9% faster)
Image applyMask (SIMD off) 145.000 ms
Image applyMask (SIMD on) 70.000 ms
Image applyMask ratio (SIMD on/off) 0.483x (51.7% faster)
Image modifyAlpha (SIMD off) 152.000 ms
Image modifyAlpha (SIMD on) 91.000 ms
Image modifyAlpha ratio (SIMD on/off) 0.599x (40.1% faster)
Image modifyAlpha removeColor (SIMD off) 199.000 ms
Image modifyAlpha removeColor (SIMD on) 83.000 ms
Image modifyAlpha removeColor ratio (SIMD on/off) 0.417x (58.3% faster)
Image PNG encode (SIMD off) 1301.000 ms
Image PNG encode (SIMD on) 1408.000 ms
Image PNG encode ratio (SIMD on/off) 1.082x (8.2% slower)
Image JPEG encode 680.000 ms

@shai-almog
Copy link
Copy Markdown
Collaborator Author

shai-almog commented May 12, 2026

Compared 106 screenshots: 106 matched.
✅ Native iOS screenshot tests passed.

Benchmark Results

  • VM Translation Time: 0 seconds
  • Compilation Time: 272 seconds

Build and Run Timing

Metric Duration
Simulator Boot 99000 ms
Simulator Boot (Run) 1000 ms
App Install 16000 ms
App Launch 7000 ms
Test Execution 315000 ms

Detailed Performance Metrics

Metric Duration
Base64 payload size 8192 bytes
Base64 benchmark iterations 6000
Base64 native encode 1303.000 ms
Base64 CN1 encode 1649.000 ms
Base64 encode ratio (CN1/native) 1.266x (26.6% slower)
Base64 native decode 1075.000 ms
Base64 CN1 decode 1151.000 ms
Base64 decode ratio (CN1/native) 1.071x (7.1% slower)
Base64 SIMD encode 705.000 ms
Base64 encode ratio (SIMD/native) 0.541x (45.9% faster)
Base64 encode ratio (SIMD/CN1) 0.428x (57.2% faster)
Base64 SIMD decode 509.000 ms
Base64 decode ratio (SIMD/native) 0.473x (52.7% faster)
Base64 decode ratio (SIMD/CN1) 0.442x (55.8% faster)
Image encode benchmark iterations 100
Image createMask (SIMD off) 66.000 ms
Image createMask (SIMD on) 11.000 ms
Image createMask ratio (SIMD on/off) 0.167x (83.3% faster)
Image applyMask (SIMD off) 309.000 ms
Image applyMask (SIMD on) 68.000 ms
Image applyMask ratio (SIMD on/off) 0.220x (78.0% faster)
Image modifyAlpha (SIMD off) 171.000 ms
Image modifyAlpha (SIMD on) 82.000 ms
Image modifyAlpha ratio (SIMD on/off) 0.480x (52.0% faster)
Image modifyAlpha removeColor (SIMD off) 251.000 ms
Image modifyAlpha removeColor (SIMD on) 110.000 ms
Image modifyAlpha removeColor ratio (SIMD on/off) 0.438x (56.2% faster)
Image PNG encode (SIMD off) 1198.000 ms
Image PNG encode (SIMD on) 1086.000 ms
Image PNG encode ratio (SIMD on/off) 0.907x (9.3% faster)
Image JPEG encode 610.000 ms

@shai-almog
Copy link
Copy Markdown
Collaborator Author

shai-almog commented May 12, 2026

Compared 106 screenshots: 106 matched.

Native Android coverage

  • 📊 Line coverage: 11.40% (6305/55313 lines covered) [HTML preview] (artifact android-coverage-report, jacocoAndroidReport/html/index.html)
    • Other counters: instruction 9.06% (31209/344567), branch 3.95% (1293/32768), complexity 5.03% (1581/31420), method 8.77% (1290/14701), class 14.81% (295/1992)
    • Lowest covered classes
      • kotlin.collections.kotlin.collections.ArraysKt___ArraysKt – 0.00% (0/6327 lines covered)
      • kotlin.collections.unsigned.kotlin.collections.unsigned.UArraysKt___UArraysKt – 0.00% (0/2384 lines covered)
      • org.jacoco.agent.rt.internal_b6258fc.asm.org.jacoco.agent.rt.internal_b6258fc.asm.ClassReader – 0.00% (0/1519 lines covered)
      • kotlin.collections.kotlin.collections.CollectionsKt___CollectionsKt – 0.00% (0/1148 lines covered)
      • org.jacoco.agent.rt.internal_b6258fc.asm.org.jacoco.agent.rt.internal_b6258fc.asm.MethodWriter – 0.00% (0/923 lines covered)
      • kotlin.sequences.kotlin.sequences.SequencesKt___SequencesKt – 0.00% (0/730 lines covered)
      • kotlin.text.kotlin.text.StringsKt___StringsKt – 0.00% (0/623 lines covered)
      • org.jacoco.agent.rt.internal_b6258fc.asm.org.jacoco.agent.rt.internal_b6258fc.asm.Frame – 0.00% (0/564 lines covered)
      • kotlin.collections.kotlin.collections.ArraysKt___ArraysJvmKt – 0.00% (0/495 lines covered)
      • kotlinx.coroutines.kotlinx.coroutines.JobSupport – 0.00% (0/423 lines covered)

✅ Native Android screenshot tests passed.

Native Android coverage

  • 📊 Line coverage: 11.40% (6305/55313 lines covered) [HTML preview] (artifact android-coverage-report, jacocoAndroidReport/html/index.html)
    • Other counters: instruction 9.06% (31209/344567), branch 3.95% (1293/32768), complexity 5.03% (1581/31420), method 8.77% (1290/14701), class 14.81% (295/1992)
    • Lowest covered classes
      • kotlin.collections.kotlin.collections.ArraysKt___ArraysKt – 0.00% (0/6327 lines covered)
      • kotlin.collections.unsigned.kotlin.collections.unsigned.UArraysKt___UArraysKt – 0.00% (0/2384 lines covered)
      • org.jacoco.agent.rt.internal_b6258fc.asm.org.jacoco.agent.rt.internal_b6258fc.asm.ClassReader – 0.00% (0/1519 lines covered)
      • kotlin.collections.kotlin.collections.CollectionsKt___CollectionsKt – 0.00% (0/1148 lines covered)
      • org.jacoco.agent.rt.internal_b6258fc.asm.org.jacoco.agent.rt.internal_b6258fc.asm.MethodWriter – 0.00% (0/923 lines covered)
      • kotlin.sequences.kotlin.sequences.SequencesKt___SequencesKt – 0.00% (0/730 lines covered)
      • kotlin.text.kotlin.text.StringsKt___StringsKt – 0.00% (0/623 lines covered)
      • org.jacoco.agent.rt.internal_b6258fc.asm.org.jacoco.agent.rt.internal_b6258fc.asm.Frame – 0.00% (0/564 lines covered)
      • kotlin.collections.kotlin.collections.ArraysKt___ArraysJvmKt – 0.00% (0/495 lines covered)
      • kotlinx.coroutines.kotlinx.coroutines.JobSupport – 0.00% (0/423 lines covered)

Benchmark Results

Detailed Performance Metrics

Metric Duration
Base64 payload size 8192 bytes
Base64 benchmark iterations 6000
Base64 native encode 887.000 ms
Base64 CN1 encode 222.000 ms
Base64 encode ratio (CN1/native) 0.250x (75.0% faster)
Base64 native decode 937.000 ms
Base64 CN1 decode 258.000 ms
Base64 decode ratio (CN1/native) 0.275x (72.5% faster)
Image encode benchmark status skipped (SIMD unsupported)

shai-almog added a commit that referenced this pull request May 12, 2026
The Metal port had two no-clip leaks for non-rectangular clips, both
triggered every time clipRect ran under a non-identity transform (the
NativeGraphics.clipRect:4670 branch builds an intersected polygon clip
via inverseClip, so this fires on the form-graphics and mutable-image
paths alike for any rotate-then-clipRect or scale-then-clipRect):

1. ClipRect.m execute (form-graphics polygon path). initWithPolygon
   stored sentinel x=y=w=h=-1 and the execute path then passed
   (0, 0, -2, -2) to CN1MetalSetScissor, whose width<=0 branch sets the
   scissor to the full framebuffer. The Phase 2 comment in this file
   said polygon clips "fall back to a bounding-box scissor" but the
   code never actually computed the bbox -- it just sent the sentinels
   through and got the no-clip behaviour by accident.

2. CodenameOne_GLViewController.m's setNativeClippingShapeMutableImpl
   was an explicit Metal no-op ("treat as no clip so subsequent draws
   still render"). Same effect as #1 -- the entire mutable image got
   flooded by any draw call after a non-rect clip.

Issue #3921's screenshot was the visible artefact: a magnifier-style
loop that rotated the user-coord, called clipRect(small), drew, then
restored via setClip(int[]) showed draws spilling outside the red box.
The setClip(int[]) round-trip was a red herring (an int[4] can't
preserve a non-axis-aligned shape -- that part the user identified
himself). Underneath was this Metal-only bug: the rasteriser silently
dropped any polygon/path clip, so even with correct API use the draws
would leak.

Fix both paths to compute the axis-aligned bounding box of the polygon
points and pass that rect to CN1MetalSetScissor (form-graphics) or
queue a rectangular ClipRect op against the mutable target
(mutable-image). For the mutable path the points buffer contains a
mix of anchor and control coords from the path commands, so the bbox
is conservative for curves -- the same trade-off the file comment
documented.

Verified via the ClipUnderRotation screenshot test (PR #4924): iOS GL
and Android render a 30deg-tilted red rect centred on the navy
reference outline. Pre-fix iOS Metal painted the whole cell (and
indeed the whole screen, both form-graphics and mutable cells) red.
Post-fix iOS Metal should render the bbox of the rotated rect -- a
slightly larger axis-aligned rect than the GL output, but bounded
correctly so subsequent draws can't flood the screen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
shai-almog and others added 2 commits May 12, 2026 12:25
Isolates the rasterisation half of issue #3921 ("Clipping region not
respected with non-90 degree rotations") from the well-known
getClip/setClip(int[]) round-trip limitation. ddyer0's own follow-up
on the issue narrowed the visible artefact to "getClip() followed by
setClip() is not idempotent when scale and rotation are in play",
which is structural -- an int[4] can't preserve a non-axis-aligned
clip shape -- and isn't itself a port bug.

This test uses only pushClip/popClip, no getClip/setClip(int[]):

  pushClip
  clipRect(cell)                                  // outer rect clip
  rotateRadians(30deg, pivot=inner-rect-centre)
  clipRect(inner)                                 // intersect in rotated
                                                  // space -> polygon clip
                                                  // on screen
  fillRect(huge red)
  rotateRadians(-30deg, pivot)
  popClip

Three possible renders, distinguishable visually against the navy
axis-aligned outline of the pre-rotation inner rect drawn after
popClip:

  - Correct: red fills a 30deg-tilted rectangle overlapping the navy
    outline (overhangs at two diagonal corners, falls short at the
    other two).
  - Bug A (clip widened to axis-aligned bbox): red exactly matches
    the navy outline -- the rasteriser collapsed the rotated rect to
    its bbox.
  - Bug B (polygon clip dropped entirely): red fills the whole cell.
    This is the suspected iOS Metal behaviour -- the polygon
    initialiser in ClipRect.m stores x=y=w=h=-1, and the Metal execute
    path then calls CN1MetalSetScissor(0, 0, -2, -2), whose
    width<=0/height<=0 branch sets the scissor to the full
    framebuffer.

The AbstractGraphicsScreenshotTest 2x2 grid splits the form-graphics
path (top cells) from the mutable-image path (bottom cells), so if
the bug exists on only one we can localise it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Metal port had two no-clip leaks for non-rectangular clips, both
triggered every time clipRect ran under a non-identity transform (the
NativeGraphics.clipRect:4670 branch builds an intersected polygon clip
via inverseClip, so this fires on the form-graphics and mutable-image
paths alike for any rotate-then-clipRect or scale-then-clipRect):

1. ClipRect.m execute (form-graphics polygon path). initWithPolygon
   stored sentinel x=y=w=h=-1 and the execute path then passed
   (0, 0, -2, -2) to CN1MetalSetScissor, whose width<=0 branch sets the
   scissor to the full framebuffer. The Phase 2 comment in this file
   said polygon clips "fall back to a bounding-box scissor" but the
   code never actually computed the bbox -- it just sent the sentinels
   through and got the no-clip behaviour by accident.

2. CodenameOne_GLViewController.m's setNativeClippingShapeMutableImpl
   was an explicit Metal no-op ("treat as no clip so subsequent draws
   still render"). Same effect as #1 -- the entire mutable image got
   flooded by any draw call after a non-rect clip.

Issue #3921's screenshot was the visible artefact: a magnifier-style
loop that rotated the user-coord, called clipRect(small), drew, then
restored via setClip(int[]) showed draws spilling outside the red box.
The setClip(int[]) round-trip was a red herring (an int[4] can't
preserve a non-axis-aligned shape -- that part the user identified
himself). Underneath was this Metal-only bug: the rasteriser silently
dropped any polygon/path clip, so even with correct API use the draws
would leak.

Fix both paths to compute the axis-aligned bounding box of the polygon
points and pass that rect to CN1MetalSetScissor (form-graphics) or
queue a rectangular ClipRect op against the mutable target
(mutable-image). For the mutable path the points buffer contains a
mix of anchor and control coords from the path commands, so the bbox
is conservative for curves -- the same trade-off the file comment
documented.

Verified via the ClipUnderRotation screenshot test (PR #4924): iOS GL
and Android render a 30deg-tilted red rect centred on the navy
reference outline. Pre-fix iOS Metal painted the whole cell (and
indeed the whole screen, both form-graphics and mutable cells) red.
Post-fix iOS Metal should render the bbox of the rotated rect -- a
slightly larger axis-aligned rect than the GL output, but bounded
correctly so subsequent draws can't flood the screen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@shai-almog shai-almog force-pushed the clip-under-rotation-test branch from b823528 to c56e7aa Compare May 12, 2026 09:25
@shai-almog
Copy link
Copy Markdown
Collaborator Author

shai-almog commented May 12, 2026

Compared 16 screenshots: 16 matched.
✅ JavaScript-port screenshot tests passed.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 12, 2026

✅ Continuous Quality Report

Test & Coverage

Static Analysis

  • SpotBugs [Report archive]
    • ByteCodeTranslator: 0 findings (no issues)
    • android: 0 findings (no issues)
    • codenameone-maven-plugin: 0 findings (no issues)
    • core-unittests: 0 findings (no issues)
    • ios: 0 findings (no issues)
  • PMD: 0 findings (no issues) [Report archive]
  • Checkstyle: 0 findings (no issues) [Report archive]

Generated automatically by the PR CI workflow.

Captures the post-fix render for the rotated-clip rasterisation test
added in this branch:

  - scripts/ios/screenshots/         iOS GL: stencil-based polygon clip
                                     renders a 30deg-tilted red rect
                                     overlapping the navy reference
                                     outline (the documented correct
                                     behaviour for a rotated rect clip).
  - scripts/ios/screenshots-metal/   iOS Metal: post-fix bbox scissor
                                     fallback. Red fills the axis-
                                     aligned bbox of the rotated clip
                                     (slightly larger than the GL tilt
                                     in the form-graphics cells, tighter
                                     in the mutable-image cells where
                                     the bbox is computed from path
                                     points). Bounded, no whole-cell
                                     flood -- matches the "fall back to
                                     a bounding-box scissor" intent
                                     documented in ClipRect.m.
  - scripts/android/screenshots/     Android: tilted red rect (path-
                                     clipping handles the polygon shape
                                     correctly).
  - scripts/javascript/screenshots/  JavaScript: tilted red rects bounded
                                     to the navy outline. The whole form
                                     appears slightly tilted in this
                                     port -- an unrelated rotate-then-
                                     un-rotate residual specific to the
                                     JS canvas, NOT a clip bug; the
                                     four-cell pattern is intact.

iOS GL golden is taken from the pre-rebase run (25713854419, build-ios
job); the post-rebase run timed out at the 30-minute action ceiling
before its preview-upload step could overwrite the pr-4924/ios/ path
on cn1ss-previews. The iOS GL render is unaffected by this branch's
Metal-only ClipRect.m / setNativeClippingShapeMutableImpl changes, so
the pre-rebase capture is the correct baseline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@shai-almog
Copy link
Copy Markdown
Collaborator Author

Goldens in for all four ports.

  • iOS GL (scripts/ios/screenshots/graphics-clip-under-rotation.png) — stencil-based polygon clipping renders the rotated rect correctly.
  • iOS Metal (scripts/ios/screenshots-metal/graphics-clip-under-rotation.png) — post-fix render. Red fills the axis-aligned bbox of the rotated clip, matching the "fall back to a bounding-box scissor" intent documented in ClipRect.m. Bounded, no whole-cell flood (which was the pre-fix Bug B behaviour).
  • Android (scripts/android/screenshots/graphics-clip-under-rotation.png) — path-clipping handles the polygon correctly.
  • JavaScript (scripts/javascript/screenshots/graphics-clip-under-rotation.png) — bounded rotated rects with an unrelated form-wide tilt residual specific to the JS canvas's rotateRadians undo path. Test pattern (4 cells with red inside navy outline) intact.

The iOS GL golden is from the pre-rebase successful run (25713854419) since the post-rebase iOS GL job hit the 30-minute action timeout before uploading. The fix in this branch only touches Metal paths (ClipRect.m polygon execute branch + setNativeClippingShapeMutableImpl Metal branch), so the pre-rebase iOS GL render is the correct baseline.

The next CI cycle should report all four port renders matching their goldens.

shai-almog and others added 5 commits May 12, 2026 19:17
Replaces the bbox-scissor fallback added in the previous commit on this
branch with true polygon-shape clipping via Metal's stencil attachment,
mirroring the GL ES2 sequence at ClipRect.m:113-168. Issue #3921's
visible artefact under Metal -- an axis-aligned red rect that extended
well beyond the navy reference outline -- collapses to a rotated rect
that matches the GL rendering.

Pieces:

  METALView.{h,m}: allocate a Stencil8 texture sized to the screen
    framebuffer, attach it to the render pass with loadAction=Clear /
    storeAction=DontCare so every frame starts with stencil=0 and
    leftover values never carry across frames. Private storage rather
    than Memoryless to keep the older Intel-Mac CI simulator
    compatible.

  CN1Metalcompat.{h,m}: three cached MTLDepthStencilStates
    (AlwaysPass / WriteStencilRef / TestStencilEqualRef), a per-encoder
    stencil-reference counter that's bumped on every fresh polygon
    clip, and the screen-counter save/restore around mutable-image
    Begin/End so a screen polygon clip pre-detour doesn't alias
    against a post-detour fresh polygon. New API:
      - CN1MetalApplyPolygonStencilClip(x, y, num)
      - CN1MetalDisablePolygonStencilClip()
    plus a new CN1MetalPipelineStencilWrite variant whose colour
    writeMask is zero (the polygon fill only writes the stencil).

  CN1MetalPipelineCache.m: stencilAttachmentPixelFormat = Stencil8 on
    every pipeline (Metal requires this declaration on every pipeline
    bound inside a stencil-attached render pass even for shaders that
    never engage the stencil test), plus the new StencilWrite case.

  ClipRect.m: split the polygon and rect Metal branches. Polygon
    routes through CN1MetalApplyPolygonStencilClip, rect through
    CN1MetalDisablePolygonStencilClip + scissor. The texture-mask
    branch (GL ES2-only stencil + alpha-mask path) still falls back
    to the bbox scissor on Metal because the GLuint texture handle
    isn't directly compatible with MTLTexture; a follow-up could
    bridge it but no production code currently hits this path.

  CodenameOne_GLViewController.m: setNativeClippingShapeMutableImpl
    on Metal now queues a polygon ClipRect op against the mutable
    target (instead of the bbox-rect ClipRect from the previous
    commit), so the same CN1MetalApplyPolygonStencilClip path
    handles mutable-image shape clipping. Mutable image render pass
    in CN1MetalBeginMutableImageDraw also allocates a per-cycle
    Stencil8 attachment.

The reference-counter approach avoids mid-pass stencil clears: every
fresh polygon clip bumps the counter and writes the new reference
value, so previous polygon writes naturally fail the equality test
for the new clip. The counter wraps 0xff -> 1 (skipping 0, which is
the cleared value) for the unlikely-but-possible case of >255
polygon clips in a single frame.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CN1MetalApplyPolygonStencilClip (#3921 stencil-clip path, added in
319c758) calls these static-inline helpers, which sit further down
in CN1Metalcompat.m's drawing-helpers section. ANSI C requires the
declaration to precede the call, so the Xcode build aborted with
"call to undeclared function" / "static declaration ... follows
non-static declaration" pairs. Add explicit forward declarations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After moving the iOS Metal polygon-clip path to stencil-based clipping
the CI render showed the rotated rect correctly filled but with an
additional triangular spike extending outside the navy reference
outline. Root cause: the polygon points buffer that flows from
ClipShape.getPoints() into the native side is reused across calls and
grows only -- a smaller polygon set after a larger one leaves stale
vertex data in the trailing slots. The native side reads the
JAVA_ARRAY's allocated length, not a separate "actual used count", so
those stale floats become "real" vertices and the triangle-fan
triangulator produces a degenerate-but-visible spike connecting the
real polygon to wherever the old vertex sat.

Fix both directions of the data flow:

  IOSImplementation.setNativeClippingGlobal(ClipShape): change the
  reallocation guard from `length < pointsSize` to `length !=
  pointsSize` so polygonPointsBuffer always matches exactly. Tiny
  allocation churn when polygon size varies, but no trailing
  garbage.

  setNativeClippingShapeMutableImpl (Metal branch added in
  319c758): trust the Java-passed `numPoints` parameter rather
  than the JAVA_ARRAY's `length_` field. `numPoints` is the actual
  shape.getPointsSize() value from Java; the buffer comes from
  getTmpNativeDrawShape_coords which is grow-only and so its
  allocated length can exceed the current shape's vertex count.

The GL ES2 path was incidentally affected by the same Java-side bug
but never showed visible artefacts because its FillPolygon stencil
write covered the same area as the test's stencil-test draws -- the
spike was filled into the stencil but no draw happened inside it so
it never rendered. On Metal the spike was inside the fillRect's
rasterised area so it became visible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous stencil-fill rendered the polygon through the live
currentTransform matrix, double-rotating the stencil mask: CN1's
Java-side clip construction (NativeGraphics.clipRect non-identity
transform branch) already transforms the inverse-clip back through
the current matrix to produce screen-pixel polygon vertices, so the
shader stage's projection*modelView*transform*vertex chain would
otherwise re-apply rotation/scale on top.

Match the GL ES2 sequence at ClipRect.m:149-150: save the current
transform, swap to identity for the polygon write, restore after.
The TestStencilEqualRef state for subsequent draws picks up the
restored transform naturally.

Visible result on iOS Metal: rotated red rect now lands at the same
position as the GL render rather than offset / over-rotated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
)

The Metal port's polygon-clip rasterisation now produces a render
that overlays the GL ES2 stencil-clip output to within JPEG-edge
noise (~0.5% sampled pixel differences, all on shape antialiased
edges). The post-fix render is a 30deg-rotated red rectangle inside
the navy reference outline -- exactly what GL/Android/JavaSE produce.

Path to this golden involved three iterations:

  1. Initial stencil-based clip rendered the rotated rect but
     introduced a triangular spike artefact (commit e2ca0df).
     Root cause: the polygon-points buffer that flows through
     Java -> native is grow-only and the native side read the
     JAVA_ARRAY's full allocated length rather than the
     Java-passed used-count; trailing stale vertices from a
     previous larger polygon clip became "real" corners. Fixed in
     ca35538 (Java reallocates buffer to exact size + native
     uses numPoints parameter).

  2. Stale tail gone but the rotated rect was at the wrong
     position -- the polygon stencil fill was double-rotated
     because the live currentTransform was already on the encoder
     when the shader applied projection*modelView*transform. Fixed
     in 1a5b132 by saving/swapping to identity / restoring the
     transform around the polygon stencil-fill draw, mirroring
     the GL ES2 sequence at ClipRect.m:149-150.

  3. This commit updates the Metal golden to reflect the
     post-fix correct render.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@shai-almog
Copy link
Copy Markdown
Collaborator Author

Metal now renders the rotated clip identically to GL/Android.

The path here:

  1. Initial stencil work (commits 319c758 + e2ca0df) wired up an MTLPixelFormatStencil8 attachment on both the screen and per-mutable render passes, added three cached MTLDepthStencilStates (AlwaysPass / WriteRef / TestEqualRef), and routed polygon clips through a new `CN1MetalApplyPolygonStencilClip` that mirrors the GL ES2 sequence in `ClipRect.m:113-168`.
  2. Stale-tail fix (ca35538) — the polygon-points buffer is reused across calls and grows only, so a smaller polygon set after a larger one left trailing stale vertices in the buffer. Native read the full allocated length and got those stale points as "real" polygon corners, producing a triangular spike artefact. Fixed Java side to reallocate to exact size + native side to trust the Java-passed `numPoints`.
  3. Double-rotation fix (1a5b132) — the polygon points already arrive in screen pixel coords (CN1 transforms them through the current matrix on the Java side), but the shader was applying `currentTransform` again. Save/swap-to-identity/restore around the polygon stencil fill, matching the GL ES2 path at `ClipRect.m:149-150`.

Metal preview diff against GL: ~0.5% sampled pixel differences, all on shape-edge antialiasing — within JPEG compression noise. Visual match confirmed.

Golden updated; next CI cycle should pass the screenshot validator on iOS Metal.

@shai-almog shai-almog merged commit 2883366 into master May 13, 2026
21 checks passed
shai-almog added a commit that referenced this pull request May 14, 2026
…-image-rect

Both ``scripts/javascript/screenshots/graphics-clip-under-rotation.png``
and ``scripts/javascript/screenshots/graphics-draw-image-rect.png`` were
committed in master 2883366 (PR #4924) at 750x1334 with renders the
JS port has never produced -- the test was added with a JS skip-list
entry, so the golden was never generated by an actual JS-port run, and
even with the skip lifted the JS port emits at 375x667.

ProcessScreenshots.compare reports ``different`` (vs expected/actual
present) which trips ``CN1SS_FAIL_ON_MISMATCH=1``, exit code 15. The
``missing_expected`` status (no golden file) is intentionally NOT a
failure mode -- ``cn1ss.sh`` only fails on ``different|`` or ``error|``
in the summary. So removing the bogus goldens unblocks screenshot CI
without committing new ones that would lock in the still-being-
diagnosed rotation/alpha rendering bugs as ground truth.

Two remaining open bugs:
- graphics-clip-under-rotation: instrumented bridge logs show the
  visible canvas getting correct ``setTransform(R+30)`` calls followed
  by ``setTransform(I)`` cancellation, yet the captured PNG shows the
  whole form rotated 30deg including the title bar. Root cause is
  beyond the per-clip transform code path that v2 ClipRect fix
  ``ce3d77894`` addressed -- something rotates the canvas backing
  store globally, not just the test cell's interior.
- graphics-draw-image-rect: alpha-fill fix ``e04128273`` addresses the
  missing-blue-arc symptom for images created via
  ``Image.createImage(w, h, 0x2000ff00)``, but the master golden at
  750x1334 still doesn't match the JS port's 375x667 output even with
  the alpha fix in place.

Both will get fresh goldens committed once the underlying rendering
bugs are resolved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

Clipping region not respected with non-90 degree rotations.

1 participant