Skip to content

feat: Backport the gooey morph to API 29-32 (OpenGL → hardware Bitmap)#1

Merged
blazejkustra merged 2 commits into
blazejkustra:mainfrom
desugar-64:syaremych/api29-32-gl-morph-backport
Jun 28, 2026
Merged

feat: Backport the gooey morph to API 29-32 (OpenGL → hardware Bitmap)#1
blazejkustra merged 2 commits into
blazejkustra:mainfrom
desugar-64:syaremych/api29-32-gl-morph-backport

Conversation

@desugar-64

Copy link
Copy Markdown
Contributor

Hi! 👋

I'm not really a React Native developer, so apologies in advance if I've missed any RN conventions here, and I'm happy to fix anything. I work mostly with Android and graphics. I came across this project, really liked the effect, and noticed it only runs the real morph on API 33+ (RenderEffect + RuntimeShader) and quietly falls back to a plain crossfade below that. I'd explored these exact GPU/EGL paths while building imla, so making the effect run on more devices felt like low-hanging fruit, and I figured I'd give it a go.

So this PR backports the blur + alpha-threshold morph down to API 29-32.

How it works

  • Renders the same crossfade → blur → alpha-threshold pipeline in OpenGL ES 3.0 on its own thread.
  • Presents the result via Bitmap.wrapHardwareBuffer: renders into an ImageReader, wraps its HardwareBuffer as a hardware Bitmap, and lets it composite through the normal HWUI path. No SurfaceView, no new dependency.
  • The blur matches RenderEffect.createBlurEffect 1:1 (same Skia sigma = 0.57735 * radius + 0.5), so it looks the same as the platform path.

The whole pipeline is GPU-accelerated, so performance is on par with the native RenderEffect path.

Scope

I tried to keep the change as small and isolated as possible. It all lives in one internal package, and the existing paths are left alone:

  • No public API change, no new dependencies, minSdk unchanged.
  • The API 33+ RenderEffect path is untouched (just moved behind a small interface); below API 29 it still falls back to crossfade as before.
  • The backend is selected purely by API level, so the new GL code can't affect the existing 33+ behavior at runtime.

Tested

  • Xiaomi Mi 9 (Android 11, API 30): renders the gooey metaball effect where it previously only crossfaded, upright and matching the 33+ output.
morph_showcase.mp4

Totally understand if you'd prefer a different direction or want changes. Just thought I'd offer it.
Thanks for the lovely library! 🙂

Renders the blur + alpha-threshold morph in OpenGL ES 3.0 and presents it as a hardware
Bitmap (no SurfaceView), so API 29-32 shows the real effect instead of a crossfade. The
API 33+ RenderEffect path and public API are unchanged.

@blazejkustra blazejkustra left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really appreciate the contribution, and I love how clean the structure is! The MorphRenderer split and the GL port both read really nicely.

Most of my inline comments come down to one theme: release() is treated as a one-shot teardown, but renderer is a long-lived val that needs to survive detach/re-attach and resize, so some state doesn't reset cleanly across that. Thanks again! 🙏

Comment thread android/src/main/java/com/morphview/internal/MorphGlPipeline.kt Outdated
Comment thread android/src/main/java/com/morphview/internal/gl/EglCore.kt
Addresses the PR review on the GL morph backend. The unifying theme is that
the renderer is a long-lived object but release() was written as one-shot
teardown.

- release() now resets to a restartable state: it drains the GL thread
  (quitSafely + join) before returning, so a re-attach can't race teardown
  for the pipeline/leases, and it resets lastSubmitted/pending/renderScheduled
  so a re-attach at the same size/progress re-renders instead of stalling on
  the crossfade
- EglCore validates every EGL return and throws on failure, so a one-off init
  failure isn't cached and the next frame retries; the GL path is also gated
  behind a device GL ES 3.0 check
- Lease.close() recycles the bitmap to drop the HardwareBuffer ref deterministically
- decompose MorphGlPipeline into SwapChain / RenderTarget / SourceTexture and
  per-pass program objects
@desugar-64

Copy link
Copy Markdown
Contributor Author

@blazejkustra Thanks for the careful review.

What changed in this update:

  • release() resets to a restartable state: drains the GL thread before returning, resets the submit state.
  • EglCore validates its EGL calls and self-recovers from a transient init failure.
  • GL path gated behind a device GL ES 3.0 check.
  • Lease.close() recycles the bitmap for deterministic buffer release.
  • Decomposed MorphGlPipeline into SwapChain / RenderTarget / SourceTexture / per-pass programs.

Point by point:

1. release() leaves lastSubmitted set → morph never returns on re-attach

Fixed. release() now resets lastSubmitted, pending and renderScheduled, so a re-attach at the same size/progress builds a fresh RenderRequest, schedules a render, and the morph returns instead of stalling on the crossfade.

2. release() races a re-attach for pipeline/leases → EGL_BAD_ACCESS / crash / leak

Fixed, and same root cause as 1. release() now drains the GL thread before returning, posts the cleanup, then quitSafely() + join(). By the time it returns the old thread is dead and its cleanup has run, so a re-attach can't spin up a second GL thread that races teardown. The join() also publishes the cleanup's pipeline = null before the next thread starts. I kept the async single-GL-thread design but made release() a true reset-to-restartable.

3. The 2-deep lease trim is count-based, not fence-based

Fair concern. It matters more here because the GL thread is async and can render ahead of what's on screen. Reasons I kept count-based for now:

  • glFinish() before acquireLatestImage() guarantees the GPU writes are complete before handoff;
  • renders are coalesced (one scheduled at a time), so the GL thread doesn't render far ahead;
  • on an API-30 device the morph held a solid 60fps with no visible tearing or stutter in testing.

The proper fix is to fence on the buffer's release, wait until HWUI is done reading before the buffer is reused. That release fence isn't exposed by the Java ImageReader/Image API; getting it means dropping to the NDK (AImageReader / AHardwareBuffer sync fences), which I avoided on purpose to keep this pure Kotlin with no native code. The Java-side GPU fences (EGL15 / glFenceSync) only cover the producer, which glFinish() already handles. So without the NDK, count-based retention is the pragmatic choice; the sibling OpenGL blur pipeline uses the same. If you'd want the NDK fence path, I'm open to it, it's a larger change.

4. Resize closes the ImageReader while leases from it are still outstanding

Here I'd push back, and I'd welcome your read. I didn't add an explicit drain; I added bitmap.recycle() to Lease.close() so the HardwareBuffer reference is dropped deterministically rather than at GC.

The reason I think the crash doesn't occur: a HardwareBuffer (and the Bitmap wrapping it) is independently reference-counted, so it stays valid after the ImageReader is closed, until its own close(). The displayed frame isn't reading from a destroyed reader, and image.close() after the reader is closed just completes the buffer's teardown. The sibling blur pipeline closes its reader on resize with outstanding leases the same way and has been stable.

If you've actually reproduced the IllegalStateException on resize mid-animation, I'm glad to add the drain (close outstanding leases before swapping the reader), it's a safe, small change. I just didn't want to add coordination that the buffer's own refcounting already provides.

5. EglCore doesn't check its EGL returns → a one-off init failure caches a half-built core

Fixed on two levels:

  • EglCore now checks every return and throws on failure. The throwing constructor never assigns the cached eglCore, so the next frame builds a fresh one and a transient failure self-recovers.
  • Added a device-level GL ES 3.0 check (ActivityManager.getDeviceConfigurationInfo().reqGlEsVersion) gating the GL path, so an unsupported device never constructs it and goes straight to the crossfade fallback.

The decomposition in the list above also came out of your "release is one-shot" point, splitting the pipeline into owned units made the restartable release() simpler to write and matches the structure of the blur pipeline.

Happy to adjust #3 and #4 if you'd like them handled here.

@desugar-64 desugar-64 requested a review from blazejkustra June 27, 2026 22:59
@blazejkustra blazejkustra merged commit 601fdff into blazejkustra:main Jun 28, 2026
4 of 6 checks passed
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.

2 participants