diff --git a/Ports/iOSPort/nativeSources/CN1Metalcompat.h b/Ports/iOSPort/nativeSources/CN1Metalcompat.h index 3c8566cabf5..925bdc90e42 100644 --- a/Ports/iOSPort/nativeSources/CN1Metalcompat.h +++ b/Ports/iOSPort/nativeSources/CN1Metalcompat.h @@ -382,5 +382,11 @@ void CN1MetalUnregisterMutableImage(GLUIImage *image); // re-seed from getImage. Must be called on the main thread. void CN1MetalBackupMutableImagesForSuspend(void); +// Texture-discard recovery (issue #5349). The generation is bumped on foreground +// and memory warning; GLUIImage.getMTLTexture re-decodes its read-only texture +// from the retained UIImage when its cached generation lags. +int CN1MetalTextureValidateGeneration(void); +void CN1MetalBumpTextureValidateGeneration(void); + #endif /* CN1_USE_METAL */ #endif /* CN1Metalcompat_h */ diff --git a/Ports/iOSPort/nativeSources/CN1Metalcompat.m b/Ports/iOSPort/nativeSources/CN1Metalcompat.m index 5860fd8b5d5..5f67e0e2b28 100644 --- a/Ports/iOSPort/nativeSources/CN1Metalcompat.m +++ b/Ports/iOSPort/nativeSources/CN1Metalcompat.m @@ -1933,6 +1933,30 @@ void CN1MetalUnregisterMutableImage(GLUIImage *image) { } } +// --------------- Texture-discard recovery (issue #5349) --------------- +// +// iOS discards the contents of MTLStorageModePrivate textures while the app is +// suspended (and can reclaim them under memory pressure). CN1 caches such +// textures for every image, so after a resume it would sample the discarded +// garbage and paint a violet/magenta fill on any surface the diff-painter does +// not fully repaint (Toolbar, unselected Tabs, a FAB shadow, a Switch thumb). +// +// A monotonically increasing generation, bumped on foreground and on memory +// warning. GLUIImage.getMTLTexture re-decodes its read-only texture from the +// retained UIImage the first time it is sampled in a newer generation. This is +// the only safe recovery signal: probing the OS purgeable state per-draw +// (setPurgeableState) trips Metal's commit-time lockPurgeableObjects validation +// on textures already referenced by an in-flight command buffer. +static volatile int gTextureValidateGeneration = 0; + +int CN1MetalTextureValidateGeneration(void) { + return gTextureValidateGeneration; +} + +void CN1MetalBumpTextureValidateGeneration(void) { + gTextureValidateGeneration++; +} + void CN1MetalBackupMutableImagesForSuspend(void) { if (gMutableImageRegistry == nil) return; NSArray *snapshot; @@ -1945,6 +1969,13 @@ void CN1MetalBackupMutableImagesForSuspend(void) { } for (GLUIImage *image in snapshot) { if ([image mtlMutableTexture] == nil) { + // Layer B (issue #5349): a plain read-only image whose texture was + // uploaded from a UIImage. Drop it now so it re-decodes from that + // retained UIImage after resume instead of sampling the contents + // iOS discards during suspend. (Also frees GPU memory before we go + // to the background.) getMTLTexture's generation check is the + // primary guard; this just reclaims eagerly. + [image dropReadOnlyCachedTexture]; continue; } // Read the current GPU pixels back into a UIImage *before* dropping @@ -1962,6 +1993,10 @@ void CN1MetalBackupMutableImagesForSuspend(void) { // invalidates the read-only mtlTexture cache. [image setImage:backup]; } + // Glyph atlases are private-storage textures too; drop them so text + // re-rasterises after resume rather than sampling discarded contents. + extern void CN1MetalGlyphAtlasReleaseAll(void); + CN1MetalGlyphAtlasReleaseAll(); } // --------------- Memory-pressure cache release --------------- @@ -1983,6 +2018,11 @@ void CN1MetalReleaseCaches(void) { // linear-gradient / radial-gradient), no offscreen bitmap to cache. // Only the glyph atlases need releasing under memory pressure. CN1MetalGlyphAtlasReleaseAll(); + // issue #5349: a memory warning means the OS is (or is about to start) + // reclaiming resources -- bump the generation so cached read-only image + // textures re-decode from their UIImage on next use rather than risk + // sampling contents the OS discarded. + CN1MetalBumpTextureValidateGeneration(); } #endif /* CN1_USE_METAL */ diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m b/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m index 23d2ac4f0c9..a7fd0fdca87 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLAppDelegate.m @@ -301,6 +301,14 @@ - (void)cn1ApplicationWillEnterForeground if ([renderingView respondsToSelector:@selector(invalidateRetainedFramebuffer)]) { [renderingView invalidateRetainedFramebuffer]; } + // issue #5349: iOS may have discarded the contents of our private-storage + // image/glyph textures while suspended. Bump the texture-validate + // generation so every cached read-only image texture re-decodes from its + // retained UIImage on next sample instead of rendering the discarded + // garbage (a violet/magenta fill) on surfaces the diff-painter does not + // fully repaint this frame. Pairs with the screenTexture clear above. + extern void CN1MetalBumpTextureValidateGeneration(void); + CN1MetalBumpTextureValidateGeneration(); #endif // Defer to the next runloop so UIKit can settle the view bounds // after the snapshot rotation. updateCanvas itself also diff --git a/Ports/iOSPort/nativeSources/GLUIImage.h b/Ports/iOSPort/nativeSources/GLUIImage.h index a156265ea24..675e805cd81 100644 --- a/Ports/iOSPort/nativeSources/GLUIImage.h +++ b/Ports/iOSPort/nativeSources/GLUIImage.h @@ -41,6 +41,13 @@ int textureHeight; #ifdef CN1_USE_METAL id mtlTexture; + // issue #5349: the texture-validate generation this cached read-only + // mtlTexture was last confirmed against. When it lags the global counter + // (bumped on foreground / memory warning), getMTLTexture re-decodes from + // the retained UIImage -- iOS may have discarded the private-storage + // texture's contents while the app was suspended, and we must not sample + // the leftover garbage (which renders as a violet/magenta fill). + int mtlTextureGeneration; // Phase 3 v2: mutable-image render target. Allocated lazily by // CN1MetalEnsureMutableTexture sized to the mutable image's logical // dimensions. drawFrame opens an MTLRenderCommandEncoder against this @@ -82,6 +89,12 @@ // is the freshest pixel source. -(id)getMTLTexture; +// issue #5349: drop the cached read-only mtlTexture so the next getMTLTexture +// re-decodes it from the retained UIImage. Called from the suspend backup for +// every registered image that is NOT a mutable render target (those go through +// the readback path instead). Safe no-op when no read-only texture is cached. +-(void)dropReadOnlyCachedTexture; + // Phase 3 v2 mutable-image accessors. CN1Metalcompat owns the lifecycle; // these are the storage hooks. Accessors only -- consumers outside this // file route through the CN1Metal*MutableImage API rather than poking diff --git a/Ports/iOSPort/nativeSources/GLUIImage.m b/Ports/iOSPort/nativeSources/GLUIImage.m index 41ca726f69c..1a9818791fe 100644 --- a/Ports/iOSPort/nativeSources/GLUIImage.m +++ b/Ports/iOSPort/nativeSources/GLUIImage.m @@ -184,12 +184,42 @@ -(void)setName:(NSString*)s { // pixel source. Screen-side DrawImage samples this; the cached UIImage- // derived mtlTexture is only relevant for never-drawn-into images. if (mtlMutableTexture != nil) return mtlMutableTexture; - if (mtlTexture != nil) return mtlTexture; + if (mtlTexture != nil) { + // issue #5349: the first time this cached read-only texture is sampled + // after a foreground/resume (or a memory warning), re-decode it from the + // retained UIImage. iOS can discard a private-storage texture's contents + // while the app is suspended, and the leftover bytes render as a + // violet/magenta fill; re-decoding from the CPU-side UIImage is cheap and + // always correct. A plain generation compare is used (no OS purgeable + // probe) -- setPurgeableState on a texture already referenced by an + // in-flight command buffer trips Metal's commit-time validation. + int gen = CN1MetalTextureValidateGeneration(); + if (mtlTextureGeneration != gen) { + mtlTextureGeneration = gen; + [mtlTexture release]; + mtlTexture = nil; + } else { + return mtlTexture; + } + } if (img == nil) return nil; mtlTexture = CN1MetalTextureFromUIImage(img); + mtlTextureGeneration = CN1MetalTextureValidateGeneration(); + // Track every GPU-backed image (not just mutable render targets) so the + // suspend backup can drop/rebuild its texture too (issue #5349). The weak + // registry drops the entry automatically on dealloc. + CN1MetalRegisterMutableImage(self); return mtlTexture; } +-(void)dropReadOnlyCachedTexture { + // issue #5349: release the cached read-only texture; getMTLTexture rebuilds + // it from the retained UIImage on next use. Bumping the generation match is + // unnecessary -- a nil texture is unconditionally rebuilt. + [mtlTexture release]; + mtlTexture = nil; +} + -(id)mtlMutableTexture { return mtlMutableTexture; } -(void)setMtlMutableTexture:(id)t width:(int)w height:(int)h { // Retain new, release old. Under MRR direct ivar assignment doesn't diff --git a/Ports/iOSPort/nativeSources/METALView.m b/Ports/iOSPort/nativeSources/METALView.m index 76ea310ee13..6fdbe77259e 100644 --- a/Ports/iOSPort/nativeSources/METALView.m +++ b/Ports/iOSPort/nativeSources/METALView.m @@ -796,7 +796,16 @@ -(void)presentPreservedFrameIfNeeded { -(void)invalidateRetainedFramebuffer { retainedFramebufferInvalid = YES; - clearRetainedFramebufferOnNextFrame = NO; + // issue #5349: after a suspend/resume iOS may have discarded the contents + // of the private-storage screenTexture, so loading it (MTLLoadActionLoad) + // paints garbage/violet in any region the diff-painter does not repaint + // this frame (the Toolbar/title bar, unselected Tabs, a FAB shadow, ...). + // Previously the clear only happened once a repaint covered the whole + // screen -- but a partial animation repaint routinely lands first, loads + // the stale texture and pins the garbage until a touch dirties the region. + // Force the very next frame to clear unconditionally so no discarded pixels + // can survive; the full repaint triggered on foreground then fills content. + clearRetainedFramebufferOnNextFrame = YES; // A preserved resize frame is also stale after a suspend/resume cycle. needsResizePresent = NO; }