From ad87821afc8f9cc37ebb5e6cb7607bb1e1d9fcf7 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 16 May 2026 09:40:52 +0300 Subject: [PATCH] iOS Metal: fix stale framebuffer on rotation (#4954) viewWillTransitionToSize: fires before UIKit updates the view's bounds, so METALView.updateFrameBufferSize: -- which ignored its caller-supplied size and read self.bounds -- saw the previous orientation's dimensions, matched the cached framebuffer size, and early-returned without recreating screenTexture, the projection matrix or the stencil texture. The CAMetalLayer drawable is meanwhile auto-resized by UIKit, so the subsequent drawFrame blits the old-sized persistent screenTexture into the new-sized drawable: the previous orientation's content lands in a corner and the remaining pixels read back uninitialised, producing the smeared/pink frames in the issue. Trust the parameters (now consistently physical pixels: the three GLViewController callers multiply by scaleValue) and only fall back to bounds when the caller passes 0. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../CodenameOne_GLViewController.m | 13 +++++--- Ports/iOSPort/nativeSources/METALView.m | 32 +++++++++++++------ 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m index 2766e0d200..e2bca4ecb0 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.m @@ -2540,7 +2540,7 @@ -(void)updateCanvas:(BOOL)animated { // the display width/height each time to match the view, without performing other resizing // details, so it is possible that the size change event still needs to be sent // even if the display width already matches the value we're given here. - [[self eaglView] updateFrameBufferSize:(int)size.width h:(int)size.height]; + [[self eaglView] updateFrameBufferSize:(int)(size.width * scaleValue) h:(int)(size.height * scaleValue)]; displayWidth = (int)size.width * scaleValue; displayHeight = (int)size.height * scaleValue; screenSizeChanged(displayWidth, displayHeight); @@ -3282,9 +3282,14 @@ -(void) viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id)coor } // simply create a property of 'BOOL' type - [[self eaglView] updateFrameBufferSize:(int)size.width h:(int)size.height]; + // Pass physical pixels: viewWillTransitionToSize: fires before UIKit + // updates the view's bounds, so on the Metal backend the EAGLView's + // bounds read would still return the previous orientation. The + // METALView resize logic trusts these parameters; the EAGLView + // implementation is a no-op (#4954). + [[self eaglView] updateFrameBufferSize:(int)(size.width * scaleValue) h:(int)(size.height * scaleValue)]; [[self eaglView] deleteFramebuffer]; - + displayWidth = (int)size.width * scaleValue; displayHeight = (int)size.height * scaleValue; @@ -3325,7 +3330,7 @@ -(void)didRotateFromInterfaceOrientation:(UIInterfaceOrientation)fromInterfaceOr return; } - [[self eaglView] updateFrameBufferSize:(int)self.view.bounds.size.width h:(int)self.view.bounds.size.height]; + [[self eaglView] updateFrameBufferSize:(int)(self.view.bounds.size.width * scaleValue) h:(int)(self.view.bounds.size.height * scaleValue)]; [[self eaglView] deleteFramebuffer]; displayWidth = (int)self.view.bounds.size.width * scaleValue; diff --git a/Ports/iOSPort/nativeSources/METALView.m b/Ports/iOSPort/nativeSources/METALView.m index fab5adbef8..aafc03219f 100644 --- a/Ports/iOSPort/nativeSources/METALView.m +++ b/Ports/iOSPort/nativeSources/METALView.m @@ -228,16 +228,28 @@ - (void)deleteFramebuffer -(void)updateFrameBufferSize:(int)w h:(int)h { - // Ignore the passed w/h -- CodenameOne_GLViewController.m calls this with - // logical points (self.view.bounds.size), but the Metal drawable and - // projection must be in physical pixels. The GL path tolerates the - // logical-point argument because EAGLView.updateFrameBufferSize: is a - // no-op (dimensions get read back from the renderbuffer after the layer - // is bound). For Metal we always compute from our own layer bounds. - CGSize sz = self.bounds.size; - CGFloat s = self.contentScaleFactor; - int pw = (int)(sz.width * s); - int ph = (int)(sz.height * s); + // Trust caller-supplied physical-pixel dimensions; fall back to bounds + // only if the caller passes 0. Reading self.bounds alone is unsafe + // during rotation: viewWillTransitionToSize: in CodenameOne_GLView- + // Controller fires BEFORE UIKit updates the view's bounds, so a + // bounds-derived size matches the cached (old) framebuffer dimensions + // and the early-return below would leave screenTexture, the projection + // matrix and stencil texture at the previous orientation. CAMetalLayer's + // drawableSize is auto-resized by UIKit on rotation, so the next + // drawFrame would blit the old-sized screenTexture into the new-sized + // drawable -- portrait content lands in a corner of the landscape + // drawable and the remaining pixels read back uninitialised, surfacing + // as the smeared/pink frames reported in #4954. The callers in this + // file (initWithCoder, layoutSubviews) and the GLViewController callers + // all pass physical pixels. + int pw = w; + int ph = h; + if (pw <= 0 || ph <= 0) { + CGSize sz = self.bounds.size; + CGFloat s = self.contentScaleFactor; + pw = (int)(sz.width * s); + ph = (int)(sz.height * s); + } if (pw <= 0 || ph <= 0) return; if (pw == framebufferWidth && ph == framebufferHeight) { return;