diff --git a/Ports/iOSPort/nativeSources/CN1MetalPipelineCache.m b/Ports/iOSPort/nativeSources/CN1MetalPipelineCache.m index a88ebbae5d..a1ab140796 100644 --- a/Ports/iOSPort/nativeSources/CN1MetalPipelineCache.m +++ b/Ports/iOSPort/nativeSources/CN1MetalPipelineCache.m @@ -44,6 +44,17 @@ static void configureBlendDisabled(MTLRenderPipelineColorAttachmentDescriptor *a a.blendingEnabled = NO; } +// Stencil-only attachment: color writes disabled at the pipeline level so +// the polygon fill leaves the colour buffer untouched and only updates the +// stencil. Pairs with CN1MetalPipelineStencilWrite + the "WriteStencilRef" +// depth-stencil state CN1Metalcompat installs around the polygon clip +// draw. +static void configureStencilWriteOnly(MTLRenderPipelineColorAttachmentDescriptor *a) { + a.pixelFormat = MTLPixelFormatBGRA8Unorm; + a.blendingEnabled = NO; + a.writeMask = MTLColorWriteMaskNone; +} + - (id)buildPipeline:(CN1MetalPipeline)pipeline library:(id)library { MTLRenderPipelineDescriptor *desc = [[MTLRenderPipelineDescriptor alloc] init]; @@ -89,9 +100,26 @@ static void configureBlendDisabled(MTLRenderPipelineColorAttachmentDescriptor *a desc.fragmentFunction = [library newFunctionWithName:@"cn1_fs_radial_gradient"]; configureBlendPremultiplied(desc.colorAttachments[0]); break; + case CN1MetalPipelineStencilWrite: + // Polygon-shape clip (#3921). Uses the standard solid-color + // shader pair (cn1_vs_solid / cn1_fs_solid) but with color + // writes disabled at the pipeline level -- the fragment + // output is discarded and only the stencil attachment is + // updated, governed by the WriteStencilRef depth-stencil + // state CN1Metalcompat binds around the polygon-fill draw. + desc.vertexFunction = [library newFunctionWithName:@"cn1_vs_solid"]; + desc.fragmentFunction = [library newFunctionWithName:@"cn1_fs_solid"]; + configureStencilWriteOnly(desc.colorAttachments[0]); + break; default: return nil; } + // Stencil8 format declaration required on every pipeline that runs + // inside the screen / mutable render passes (both attach a Stencil8 + // texture now to support polygon-shape clipping in #3921). Without + // this Metal aborts the draw call with a pixel-format mismatch even + // for shaders that never engage the stencil test. + desc.stencilAttachmentPixelFormat = MTLPixelFormatStencil8; if (desc.vertexFunction == nil || desc.fragmentFunction == nil) { NSLog(@"CN1MetalPipelineCache: shader function missing for pipeline %ld", (long)pipeline); return nil; diff --git a/Ports/iOSPort/nativeSources/CN1Metalcompat.h b/Ports/iOSPort/nativeSources/CN1Metalcompat.h index ca15145154..5518156f0c 100644 --- a/Ports/iOSPort/nativeSources/CN1Metalcompat.h +++ b/Ports/iOSPort/nativeSources/CN1Metalcompat.h @@ -58,6 +58,11 @@ typedef NS_ENUM(NSInteger, CN1MetalPipeline) { CN1MetalPipelineLinearGradient, // FillLinearGradient (pure GPU, no CG bitmap) CN1MetalPipelineRadialGradient, // FillRadialGradient (pure GPU, no CG bitmap) CN1MetalPipelineAlphaMaskRadial, // DrawTextureAlphaMask + RadialGradientPaint + CN1MetalPipelineStencilWrite, // Polygon-shape stencil fill (#3921): + // color writes off, used only to mark + // pixels inside a polygon clip shape so + // subsequent draws can stencil-test + // against the reference value. CN1MetalPipelineCount }; @@ -115,6 +120,31 @@ void CN1MetalLoadIdentity(void); // our projection). Passing width<=0 or height<=0 disables clipping. void CN1MetalSetScissor(int x, int y, int width, int height); +// Polygon-shape clipping via the stencil attachment (#3921). Mirrors the +// GL ES2 stencil sequence in ClipRect.m:113-168: +// +// 1. Increment the per-encoder stencil reference value (the counter +// avoids the need to clear the stencil mid-frame -- every fresh +// polygon clip uses a new reference that previous writes can't +// match). +// 2. Bind the WriteStencilRef depth-stencil state + StencilWrite +// pipeline (color writes disabled), render the polygon as a +// triangle fan from vertex 0 (matches CN1MetalFillPolygon's +// assumption that the polygon is convex). +// 3. Bind the TestStencilEqualRef depth-stencil state so subsequent +// draws are clipped to pixels where stencil == reference. +// +// Polygon points are in framebuffer pixel space, same coord system as +// CN1MetalSetScissor. Pass num = number of (x, y) pairs. +void CN1MetalApplyPolygonStencilClip(const float *xCoords, const float *yCoords, int num); + +// Disable polygon stencil clipping for subsequent draws. Used when a +// rectangular clip is set (which reverts to scissor) or when clipping +// is removed altogether. The stencil texture itself isn't touched -- +// only the depth-stencil state goes back to "always pass, no writes" +// so the previously-written stencil bits are simply ignored. +void CN1MetalDisablePolygonStencilClip(void); + // -------- Draw primitives (invoked from ExecutableOp subclasses' execute methods) -------- // Fill a rectangle with a solid color + alpha (0-255 each). x/y/w/h in diff --git a/Ports/iOSPort/nativeSources/CN1Metalcompat.m b/Ports/iOSPort/nativeSources/CN1Metalcompat.m index 7e79569fa9..2442fbb52c 100644 --- a/Ports/iOSPort/nativeSources/CN1Metalcompat.m +++ b/Ports/iOSPort/nativeSources/CN1Metalcompat.m @@ -64,9 +64,36 @@ static CN1MetalMatrices lastBoundMatrices; static BOOL lastBoundMatricesValid = NO; +// Polygon-shape clip state (#3921). The stencil reference counter is +// per-encoder: every fresh polygon clip increments it and writes the new +// value into the stencil texture, so the test for == reference naturally +// fails against any previously-written area. Keep three depth-stencil +// states, lazily built on first use: +// AlwaysPass — default for non-stencil draws (or after Disable) +// WriteStencilRef — polygon fill that paints stencil = reference +// TestStencilEqualRef — subsequent draws clipped to stencil == ref +// We deliberately don't cache "is stencil clip active" because the +// encoder-state cache is reset across mutable-image Begin/End cycles +// (screen encoder state survives that round-trip but our cache doesn't). +// Both Apply and Disable unconditionally re-bind so the encoder state +// always matches the intent, at the cost of a redundant Metal API call +// in the no-op case. +static __strong id depthStencilStateAlwaysPass = nil; +static __strong id depthStencilStateWriteRef = nil; +static __strong id depthStencilStateTestEqualRef = nil; +static uint32_t currentStencilReference = 0; + static inline void invalidateEncoderStateCache(void) { lastBoundPipelineState = nil; lastBoundMatricesValid = NO; + // A new encoder starts at stencil reference 0; ApplyPolygonStencilClip + // bumps to 1 on first use. (The reference counter is encoder-scoped: + // for the mutable round-trip case, the cached counter is reset when + // the encoder cache is invalidated, but the *screen* encoder retains + // its actual stencil values across the mutable detour. That's fine + // because every fresh Apply call bumps the counter and writes the + // new reference, so collisions are vanishingly unlikely.) + currentStencilReference = 0; } #define CN1_MATRIX_STACK_DEPTH 32 @@ -230,6 +257,174 @@ void CN1MetalSetScissor(int x, int y, int width, int height) { }]; } +// --------------- Polygon stencil clip (#3921) --------------- +// +// Forward declarations for the encoder-state cache helpers defined below +// (drawing-helpers section). The polygon stencil clip needs them too, +// and ANSI C requires the declaration to precede the call. +static inline void bindPipelineStateIfChanged(id state); +static inline void uploadMatricesIfChanged(NSUInteger atIndex); + +static id buildAlwaysPassDepthStencilState(void) { + MTLDepthStencilDescriptor *desc = [[MTLDepthStencilDescriptor alloc] init]; + desc.depthCompareFunction = MTLCompareFunctionAlways; + desc.depthWriteEnabled = NO; + // Front + back stencil descriptors default to "always pass, keep on + // every outcome" which is exactly what we want for non-stencil + // draws -- the attachment exists but no draw engages it. + id state = [CN1MetalDevice() newDepthStencilStateWithDescriptor:desc]; +#ifndef CN1_USE_ARC + [desc release]; +#endif + return state; +} + +static id buildWriteStencilRefDepthStencilState(void) { + MTLDepthStencilDescriptor *desc = [[MTLDepthStencilDescriptor alloc] init]; + desc.depthCompareFunction = MTLCompareFunctionAlways; + desc.depthWriteEnabled = NO; + MTLStencilDescriptor *s = [[MTLStencilDescriptor alloc] init]; + s.stencilCompareFunction = MTLCompareFunctionAlways; + s.stencilFailureOperation = MTLStencilOperationKeep; + s.depthFailureOperation = MTLStencilOperationKeep; + s.depthStencilPassOperation = MTLStencilOperationReplace; // write reference + s.readMask = 0xff; + s.writeMask = 0xff; + desc.frontFaceStencil = s; + desc.backFaceStencil = s; + id state = [CN1MetalDevice() newDepthStencilStateWithDescriptor:desc]; +#ifndef CN1_USE_ARC + [s release]; + [desc release]; +#endif + return state; +} + +static id buildTestStencilEqualRefDepthStencilState(void) { + MTLDepthStencilDescriptor *desc = [[MTLDepthStencilDescriptor alloc] init]; + desc.depthCompareFunction = MTLCompareFunctionAlways; + desc.depthWriteEnabled = NO; + MTLStencilDescriptor *s = [[MTLStencilDescriptor alloc] init]; + s.stencilCompareFunction = MTLCompareFunctionEqual; + s.stencilFailureOperation = MTLStencilOperationKeep; + s.depthFailureOperation = MTLStencilOperationKeep; + s.depthStencilPassOperation = MTLStencilOperationKeep; + s.readMask = 0xff; + s.writeMask = 0x00; // never write while testing + desc.frontFaceStencil = s; + desc.backFaceStencil = s; + id state = [CN1MetalDevice() newDepthStencilStateWithDescriptor:desc]; +#ifndef CN1_USE_ARC + [s release]; + [desc release]; +#endif + return state; +} + +static void ensureDepthStencilStates(void) { + if (depthStencilStateAlwaysPass == nil) { + depthStencilStateAlwaysPass = buildAlwaysPassDepthStencilState(); + } + if (depthStencilStateWriteRef == nil) { + depthStencilStateWriteRef = buildWriteStencilRefDepthStencilState(); + } + if (depthStencilStateTestEqualRef == nil) { + depthStencilStateTestEqualRef = buildTestStencilEqualRefDepthStencilState(); + } +} + +void CN1MetalApplyPolygonStencilClip(const float *xCoords, const float *yCoords, int num) { + if (activeEncoder == nil || pipelineCache == nil) return; + if (num < 3 || xCoords == NULL || yCoords == NULL) { + // Degenerate polygon: nothing inside it can pass -- emulate by + // shrinking the scissor to a zero-size rect (matches the + // "everything is clipped out" intent). + CN1MetalSetScissor(0, 0, 0, 0); + ensureDepthStencilStates(); + [activeEncoder setDepthStencilState:depthStencilStateAlwaysPass]; + return; + } + ensureDepthStencilStates(); + + // Bump the reference value (wrap at 255 -> 1 to avoid colliding with + // the cleared-zero state). Each polygon clip gets a fresh ref so + // earlier writes can't satisfy the test for the new clip. + currentStencilReference++; + if (currentStencilReference > 0xff) { + currentStencilReference = 1; + } + + // Open the scissor so the polygon fill isn't truncated by any prior + // rectangular scissor. The stencil mask will produce the actual + // shape; later draws may re-narrow with a scissor if the framework + // also called clipRect with a rect. + CN1MetalSetScissor(0, 0, currentFramebufferWidth, currentFramebufferHeight); + + // Build the triangle-fan vertex list for the polygon: (0, i, i+1) + // for i in [1 .. num-1). Matches CN1MetalFillPolygon's convex-only + // assumption. setVertexBytes has a 4KB cap, so batch like + // FillPolygon does. + enum { BATCH_TRIS = 168, BATCH_FLOATS = BATCH_TRIS * 6 }; + float stackBuf[BATCH_FLOATS]; + + id stencilWritePipeline = [pipelineCache pipelineFor:CN1MetalPipelineStencilWrite]; + if (stencilWritePipeline == nil) { + return; + } + bindPipelineStateIfChanged(stencilWritePipeline); + [activeEncoder setDepthStencilState:depthStencilStateWriteRef]; + [activeEncoder setStencilReferenceValue:currentStencilReference]; + + // Polygon points arrive in screen pixel space (CN1's clipRect builds + // them by transforming the user-coord intersection back through the + // current transform on the Java side). The shader's vertex stage + // would otherwise apply the live `currentTransform` again -- a + // double-transform that shifts and re-rotates the stencil mask. Match + // the GL ES2 sequence at ClipRect.m:149-150: render the polygon with + // an identity transform, then restore. + simd_float4x4 savedTransform = currentTransform; + currentTransform = identityMatrix(); + uploadMatricesIfChanged(1); + // The solid pipeline expects a fragment colour buffer at index 0. Color + // writes are masked off on this pipeline so the value doesn't matter, + // but we still need to bind *something* or the Metal validator will + // fault. Use zero — premultiplied "discarded" colour. + simd_float4 dummyColor = (simd_float4){0, 0, 0, 0}; + [activeEncoder setFragmentBytes:&dummyColor length:sizeof(dummyColor) atIndex:0]; + + int triRemaining = num - 2; + int firstTri = 0; + while (triRemaining > 0) { + int batch = (triRemaining > BATCH_TRIS) ? BATCH_TRIS : triRemaining; + int out = 0; + for (int t = 0; t < batch; t++) { + int i = 1 + firstTri + t; // 1, 2, 3, ... + stackBuf[out++] = xCoords[0]; stackBuf[out++] = yCoords[0]; + stackBuf[out++] = xCoords[i]; stackBuf[out++] = yCoords[i]; + stackBuf[out++] = xCoords[i + 1]; stackBuf[out++] = yCoords[i + 1]; + } + [activeEncoder setVertexBytes:stackBuf length:(NSUInteger)(out * sizeof(float)) atIndex:0]; + [activeEncoder drawPrimitives:MTLPrimitiveTypeTriangle vertexStart:0 vertexCount:(NSUInteger)(out / 2)]; + firstTri += batch; + triRemaining -= batch; + } + + // Restore the user transform so subsequent draws apply the same + // rotation/scale/translate the framework's been accumulating. The + // identity swap above was only for the polygon stencil write. + currentTransform = savedTransform; + // From now on, every draw on this encoder is masked to pixels where + // stencil == currentStencilReference. + [activeEncoder setDepthStencilState:depthStencilStateTestEqualRef]; + [activeEncoder setStencilReferenceValue:currentStencilReference]; +} + +void CN1MetalDisablePolygonStencilClip(void) { + if (activeEncoder == nil) return; + ensureDepthStencilStates(); + [activeEncoder setDepthStencilState:depthStencilStateAlwaysPass]; +} + // --------------- Drawing helpers --------------- static CN1MetalMatrices currentMatrices(void) { @@ -902,6 +1097,7 @@ void CN1MetalDrawAlphaMaskRadial(id texture, static simd_float4x4 savedScreenProjection; static int savedScreenFw = 0; static int savedScreenFh = 0; +static uint32_t savedScreenStencilReference = 0; static BOOL savedScreenStateValid = NO; // Build a Y-down ortho projection for an offscreen (w x h) framebuffer. @@ -1067,16 +1263,49 @@ BOOL CN1MetalBeginMutableImageDraw(GLUIImage *image) { // the same Image.getGraphics(). desc.colorAttachments[0].loadAction = MTLLoadActionLoad; desc.colorAttachments[0].storeAction = MTLStoreActionStore; + // Attach a Stencil8 for polygon-shape clipping (#3921). Private + // storage (Memoryless isn't supported on the iOS Simulator on older + // Intel-Mac CI runners; see the note in METALView.m). Stencil values + // are scoped to this Begin/End cycle -- the next mutable draw on the + // same image will allocate a fresh stencil texture and clear it. + id device = CN1MetalDevice(); + id stencilTex = nil; + if (device != nil) { + MTLTextureDescriptor *stencilDesc = [MTLTextureDescriptor + texture2DDescriptorWithPixelFormat:MTLPixelFormatStencil8 + width:(NSUInteger)w height:(NSUInteger)h mipmapped:NO]; + stencilDesc.usage = MTLTextureUsageRenderTarget; + stencilDesc.storageMode = MTLStorageModePrivate; + stencilTex = [device newTextureWithDescriptor:stencilDesc]; + if (stencilTex != nil) { + desc.stencilAttachment.texture = stencilTex; + desc.stencilAttachment.loadAction = MTLLoadActionClear; + desc.stencilAttachment.storeAction = MTLStoreActionDontCare; + desc.stencilAttachment.clearStencil = 0; + } + } id enc = [cb renderCommandEncoderWithDescriptor:desc]; +#ifndef CN1_USE_ARC + // Render pass descriptor retains the stencil attachment for the + // pass duration; drop our local +1 once the encoder is built. + [stencilTex release]; +#endif if (enc == nil) return NO; [enc setViewport:(MTLViewport){0.0, 0.0, (double)w, (double)h, 0.0, 1.0}]; // Save current screen state (drawFrame opened the screen encoder via // setFramebuffer before starting drain) and swap in the mutable's. + // Include the polygon-clip stencil reference so the screen's stencil + // values don't collide with the mutable's (each has its own stencil + // texture, so the counters are independent; but the screen counter + // must come back unchanged so the next screen polygon clip lands at + // ref+1 rather than 1, which would alias against any pixels the + // screen wrote at ref=1 earlier in this frame). savedScreenEncoder = activeEncoder; savedScreenProjection = currentProjection; savedScreenFw = currentFramebufferWidth; savedScreenFh = currentFramebufferHeight; + savedScreenStencilReference = currentStencilReference; savedScreenStateValid = YES; activeEncoder = enc; @@ -1111,6 +1340,10 @@ void CN1MetalEndMutableImageDraw(GLUIImage *image) { currentProjection = savedScreenProjection; currentFramebufferWidth = savedScreenFw; currentFramebufferHeight = savedScreenFh; + // Restore the screen-side stencil reference counter so any + // pre-detour polygon clip's writes are still distinguishable + // from a fresh post-detour clip (see Begin's note). + currentStencilReference = savedScreenStencilReference; savedScreenEncoder = nil; savedScreenStateValid = NO; } diff --git a/Ports/iOSPort/nativeSources/ClipRect.m b/Ports/iOSPort/nativeSources/ClipRect.m index a2ce47b6d5..5cc0c98230 100644 --- a/Ports/iOSPort/nativeSources/ClipRect.m +++ b/Ports/iOSPort/nativeSources/ClipRect.m @@ -96,16 +96,40 @@ -(void)executeWithLog { -(void)execute { #ifdef CN1_USE_METAL - // Phase 2: handle only the rectangular scissor case via Metal's - // setScissorRect. Stencil-based clipping for texture/polygon clips - // is deferred to a later phase -- those currently fall back to a - // bounding-box scissor (incorrect for non-rectangular masks but - // does not crash). - int sx = x, sy = y, sw = width, sh = height; - if (sx < 0) { sw += sx; sx = 0; } - if (sy < 0) { sh += sy; sy = 0; } - CN1MetalSetScissor(sx, sy, sw, sh); - clipApplied = (sw > 0 && sh > 0); + // Issue #3921 path. Three shapes can arrive here: + // + // 1. A rectangular clip (initWithArgs:...) -- x/y/w/h hold the + // rect, no polygon points, no texture. Set the scissor and + // disable any prior polygon-stencil clip so we don't carry a + // stale stencil test forward. + // + // 2. A polygon clip (initWithPolygon:...) -- numPoints > 0 with + // xPoints/yPoints in framebuffer pixel space (built by + // NativeGraphics.clipRect's non-identity-transform branch via + // inverseClip + transform back to screen space). Use the + // stencil pipeline: render the polygon to the stencil at a + // fresh reference value, then bind a stencil-test depth state + // so subsequent draws on this encoder are masked to the + // polygon shape. Mirrors the GL ES2 sequence below. + // + // 3. A texture-mask clip (initWithArgs:...:texture:) -- texture + // != 0 with x/y/w/h holding the mask bbox. Metal's GLuint + // texture handle from the GL path isn't directly compatible + // with MTLTexture, so this still falls back to a bbox scissor + // (matches the documented Phase 2 fallback for the texture + // mask case; a follow-up could rasterise the alpha mask into + // an MTLTexture and sample it in a stencil-write shader). + if (numPoints > 0 && xPoints != NULL && yPoints != NULL) { + CN1MetalApplyPolygonStencilClip(xPoints, yPoints, numPoints); + clipApplied = YES; + } else { + int sx = x, sy = y, sw = width, sh = height; + if (sx < 0) { sw += sx; sx = 0; } + if (sy < 0) { sh += sy; sy = 0; } + CN1MetalDisablePolygonStencilClip(); + CN1MetalSetScissor(sx, sy, sw, sh); + clipApplied = (sw > 0 && sh > 0); + } #else #ifdef USE_ES2 if ( texture != 0 || numPoints > 0 ){ diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m index b274bf355a..2766e0d200 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m @@ -1731,11 +1731,57 @@ int Java_com_codename1_impl_ios_IOSImplementation_getDisplayWidthImpl() { (int numCommands, JAVA_OBJECT commands, int numPoints, JAVA_OBJECT points) { #ifdef CN1_USE_METAL - // Shape clipping for mutable images on Metal: scissor rect can only - // express axis-aligned rects. A future improvement could rasterise the - // shape into a stencil/alpha mask. For now treat as "no clip" so - // subsequent draws still render. - (void)numCommands; (void)commands; (void)numPoints; (void)points; + // Polygon-shape clipping for mutable images on Metal (#3921). Queue a + // ClipRect polygon op against the mutable target; its execute method + // on the drain path routes through CN1MetalApplyPolygonStencilClip, + // which fills the polygon into the per-mutable-image stencil texture + // (allocated by CN1MetalBeginMutableImageDraw) and switches the + // depth-stencil state so subsequent draws on the mutable's encoder + // are masked to the polygon shape. + // + // The points buffer received here is a full GeneralPath dump: anchor + // + control coords interleaved as float pairs. Curve control points + // can sit outside the actual rasterised path, but CN1's clip + // construction only emits polygon paths through this entry point + // (Java side: NativeGraphics.clipRect:4670 -> inverseClip path + // intersect, which only produces line segments). For non-polygon + // shapes a future patch can route through an alpha-mask stencil + // fill; today the line-only assumption matches every clip CN1 can + // build. + (void)numCommands; (void)commands; + GLUIImage *target = [CodenameOne_GLViewController instance].currentMutableImage; + if (target == nil) return; +#ifndef NEW_CODENAME_ONE_VM + org_xmlvm_runtime_XMLVMArray* pArray = points; + JAVA_ARRAY_FLOAT* data = (JAVA_ARRAY_FLOAT*)pArray->fields.org_xmlvm_runtime_XMLVMArray.array_; + int bufferLen = pArray->fields.org_xmlvm_runtime_XMLVMArray.length_; +#else + JAVA_ARRAY_FLOAT* data = (JAVA_ARRAY_FLOAT*)((JAVA_ARRAY)points)->data; + int bufferLen = ((JAVA_ARRAY)points)->length; +#endif + // Use the Java-passed `numPoints` (the actual used float count from + // shape.getPointsSize()) -- the underlying buffer is reused / grown- + // only by getTmpNativeDrawShape_coords, so its JAVA_ARRAY length can + // exceed the actual point count and trailing slots contain stale + // data from previous (larger) shapes. Reading those would inject + // spurious polygon vertices that produce visible spike artefacts in + // the clipped fill (#3921 / PR #4924). + int len = numPoints; + if (len > bufferLen) len = bufferLen; // safety clamp + if (len < 6 || data == NULL) return; // need at least 3 (x, y) pairs + int numPairs = len / 2; + JAVA_FLOAT x[numPairs]; + JAVA_FLOAT y[numPairs]; + for (int i = 0; i < numPairs; i++) { + x[i] = data[i * 2]; + y[i] = data[i * 2 + 1]; + } + ClipRect *f = [[ClipRect alloc] initWithPolygon:x y:y length:numPairs]; + [f setTarget:target]; + [[CodenameOne_GLViewController instance] upcomingAddClip:f]; +#ifndef CN1_USE_ARC + [f release]; +#endif #else CGContextRef context = UIGraphicsGetCurrentContext(); CGContextRestoreGState(context); diff --git a/Ports/iOSPort/nativeSources/METALView.h b/Ports/iOSPort/nativeSources/METALView.h index 0583764408..3de2a10313 100644 --- a/Ports/iOSPort/nativeSources/METALView.h +++ b/Ports/iOSPort/nativeSources/METALView.h @@ -54,6 +54,15 @@ // are ephemeral (each is cleared on acquire), so we render into this // reusable texture and blit it to the drawable at present time. @property (nonatomic, retain) id screenTexture; +// Stencil8 attachment used for polygon-shape clipping (#3921). Same +// dimensions as screenTexture; cleared at the start of every frame so +// reference-value-counter stencil reuse never crosses frame boundaries. +// Pipeline states declare stencilAttachmentPixelFormat = Stencil8 even +// though most draws bind an "always-pass / no-write" depth-stencil state +// so they're functionally stencil-free; only the polygon-clip path +// engages the stencil via ClipRect.m's CN1MetalApplyPolygonStencilClip +// helper. +@property (nonatomic, retain) id stencilTexture; @property (nonatomic, retain) UIView* peerComponentsLayer; @property (nonatomic, readonly) int framebufferWidth; @property (nonatomic, readonly) int framebufferHeight; diff --git a/Ports/iOSPort/nativeSources/METALView.m b/Ports/iOSPort/nativeSources/METALView.m index 675a345f53..fab5adbef8 100644 --- a/Ports/iOSPort/nativeSources/METALView.m +++ b/Ports/iOSPort/nativeSources/METALView.m @@ -49,6 +49,7 @@ @implementation METALView @synthesize renderCommandEncoder; @synthesize drawable; @synthesize screenTexture; +@synthesize stencilTexture; @synthesize peerComponentsLayer; @synthesize framebufferWidth; @synthesize framebufferHeight; @@ -300,6 +301,23 @@ -(void)updateFrameBufferSize:(int)w h:(int)h { clearPass.colorAttachments[0].clearColor = MTLClearColorMake(0.0, 0.0, 0.0, 1.0); [[clearCb renderCommandEncoderWithDescriptor:clearPass] endEncoding]; [clearCb commit]; + + // Build a matching Stencil8 attachment for polygon-shape clipping + // (#3921). Private storage rather than Memoryless because Memoryless + // is only supported on tile-based deferred GPUs (iOS Simulator on + // older Intel-Mac CI runners doesn't accept it). The stencil is + // ephemeral conceptually but Private works on all GPU families and + // the size cost is tiny (1 byte/pixel). + MTLTextureDescriptor *stencilDesc = [MTLTextureDescriptor + texture2DDescriptorWithPixelFormat:MTLPixelFormatStencil8 + width:pw height:ph mipmapped:NO]; + stencilDesc.usage = MTLTextureUsageRenderTarget; + stencilDesc.storageMode = MTLStorageModePrivate; + id newStencil = [layer.device newTextureWithDescriptor:stencilDesc]; + self.stencilTexture = newStencil; +#ifndef CN1_USE_ARC + [newStencil release]; +#endif } -(void)createRenderPassDescriptor { @@ -317,6 +335,18 @@ -(void)createRenderPassDescriptor { colorAttachment.texture = self.screenTexture; colorAttachment.loadAction = MTLLoadActionLoad; colorAttachment.storeAction = MTLStoreActionStore; + // Attach the Stencil8 texture for polygon-shape clipping (#3921). + // Cleared at the start of every frame and discarded at the end -- + // stencil values from previous frames are never referenced, and the + // reference-value counter in CN1Metalcompat resets per encoder, so + // a fresh clear is the right semantics. + if (self.stencilTexture != nil) { + MTLRenderPassStencilAttachmentDescriptor* stencilAttachment = self.renderPassDescriptor.stencilAttachment; + stencilAttachment.texture = self.stencilTexture; + stencilAttachment.loadAction = MTLLoadActionClear; + stencilAttachment.storeAction = MTLStoreActionDontCare; + stencilAttachment.clearStencil = 0; + } } - (void)setFramebuffer diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java index 84b2235f6a..9ca018a352 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSImplementation.java @@ -1641,7 +1641,16 @@ private void setNativeClippingGlobal(ClipShape shape){ setNativeClippingGlobal(bounds.getX(), bounds.getY(), bounds.getWidth(), bounds.getHeight(), true); } else if (shape.isPolygon()) { int pointsSize = shape.getPointsSize(); - if (polygonPointsBuffer == null || polygonPointsBuffer.length < pointsSize) { + // Reallocate when the buffer doesn't EXACTLY match -- previously + // this only reallocated when undersized, so a smaller polygon + // reused a larger buffer and the trailing slots retained the + // previous (larger) polygon's vertices. The native side reads + // the JAVA_ARRAY's allocated length, not a separate count, so + // those stale vertices became "real" polygon corners and + // produced visible spike artefacts in the rendered clip on + // iOS Metal (#3921 / PR #4924). Allocate exactly the size we + // need so trailing garbage can't appear. + if (polygonPointsBuffer == null || polygonPointsBuffer.length != pointsSize) { polygonPointsBuffer = new float[pointsSize]; } shapeToPolygon(shape, polygonPointsBuffer); diff --git a/scripts/android/screenshots/graphics-clip-under-rotation.png b/scripts/android/screenshots/graphics-clip-under-rotation.png new file mode 100644 index 0000000000..8876e1fa9a Binary files /dev/null and b/scripts/android/screenshots/graphics-clip-under-rotation.png differ diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java index 5071520dcd..f98d558ff4 100644 --- a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/Cn1ssDeviceRunner.java @@ -9,6 +9,7 @@ import com.codenameone.examples.hellocodenameone.NativeInterfaceLanguageValidator; import com.codenameone.examples.hellocodenameone.tests.graphics.AffineScale; import com.codenameone.examples.hellocodenameone.tests.graphics.Clip; +import com.codenameone.examples.hellocodenameone.tests.graphics.ClipUnderRotation; import com.codenameone.examples.hellocodenameone.tests.graphics.DrawArc; import com.codenameone.examples.hellocodenameone.tests.graphics.DrawGradient; import com.codenameone.examples.hellocodenameone.tests.graphics.DrawImage; @@ -128,6 +129,7 @@ private static int testTimeoutMs() { new FillShape(), new StrokeTest(), new Clip(), + new ClipUnderRotation(), new TileImage(), new Rotate(), new TransformTranslation(), @@ -362,6 +364,7 @@ private static boolean isJsSkippedScreenshotTest(String testName) { // graphics tests || "AffineScale".equals(testName) || "Clip".equals(testName) + || "ClipUnderRotation".equals(testName) || "DrawArc".equals(testName) || "DrawGradient".equals(testName) || "DrawImage".equals(testName) diff --git a/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/graphics/ClipUnderRotation.java b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/graphics/ClipUnderRotation.java new file mode 100644 index 0000000000..322f570119 --- /dev/null +++ b/scripts/hellocodenameone/common/src/main/java/com/codenameone/examples/hellocodenameone/tests/graphics/ClipUnderRotation.java @@ -0,0 +1,119 @@ +package com.codenameone.examples.hellocodenameone.tests.graphics; + +import com.codename1.ui.Graphics; +import com.codename1.ui.Transform; +import com.codename1.ui.geom.Rectangle; +import com.codenameone.examples.hellocodenameone.tests.AbstractGraphicsScreenshotTest; + +// Targeted test for the rotated-clip rasterisation path raised in issue +// #3921 ("Clipping region not respected with non-90 degree rotations"). +// Stresses the same code path the original report cared about, but with +// pushClip/popClip only -- no getClip/setClip(int[]) -- so it isolates the +// rasterisation path from the well-known fact that getClip()/setClip(int[]) +// can't preserve a non-axis-aligned clip shape (which is what ddyer0's own +// follow-up comment on the issue identified as the cause of his repro). +// +// Sequence: +// pushClip +// clipRect(cell) // outer axis-aligned clip +// rotateRadians(30deg, pivot=inner-rect-centre) +// clipRect(inner) // intersect in rotated space +// // -> screen clip is now +// // the intersection of an +// // axis-aligned rect and a +// // rotated rect (a polygon +// // on screen) +// fillRect(big red) +// rotateRadians(-30deg, pivot) +// popClip +// +// Correct render: the red fill appears as a 30deg-tilted rect centred on the +// inner-rect outline -- the clip shape under rotation is honoured. +// Bug A (clip widened to axis-aligned bbox): the red fill is an axis-aligned +// rect larger than (and aligned with) the navy reference outline -- the bbox +// of the rotated rect, not the rotated rect itself. +// Bug B (polygon clip dropped entirely): the red fill covers the whole cell +// -- the rasteriser saw an unknown clip shape and disabled clipping. 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. +// +// The navy reference outline is the pre-rotation inner rect; a correct +// render shows a tilted-rect red fill that overhangs the navy outline at +// the two diagonal corners and falls short of it at the two opposite +// corners, while a Bug A render exactly matches the outline, and a Bug B +// render swamps the outline entirely. +public class ClipUnderRotation extends AbstractGraphicsScreenshotTest { + + @Override + protected void drawContent(Graphics g, Rectangle bounds) { + int x = bounds.getX(); + int y = bounds.getY(); + int w = bounds.getWidth(); + int h = bounds.getHeight(); + + g.setColor(0xeeeeee); + g.fillRect(x, y, w, h); + + if (!Transform.isSupported()) { + g.setColor(0); + g.drawString("Affine unsupported", x + 4, y + 4); + return; + } + + // Inner rect: 1/3 of the cell, centred. Big enough that the + // 30deg-rotated version still fits comfortably inside the cell; + // small enough that there is plenty of gray buffer around it so a + // Bug B render (whole-cell red) is unmistakable. + int innerW = Math.max(40, w / 3); + int innerH = Math.max(40, h / 3); + int innerX = x + (w - innerW) / 2; + int innerY = y + (h - innerH) / 2; + int pivotX = innerX + innerW / 2; + int pivotY = innerY + innerH / 2; + + g.pushClip(); + // Outer clip = the cell itself. This guarantees we are intersecting + // a known rect with the rotated inner rect inside the transform + // block, which is what forces the framework into the polygon-clip + // branch (clipRect-under-non-identity-transform path in + // IOSImplementation.NativeGraphics.clipRect line 4670). + g.clipRect(x, y, w, h); + + float angle = (float)(Math.PI / 6); // 30deg + g.rotateRadians(angle, pivotX, pivotY); + // Intersect with the inner rect. In screen pixels this produces a + // rotated rectangular clip shape (a parallelogram-shaped polygon). + g.clipRect(innerX, innerY, innerW, innerH); + + // Big red fill: a rect much larger than the cell, expressed in the + // rotated coordinate system. The rasteriser is responsible for + // honouring the polygon clip and rendering only the rotated-rect + // intersection. + g.setColor(0xff0000); + g.fillRect(x - w, y - h, w * 4, h * 4); + + // Restore the transform before popClip so the popped state is the + // identity transform we started with. + g.rotateRadians(-angle, pivotX, pivotY); + g.popClip(); + + // Navy axis-aligned outline of the pre-rotation inner rect. Drawn + // after popClip so its position is stable. The red fill should + // appear as a 30deg-rotated rect overlapping this outline (not + // matching it exactly). + g.setColor(0x000080); + g.drawRect(innerX, innerY, innerW - 1, innerH - 1); + + // Sentinel green dot in the corner; should always render after the + // pop / un-rotate restored the identity transform. + g.setColor(0x008000); + g.fillRect(x + 2, y + 2, 6, 6); + } + + @Override + protected String screenshotName() { + return "graphics-clip-under-rotation"; + } +} diff --git a/scripts/ios/screenshots-metal/graphics-clip-under-rotation.png b/scripts/ios/screenshots-metal/graphics-clip-under-rotation.png new file mode 100644 index 0000000000..5d9efa1a87 Binary files /dev/null and b/scripts/ios/screenshots-metal/graphics-clip-under-rotation.png differ diff --git a/scripts/ios/screenshots/graphics-clip-under-rotation.png b/scripts/ios/screenshots/graphics-clip-under-rotation.png new file mode 100644 index 0000000000..3c132bf837 Binary files /dev/null and b/scripts/ios/screenshots/graphics-clip-under-rotation.png differ diff --git a/scripts/javascript/screenshots/graphics-clip-under-rotation.png b/scripts/javascript/screenshots/graphics-clip-under-rotation.png new file mode 100644 index 0000000000..a60f0f931f Binary files /dev/null and b/scripts/javascript/screenshots/graphics-clip-under-rotation.png differ