diff --git a/android/src/main/java/com/morphview/MorphViewView.kt b/android/src/main/java/com/morphview/MorphViewView.kt index 934c254..8ecd1d5 100644 --- a/android/src/main/java/com/morphview/MorphViewView.kt +++ b/android/src/main/java/com/morphview/MorphViewView.kt @@ -8,32 +8,24 @@ import android.graphics.Canvas import android.graphics.Paint import android.graphics.PorterDuff import android.graphics.PorterDuffColorFilter -import android.graphics.RectF -import android.graphics.RenderEffect -import android.graphics.RenderNode -import android.graphics.RuntimeShader -import android.graphics.Shader -import android.os.Build import android.os.Handler import android.os.Looper import android.util.AttributeSet import android.view.View import android.view.animation.PathInterpolator +import com.morphview.internal.MorphRenderer import java.net.URL import java.util.concurrent.Executors import java.util.concurrent.atomic.AtomicInteger -import kotlin.math.PI import kotlin.math.abs import kotlin.math.ceil import kotlin.math.cos -import kotlin.math.min import kotlin.math.sin -import kotlin.math.sqrt /** - * Gooey "metaball" morph between two images. Both images are drawn into a single [RenderNode] that - * carries the blur + alpha-threshold [RenderEffect], and only that node is drawn to screen — so the - * crossfade and the effect are always committed in the same frame and the raw image never flashes. + * Gooey "metaball" morph between two images. This view owns image loading, the crossfade animation, + * and the optional baked border; the actual effect (crossfade → blur → alpha-threshold) is delegated + * to a [MorphRenderer] whose backend is chosen per API level in [MorphRenderer.create]. */ class MorphViewView : View { @@ -53,19 +45,8 @@ class MorphViewView : View { private var progress: Float = 0f private var animator: ValueAnimator? = null - private val shader: RuntimeShader? = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - RuntimeShader(ALPHA_THRESHOLD_SHADER) - } else { - null - } - - private val contentNode: RenderNode? = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) RenderNode("morph") else null - - private val drawPaint = Paint(Paint.FILTER_BITMAP_FLAG or Paint.ANTI_ALIAS_FLAG) - private val dstRect = RectF() - private var tintFilter: PorterDuffColorFilter? = null + // Async backends invalidate the view off-thread when a new frame is ready. + private val renderer = MorphRenderer.create(context) { postInvalidateOnAnimation() } private val ioExecutor = Executors.newCachedThreadPool() private val mainHandler = Handler(Looper.getMainLooper()) @@ -116,7 +97,6 @@ class MorphViewView : View { fun setTintColorInt(color: Int?) { tintColor = color - tintFilter = color?.let { PorterDuffColorFilter(it, PorterDuff.Mode.SRC_IN) } invalidate() } @@ -150,71 +130,15 @@ class MorphViewView : View { } } + /** Effect parameters are recomputed from [progress] by the renderer in [onDraw]; just redraw. */ private fun applyProgress() { - // sqrt(sin) has a vertical tangent at the ends, so any motion off rest is already heavily - // blurred — a transition interrupted near completion can't flash a crisp, un-fused image. - val blurProgress = sqrt(sin(progress * PI.toFloat())) - val radiusPx = blurProgress * blurRadius * density - - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || shader == null) { - invalidate() - return - } - - shader.setFloatUniform("threshold", 0.5f) - val thresholdEffect = RenderEffect.createRuntimeShaderEffect(shader, "composable") - // 0.5px floor keeps the same effect chain at rest instead of toggling it on/off at 0 and 1. - val effectiveRadius = radiusPx.coerceAtLeast(0.5f) - val blurEffect = RenderEffect.createBlurEffect(effectiveRadius, effectiveRadius, Shader.TileMode.DECAL) - // createChainEffect(outer, inner): blur runs first, then the threshold. - contentNode?.setRenderEffect(RenderEffect.createChainEffect(thresholdEffect, blurEffect)) invalidate() } // MARK: - Drawing override fun onDraw(canvas: Canvas) { - val node = contentNode - if (node == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || shader == null || - !canvas.isHardwareAccelerated || width <= 0 || height <= 0 - ) { - // Fallback (pre-API-33 or software canvas): plain crossfade, no morph effect. - drawImage(canvas, fromBitmap, 1f - progress) - drawImage(canvas, toBitmap, progress) - return - } - - node.setPosition(0, 0, width, height) - val recordingCanvas = node.beginRecording() - try { - drawImage(recordingCanvas, fromBitmap, 1f - progress) - drawImage(recordingCanvas, toBitmap, progress) - } finally { - node.endRecording() - } - canvas.drawRenderNode(node) - } - - /** Draws [bmp] centered and scaled-to-fit (ImageView FIT_CENTER) at the given [alpha]. */ - private fun drawImage(canvas: Canvas, bmp: Bitmap?, alpha: Float) { - if (bmp == null || alpha <= 0.001f) return - - val vw = width.toFloat() - val vh = height.toFloat() - val bw = bmp.width.toFloat() - val bh = bmp.height.toFloat() - if (vw <= 0f || vh <= 0f || bw <= 0f || bh <= 0f) return - - val scale = min(vw / bw, vh / bh) - val dw = bw * scale - val dh = bh * scale - val left = (vw - dw) / 2f - val top = (vh - dh) / 2f - dstRect.set(left, top, left + dw, top + dh) - - drawPaint.alpha = (alpha * 255f).toInt().coerceIn(0, 255) - drawPaint.colorFilter = tintFilter - canvas.drawBitmap(bmp, null, dstRect, drawPaint) + renderer.draw(canvas, width, height, fromBitmap, toBitmap, progress, blurRadius * density, tintColor) } // MARK: - Image loading @@ -323,24 +247,10 @@ class MorphViewView : View { override fun onDetachedFromWindow() { super.onDetachedFromWindow() animator?.cancel() + renderer.release() } companion object { private val MORPH_INTERPOLATOR = PathInterpolator(0.42f, 0f, 0.58f, 1f) - - private const val ALPHA_THRESHOLD_SHADER = """ - uniform shader composable; - uniform float threshold; - - half4 main(float2 coord) { - half4 color = composable.eval(coord); - if (color.a < 0.001) { - return half4(0.0); - } - half alpha = smoothstep(threshold - 0.05, threshold + 0.05, color.a); - half3 straight = color.rgb / color.a; - return half4(straight * alpha, alpha); - } - """ } } diff --git a/android/src/main/java/com/morphview/internal/HardwareBufferMorphRenderer.kt b/android/src/main/java/com/morphview/internal/HardwareBufferMorphRenderer.kt new file mode 100644 index 0000000..c66ea59 --- /dev/null +++ b/android/src/main/java/com/morphview/internal/HardwareBufferMorphRenderer.kt @@ -0,0 +1,150 @@ +package com.morphview.internal + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.RectF +import android.os.Build +import android.os.Handler +import android.os.HandlerThread +import androidx.annotation.RequiresApi +import com.morphview.internal.gl.SwapChain +import java.util.ArrayDeque +import java.util.concurrent.atomic.AtomicBoolean + +/** + * API 29–32 backend: renders the morph in OpenGL on a dedicated [HandlerThread] and presents the + * result as a hardware [Bitmap] via [Bitmap.wrapHardwareBuffer] — the "present via hardware Bitmap, + * no SurfaceView" path. + * + * Async is hidden behind the synchronous [draw] contract: each draw submits the latest state to the + * GL thread (coalesced to newest-only) and blits the latest ready frame. The result is stale by at + * most one frame — invisible mid-morph, where everything is already blurred — and the very first + * frames fall back to a plain crossfade until GL catches up. + */ +@RequiresApi(Build.VERSION_CODES.Q) +internal class HardwareBufferMorphRenderer( + private val onFrameReady: () -> Unit, +) : MorphRenderer { + + private val fallback = FitCenterPainter() + private val blitPaint = Paint(Paint.FILTER_BITMAP_FLAG) + private val blitDst = RectF() + + private var glThread: HandlerThread? = null + private var glHandler: Handler? = null + + // GL-thread-confined. + private var pipeline: MorphGlPipeline? = null + private val leases = ArrayDeque() + + // Latest requested frame: UI thread writes, GL thread reads. + @Volatile + private var pending: RenderRequest? = null + private var lastSubmitted: RenderRequest? = null // UI-thread only + private val renderScheduled = AtomicBoolean(false) + + // Newest GL output: GL thread writes, UI thread blits. + @Volatile + private var displayBitmap: Bitmap? = null + + override fun draw( + canvas: Canvas, + width: Int, + height: Int, + from: Bitmap?, + to: Bitmap?, + progress: Float, + blurRadiusPx: Float, + tintColor: Int?, + ) { + if (width <= 0 || height <= 0) return + ensureThread() + + // Submit only when the frame changed — otherwise each produced frame's invalidate would + // re-trigger an identical render, spinning the GL thread at rest. + val req = RenderRequest(width, height, from, to, progress, blurRadiusPx, tintColor) + if (req != lastSubmitted) { + lastSubmitted = req + pending = req + scheduleRender() + } + + val bmp = displayBitmap + if (bmp != null && !bmp.isRecycled) { + blitDst.set(0f, 0f, width.toFloat(), height.toFloat()) + canvas.drawBitmap(bmp, null, blitDst, blitPaint) + } else { + // No GL frame yet — crossfade so the view isn't blank on the first frames. + fallback.draw(canvas, from, width, height, 1f - progress, tintColor) + fallback.draw(canvas, to, width, height, progress, tintColor) + } + } + + override fun release() { + val thread = glThread ?: return // never started, or already released — nothing to reset + displayBitmap = null + glHandler?.post { + pipeline?.release() + pipeline = null + while (leases.isNotEmpty()) leases.removeFirst().close() + } + glThread = null + glHandler = null + // Drain the GL thread before returning so a re-attach can't race this teardown for pipeline/leases. + thread.quitSafely() + thread.join() + // Reset submit state, or a re-attach at the same size/progress never re-renders. + lastSubmitted = null + pending = null + renderScheduled.set(false) + } + + private fun ensureThread() { + if (glThread != null) return + val thread = HandlerThread("MorphViewGL").apply { start() } + glThread = thread + glHandler = Handler(thread.looper) + } + + // Collapse a burst of draws into a single render of the newest state. + private fun scheduleRender() { + if (renderScheduled.compareAndSet(false, true)) { + glHandler?.post { + renderScheduled.set(false) + pending?.let { renderFrame(it) } + } + } + } + + // GL thread. + private fun renderFrame(req: RenderRequest) { + val lease = produceLease(req) ?: return + leases.addLast(lease) + // Keep newest + one previous (HWUI may still be compositing last frame); free older buffers. + while (leases.size > 2) leases.removeFirst().close() + displayBitmap = lease.bitmap + onFrameReady() + } + + /** + * GL thread. Lazily creates the pipeline and renders [req]. Returns null if GL initialisation or + * the render fails (e.g. context loss) — the caller skips the frame and retries on the next draw. + */ + private fun produceLease(req: RenderRequest): SwapChain.Lease? = try { + val gl = pipeline ?: MorphGlPipeline().also { pipeline = it } + gl.render(req.width, req.height, req.from, req.to, req.progress, req.blurRadiusPx, req.tintColor) + } catch (e: Exception) { + null + } + + private data class RenderRequest( + val width: Int, + val height: Int, + val from: Bitmap?, + val to: Bitmap?, + val progress: Float, + val blurRadiusPx: Float, + val tintColor: Int?, + ) +} diff --git a/android/src/main/java/com/morphview/internal/MorphGlPipeline.kt b/android/src/main/java/com/morphview/internal/MorphGlPipeline.kt new file mode 100644 index 0000000..f2a8de4 --- /dev/null +++ b/android/src/main/java/com/morphview/internal/MorphGlPipeline.kt @@ -0,0 +1,129 @@ +package com.morphview.internal + +import android.graphics.Bitmap +import android.opengl.GLES20 +import android.opengl.GLES30 +import android.os.Build +import androidx.annotation.RequiresApi +import com.morphview.internal.gl.EglCore +import com.morphview.internal.gl.RenderTarget +import com.morphview.internal.gl.SourceTexture +import com.morphview.internal.gl.SwapChain +import com.morphview.internal.gl.createFullscreenQuad +import com.morphview.internal.gl.createQuadVao +import com.morphview.internal.gl.linkProgram + +/** + * Off-screen GL pipeline that renders the morph (composite -> separable Gaussian blur -> + * alpha-threshold) into a [HardwareBuffer][android.hardware.HardwareBuffer] and hands it back as a + * [SwapChain.Lease]. Every method must run on the single GL thread that owns the EGL context (see + * [HardwareBufferMorphRenderer]). + * + * Output egress is the zero-dependency path: render into the [SwapChain]'s ImageReader surface, then + * acquire it as a HardwareBuffer-backed [Bitmap]. The bitmap composites through the normal HWUI path — + * no SurfaceView, no `androidx.graphics` dependency, no `glReadPixels` round-trip. + * + * Blur matches `RenderEffect.createBlurEffect` 1:1 — Skia maps its blur radius to a Gaussian + * `sigma = radius*0.57735 + 0.5`; both passes run at full resolution. Sources upload premultiplied + * (Android default) so every pass works in premultiplied space; the threshold shader flips Y for the + * GL-vs-HardwareBuffer origin difference. + */ +@RequiresApi(Build.VERSION_CODES.Q) +internal class MorphGlPipeline { + + private val eglCore = EglCore() + private val swapChain = SwapChain(eglCore) + + // Ping-pong blur targets: composite + final blur output, and the horizontal-pass scratch. + private val compositeTarget = RenderTarget() + private val blurIntermediate = RenderTarget() + + // Source textures for from/to, re-uploaded only when their bitmap identity changes. + private val srcFrom = SourceTexture() + private val srcTo = SourceTexture() + + private var programs: Programs? = null + private var quadVao = 0 + + /** + * Renders one morph frame synchronously and returns it as a [SwapChain.Lease], or null if any GL + * step fails (the caller skips the frame and retries next draw). The [SwapChain], [RenderTarget]s + * and programs bring themselves up lazily and resize themselves; steady-state calls only re-upload + * changed source bitmaps and run the four passes. + * + * Passes: (1) composite from/to FIT_CENTER with premultiplied src-over into compositeTarget; (2,3) + * separable Gaussian H into blurIntermediate then V back into compositeTarget; (4) alpha-threshold + * to the window surface. A [GLES20.glFinish] before read-back makes the GPU writes visible to HWUI. + */ + fun render( + viewWidth: Int, + viewHeight: Int, + from: Bitmap?, + to: Bitmap?, + progress: Float, + blurRadiusPx: Float, + tintColor: Int?, + ): SwapChain.Lease? { + swapChain.ensureSize(viewWidth, viewHeight) + if (!swapChain.makeCurrent()) return null + ensureGlObjects() + compositeTarget.ensureSize(viewWidth, viewHeight) + blurIntermediate.ensureSize(viewWidth, viewHeight) + srcFrom.uploadIfChanged(from) + srcTo.uploadIfChanged(to) + GLES30.glBindVertexArray(quadVao) // static quad geometry for every pass; bound once per frame + + val gl = programs ?: return null + val radius = morphBlurRadiusPx(progress = progress, blurRadiusPx = blurRadiusPx).coerceAtLeast(0.5f) + + // Pass 1 — composite from/to (crossfade + tint) into compositeTarget, premultiplied src-over. + compositeTarget.bind() + GLES20.glClearColor(0f, 0f, 0f, 0f) + GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT) + GLES20.glEnable(GLES20.GL_BLEND) + GLES20.glBlendFunc(GLES20.GL_ONE, GLES20.GL_ONE_MINUS_SRC_ALPHA) + if (from != null) gl.composite.draw(srcFrom.id(), from, 1f - progress, tintColor, viewWidth, viewHeight) + if (to != null) gl.composite.draw(srcTo.id(), to, progress, tintColor, viewWidth, viewHeight) + GLES20.glDisable(GLES20.GL_BLEND) + + // Pass 2/3 — separable Gaussian: horizontal into blurIntermediate, then vertical back into compositeTarget. + blurIntermediate.bind() + gl.blur.draw(compositeTarget.texture(), viewWidth, viewHeight, BlurAxis.Horizontal, radius) + compositeTarget.bind() + gl.blur.draw(blurIntermediate.texture(), viewWidth, viewHeight, BlurAxis.Vertical, radius) + + // Pass 4 — alpha threshold, compositeTarget -> window surface (no clear: the quad covers every pixel). + GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, 0) + GLES20.glViewport(0, 0, viewWidth, viewHeight) + gl.threshold.draw(compositeTarget.texture(), 0.5f) + + swapChain.swapBuffers() + GLES20.glFinish() // ensure GPU writes complete before HWUI samples the buffer (off the UI thread) + + return swapChain.acquireLease() + } + + fun release() { + swapChain.release() + eglCore.release() + } + + private fun ensureGlObjects() { + if (programs != null) return + // Resolve every program once; the per-frame passes never call glGet*Location again. + val built = Programs( + composite = CompositeProgram(linkProgram(vertexSrc = MorphShaders.VERTEX, fragmentSrc = MorphShaders.COMPOSITE_FRAGMENT)), + blur = BlurProgram(linkProgram(vertexSrc = MorphShaders.VERTEX, fragmentSrc = MorphShaders.BLUR_FRAGMENT)), + threshold = ThresholdProgram(linkProgram(vertexSrc = MorphShaders.VERTEX, fragmentSrc = MorphShaders.THRESHOLD_FRAGMENT)), + ) + quadVao = createQuadVao(createFullscreenQuad()) + programs = built // publish last, so readiness == (programs != null) + } + + /** The three linked programs, built together in [ensureGlObjects]; non-null == GL is ready. */ + private class Programs( + val composite: CompositeProgram, + val blur: BlurProgram, + val threshold: ThresholdProgram, + ) +} diff --git a/android/src/main/java/com/morphview/internal/MorphPrograms.kt b/android/src/main/java/com/morphview/internal/MorphPrograms.kt new file mode 100644 index 0000000..9313594 --- /dev/null +++ b/android/src/main/java/com/morphview/internal/MorphPrograms.kt @@ -0,0 +1,107 @@ +package com.morphview.internal + +import android.graphics.Bitmap +import android.graphics.Color +import android.opengl.GLES20 +import kotlin.math.min + +/** + * The linked GL programs for the morph passes. Each resolves its uniform locations once at link, then + * its `draw` sets the per-pass uniforms and issues the fullscreen-quad draw into the bound framebuffer. + * Geometry comes from the VAO bound once per frame by the pipeline. Used on the GL thread. + */ +internal open class QuadProgram(val id: Int) { + + private val uScale = GLES20.glGetUniformLocation(id, "uScale") + private val uOffset = GLES20.glGetUniformLocation(id, "uOffset") + private val uTex = GLES20.glGetUniformLocation(id, "uTex") + + protected fun use() = GLES20.glUseProgram(id) + + protected fun setQuadTransform(scaleX: Float, scaleY: Float, offsetX: Float, offsetY: Float) { + GLES20.glUniform2f(uScale, scaleX, scaleY) + GLES20.glUniform2f(uOffset, offsetX, offsetY) + } + + protected fun bindTexture(texture: Int) { + GLES20.glActiveTexture(GLES20.GL_TEXTURE0) + GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, texture) + GLES20.glUniform1i(uTex, 0) + } + + protected fun drawQuad() = GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4) +} + +internal class CompositeProgram(id: Int) : QuadProgram(id) { + + private val uAlpha = GLES20.glGetUniformLocation(id, "uAlpha") + private val uUseTint = GLES20.glGetUniformLocation(id, "uUseTint") + private val uTint = GLES20.glGetUniformLocation(id, "uTint") + + // Draws bmp FIT_CENTER into the targetW x targetH framebuffer at the given alpha, premultiplied. + fun draw(texture: Int, bmp: Bitmap, alpha: Float, tintColor: Int?, targetW: Int, targetH: Int) { + val bw = bmp.width.toFloat() + val bh = bmp.height.toFloat() + if (bw <= 0f || bh <= 0f) return + use() + // FIT_CENTER rect -> NDC scale/offset (Y flipped: screen-top maps to NDC +1). + val scale = min(targetW / bw, targetH / bh) + val dw = bw * scale + val dh = bh * scale + val cx = (targetW - dw) / 2f + dw / 2f + val cy = (targetH - dh) / 2f + dh / 2f + setQuadTransform(dw / targetW, dh / targetH, (cx / targetW) * 2f - 1f, 1f - (cy / targetH) * 2f) + bindTexture(texture) + GLES20.glUniform1f(uAlpha, alpha) + if (tintColor != null) { + GLES20.glUniform1i(uUseTint, 1) + GLES20.glUniform3f( + uTint, + Color.red(tintColor) / 255f, + Color.green(tintColor) / 255f, + Color.blue(tintColor) / 255f, + ) + } else { + GLES20.glUniform1i(uUseTint, 0) + } + drawQuad() + } +} + +internal class BlurProgram(id: Int) : QuadProgram(id) { + + private val uTexel = GLES20.glGetUniformLocation(id, "uTexel") + private val uDir = GLES20.glGetUniformLocation(id, "uDir") + private val uRadius = GLES20.glGetUniformLocation(id, "uRadius") + + // Blurs texture along axis into the bound width x height framebuffer. + fun draw(texture: Int, width: Int, height: Int, axis: BlurAxis, radius: Float) { + use() + setQuadTransform(1f, 1f, 0f, 0f) + bindTexture(texture) + GLES20.glUniform2f(uTexel, 1f / width, 1f / height) + GLES20.glUniform2f(uDir, axis.dx, axis.dy) + GLES20.glUniform1f(uRadius, radius) + drawQuad() + } +} + +internal class ThresholdProgram(id: Int) : QuadProgram(id) { + + private val uThreshold = GLES20.glGetUniformLocation(id, "uThreshold") + + // Alpha-thresholds texture into the bound framebuffer (the shader flips Y for the buffer origin). + fun draw(texture: Int, threshold: Float) { + use() + setQuadTransform(1f, 1f, 0f, 0f) + bindTexture(texture) + GLES20.glUniform1f(uThreshold, threshold) + drawQuad() + } +} + +/** Direction of one separable-blur pass, as the `uDir` step applied to the texel size. */ +internal enum class BlurAxis(val dx: Float, val dy: Float) { + Horizontal(1f, 0f), + Vertical(0f, 1f), +} diff --git a/android/src/main/java/com/morphview/internal/MorphRenderer.kt b/android/src/main/java/com/morphview/internal/MorphRenderer.kt new file mode 100644 index 0000000..840acd2 --- /dev/null +++ b/android/src/main/java/com/morphview/internal/MorphRenderer.kt @@ -0,0 +1,203 @@ +package com.morphview.internal + +import android.app.ActivityManager +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.PorterDuff +import android.graphics.PorterDuffColorFilter +import android.graphics.RectF +import android.graphics.RenderEffect +import android.graphics.RenderNode +import android.graphics.RuntimeShader +import android.graphics.Shader +import android.os.Build +import androidx.annotation.RequiresApi +import kotlin.math.PI +import kotlin.math.min +import kotlin.math.sin +import kotlin.math.sqrt + +/** + * Backend that draws the gooey metaball morph (crossfade → blur → alpha-threshold) for a single + * [com.morphview.MorphViewView]. One interface, implementation chosen by API level in [create]. + * + * The effect needs a Gaussian blur followed by an alpha threshold, which gate the platform path to + * API 33 ([RuntimeShader]). Below that, [HardwareBufferMorphRenderer] reproduces it in OpenGL and + * presents the result as a hardware [Bitmap]; below API 29 we fall back to a plain crossfade. + */ +internal interface MorphRenderer { + + /** + * Draws [from] and [to] crossfaded by [progress] (0 = `from`, 1 = `to`), scaled FIT_CENTER into + * [width]×[height], with the morph blur+threshold effect. [blurRadiusPx] is the configured radius + * in pixels at the morph midpoint; the backend applies the morph curve. [tintColor] is an optional + * SRC_IN tint. + */ + fun draw( + canvas: Canvas, + width: Int, + height: Int, + from: Bitmap?, + to: Bitmap?, + progress: Float, + blurRadiusPx: Float, + tintColor: Int?, + ) + + fun release() {} + + companion object { + /** [onFrameReady] is invoked (possibly off the main thread) when an async backend has a frame. */ + fun create(context: Context, onFrameReady: () -> Unit): MorphRenderer { + val sdk = Build.VERSION.SDK_INT + return when { + sdk >= Build.VERSION_CODES.TIRAMISU -> RenderEffectMorphRenderer() + sdk >= Build.VERSION_CODES.Q && supportsGlEs3(context) -> HardwareBufferMorphRenderer(onFrameReady) + else -> CrossfadeMorphRenderer() + } + } + + // The floor for the GL morph (VAOs, GLSL ES 3.00). + private const val GL_ES_3_0 = 0x30000 + + private fun supportsGlEs3(context: Context): Boolean { + val am = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + return am.deviceConfigurationInfo.reqGlEsVersion >= GL_ES_3_0 + } + } +} + +/** + * The morph blur strength over [progress]. `sqrt(sin)` has a vertical tangent at the ends, so any + * motion off rest is already heavily blurred — a transition interrupted near completion can't flash + * a crisp, un-fused image. + */ +internal fun morphBlurRadiusPx(progress: Float, blurRadiusPx: Float): Float = + // sin(progress*PI) dips a hair below 0 at progress == 1 (32-bit PI rounds slightly high), and + // sqrt(negative) = NaN — which then propagates through the blur to an empty frame. Clamp to >= 0. + sqrt(sin(progress * PI.toFloat()).coerceAtLeast(0f)) * blurRadiusPx + +/** + * Draws a bitmap scaled-to-fit (ImageView FIT_CENTER) at a given alpha with an optional SRC_IN tint. + * Extracted from `MorphViewView.drawImage` so every backend shares one crossfade primitive. + */ +internal class FitCenterPainter { + private val paint = Paint(Paint.FILTER_BITMAP_FLAG or Paint.ANTI_ALIAS_FLAG) + private val dst = RectF() + private var tintColor: Int? = null + private var tintFilter: PorterDuffColorFilter? = null + + fun draw(canvas: Canvas, bmp: Bitmap?, width: Int, height: Int, alpha: Float, tint: Int?) { + if (bmp == null || alpha <= 0.001f) return + val vw = width.toFloat() + val vh = height.toFloat() + val bw = bmp.width.toFloat() + val bh = bmp.height.toFloat() + if (vw <= 0f || vh <= 0f || bw <= 0f || bh <= 0f) return + + val scale = min(vw / bw, vh / bh) + val dw = bw * scale + val dh = bh * scale + val left = (vw - dw) / 2f + val top = (vh - dh) / 2f + dst.set(left, top, left + dw, top + dh) + + if (tint != tintColor) { + tintColor = tint + tintFilter = tint?.let { PorterDuffColorFilter(it, PorterDuff.Mode.SRC_IN) } + } + paint.alpha = (alpha * 255f).toInt().coerceIn(0, 255) + paint.colorFilter = tintFilter + canvas.drawBitmap(bmp, null, dst, paint) + } +} + +/** Pre-API-29 / software-canvas fallback: plain crossfade, no morph effect. */ +internal class CrossfadeMorphRenderer : MorphRenderer { + private val painter = FitCenterPainter() + + override fun draw( + canvas: Canvas, + width: Int, + height: Int, + from: Bitmap?, + to: Bitmap?, + progress: Float, + blurRadiusPx: Float, + tintColor: Int?, + ) { + painter.draw(canvas, from, width, height, 1f - progress, tintColor) + painter.draw(canvas, to, width, height, progress, tintColor) + } +} + +/** + * API 33+: both images are drawn into a single [RenderNode] that carries the blur + alpha-threshold + * [RenderEffect], and only that node is drawn — so the crossfade and the effect commit in the same + * frame and the raw image never flashes. + */ +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +internal class RenderEffectMorphRenderer : MorphRenderer { + private val painter = FitCenterPainter() + private val node = RenderNode("morph") + private val shader = RuntimeShader(ALPHA_THRESHOLD_SHADER) + + override fun draw( + canvas: Canvas, + width: Int, + height: Int, + from: Bitmap?, + to: Bitmap?, + progress: Float, + blurRadiusPx: Float, + tintColor: Int?, + ) { + if (!canvas.isHardwareAccelerated || width <= 0 || height <= 0) { + // Software canvas can't carry a RenderEffect — plain crossfade. + painter.draw(canvas, from, width, height, 1f - progress, tintColor) + painter.draw(canvas, to, width, height, progress, tintColor) + return + } + + shader.setFloatUniform("threshold", 0.5f) + val thresholdEffect = RenderEffect.createRuntimeShaderEffect(shader, "composable") + // 0.5px floor keeps the same effect chain at rest instead of toggling it on/off at 0 and 1. + val effectiveRadius = morphBlurRadiusPx(progress, blurRadiusPx).coerceAtLeast(0.5f) + val blurEffect = RenderEffect.createBlurEffect(effectiveRadius, effectiveRadius, Shader.TileMode.DECAL) + // createChainEffect(outer, inner) applies inner first: blur, then threshold. + node.setRenderEffect(RenderEffect.createChainEffect(thresholdEffect, blurEffect)) + + node.setPosition(0, 0, width, height) + val recordingCanvas = node.beginRecording() + try { + painter.draw(recordingCanvas, from, width, height, 1f - progress, tintColor) + painter.draw(recordingCanvas, to, width, height, progress, tintColor) + } finally { + node.endRecording() + } + canvas.drawRenderNode(node) + } + + override fun release() { + node.discardDisplayList() + } + + companion object { + private const val ALPHA_THRESHOLD_SHADER = """ + uniform shader composable; + uniform float threshold; + + half4 main(float2 coord) { + half4 color = composable.eval(coord); + if (color.a < 0.001) { + return half4(0.0); + } + half alpha = smoothstep(threshold - 0.05, threshold + 0.05, color.a); + half3 straight = color.rgb / color.a; + return half4(straight * alpha, alpha); + } + """ + } +} diff --git a/android/src/main/java/com/morphview/internal/MorphShaders.kt b/android/src/main/java/com/morphview/internal/MorphShaders.kt new file mode 100644 index 0000000..87d825b --- /dev/null +++ b/android/src/main/java/com/morphview/internal/MorphShaders.kt @@ -0,0 +1,101 @@ +package com.morphview.internal + +/** + * GLSL ES 3.00 sources for the morph effect, in pipeline order. All three programs share [VERTEX]; + * the fragment stages run as: [COMPOSITE_FRAGMENT] (crossfade `from`/`to`) -> [BLUR_FRAGMENT] + * (separable Gaussian, run twice) -> [THRESHOLD_FRAGMENT] (alpha cut that fuses the blobs). That + * crossfade -> blur -> threshold order is what produces the gooey metaball merge; see + * [MorphGlPipeline] for how they are wired into FBOs. + * + * Everything works in premultiplied alpha (Android bitmaps upload premultiplied). + */ +internal object MorphShaders { + + /** Positions a unit quad via uScale/uOffset; uv.y is flipped so a source's top row maps upright. */ + const val VERTEX = """#version 300 es + layout(location = 0) in vec2 aPos; // must match POS_LOCATION in GlUtils + layout(location = 1) in vec2 aUv; // must match UV_LOCATION in GlUtils + uniform vec2 uScale; + uniform vec2 uOffset; + out vec2 vUv; + void main() { + vUv = aUv; + gl_Position = vec4(aPos * uScale + uOffset, 0.0, 1.0); + } + """ + + /** Draws one source image at uAlpha with an optional SRC_IN tint, premultiplied. */ + const val COMPOSITE_FRAGMENT = """#version 300 es + precision highp float; + uniform sampler2D uTex; + uniform float uAlpha; + uniform int uUseTint; + uniform vec3 uTint; + in vec2 vUv; + out vec4 frag; + void main() { + vec4 c = texture(uTex, vUv); // premultiplied (Android bitmaps upload premultiplied) + float a = c.a * uAlpha; + vec3 pm = (uUseTint == 1) ? (uTint * a) : (c.rgb * uAlpha); + frag = vec4(pm, a); + } + """ + + /** One axis of a separable Gaussian; sigma matches RenderEffect/Skia exactly. */ + const val BLUR_FRAGMENT = """#version 300 es + precision highp float; + uniform sampler2D uTex; + uniform vec2 uTexel; + uniform vec2 uDir; + uniform float uRadius; + in vec2 vUv; + out vec4 frag; + const int TAPS = 16; + void main() { + // sigma is Skia's SkBlurMask::ConvertRadiusToSigma verbatim — kBLUR_SIGMA_SCALE = 0.57735f + // (~1/sqrt(3)), sigma = radius*0.57735 + 0.5 — the same radius->sigma mapping that + // RenderEffect.createBlurEffect feeds Skia, so this GL blur is 1:1 with the platform path. + // Spread 2*TAPS+1 = 33 samples across +/-3 sigma (where ~99.7% of the Gaussian weight lies). + float sigma = uRadius * 0.57735 + 0.5; + float twoSigma2 = 2.0 * sigma * sigma; + float stepPx = (3.0 * sigma) / float(TAPS); + vec4 sum = vec4(0.0); + float wsum = 0.0; + for (int i = -TAPS; i <= TAPS; i++) { + float offsetPx = float(i) * stepPx; + float w = exp(-(offsetPx * offsetPx) / twoSigma2); + vec2 uv = vUv + uDir * uTexel * offsetPx; + // DECAL tile mode: out-of-bounds samples are transparent (0), but still normalised by the + // full weight, so edges fade to transparent instead of clamping. + vec2 inb = step(vec2(0.0), uv) * step(uv, vec2(1.0)); + sum += texture(uTex, uv) * (w * inb.x * inb.y); + wsum += w; + } + frag = sum / max(wsum, 1e-4); + } + """ + + /** + * Alpha threshold that fuses overlapping blurred blobs into the metaball silhouette. GLSL port of + * the AGSL `ALPHA_THRESHOLD_SHADER` in [RenderEffectMorphRenderer], kept equivalent so the 29–32 + * GL path matches the 33+ RuntimeShader path. + */ + const val THRESHOLD_FRAGMENT = """#version 300 es + precision highp float; + uniform sampler2D uTex; + uniform float uThreshold; + in vec2 vUv; + out vec4 frag; + void main() { + // Flip Y for the GL bottom-left vs HardwareBuffer/Bitmap top-left origin difference. + vec4 c = texture(uTex, vec2(vUv.x, 1.0 - vUv.y)); // premultiplied blurred composite + if (c.a < 0.001) { + frag = vec4(0.0); + return; + } + float na = smoothstep(uThreshold - 0.05, uThreshold + 0.05, c.a); + vec3 straight = c.rgb / c.a; + frag = vec4(straight * na, na); // premultiplied output + } + """ +} diff --git a/android/src/main/java/com/morphview/internal/gl/EglCore.kt b/android/src/main/java/com/morphview/internal/gl/EglCore.kt new file mode 100644 index 0000000..a9ddf26 --- /dev/null +++ b/android/src/main/java/com/morphview/internal/gl/EglCore.kt @@ -0,0 +1,82 @@ +package com.morphview.internal.gl + +import android.opengl.EGL14 +import android.opengl.EGLConfig +import android.opengl.EGLContext +import android.opengl.EGLDisplay +import android.opengl.EGLExt +import android.opengl.EGLSurface +import android.view.Surface + +/** + * Minimal EGL bring-up for off-screen rendering: a default-display, RGBA8888, OpenGL ES 3.0 context. + * ES 3.0 is required, not optional — the morph pipeline uses VAOs, `#version 300 es` shaders, and + * explicit `layout(location = ...)` attributes. The config is requested with the ES3 renderable bit + * and the context with client version 3 so the requirement is explicit, not driver-leniency. + * + * Owns the display/config/context; window surfaces are created per render target and handed back to + * the caller, which owns their lifetime. Construct and use on a single GL thread. + */ +internal class EglCore { + + private var display: EGLDisplay = EGL14.EGL_NO_DISPLAY + private var context: EGLContext = EGL14.EGL_NO_CONTEXT + private var config: EGLConfig? = null + + init { + display = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY) + check(display != EGL14.EGL_NO_DISPLAY) { "eglGetDisplay returned no display" } + val version = IntArray(2) + check(EGL14.eglInitialize(display, version, 0, version, 1)) { "eglInitialize failed" } + val configAttr = intArrayOf( + // Require an ES 3.0-capable config — the pipeline needs VAOs and GLSL ES 3.00. + EGL14.EGL_RENDERABLE_TYPE, EGLExt.EGL_OPENGL_ES3_BIT_KHR, + EGL14.EGL_SURFACE_TYPE, EGL14.EGL_WINDOW_BIT, + EGL14.EGL_RED_SIZE, 8, + EGL14.EGL_GREEN_SIZE, 8, + EGL14.EGL_BLUE_SIZE, 8, + EGL14.EGL_ALPHA_SIZE, 8, + EGL14.EGL_NONE, + ) + val configs = arrayOfNulls(1) + val numConfig = IntArray(1) + check( + EGL14.eglChooseConfig(display, configAttr, 0, configs, 0, 1, numConfig, 0) && + numConfig[0] > 0 && configs[0] != null, + ) { "no ES3-capable EGL config" } + config = configs[0] + // EGL_CONTEXT_CLIENT_VERSION = 3 creates an OpenGL ES 3.0 context. + val contextAttr = intArrayOf(EGL14.EGL_CONTEXT_CLIENT_VERSION, 3, EGL14.EGL_NONE) + context = EGL14.eglCreateContext(display, config, EGL14.EGL_NO_CONTEXT, contextAttr, 0) + check(context != EGL14.EGL_NO_CONTEXT) { "eglCreateContext failed" } + } + + fun createWindowSurface(surface: Surface): EGLSurface = + EGL14.eglCreateWindowSurface(display, config, surface, intArrayOf(EGL14.EGL_NONE), 0) + + /** Binds [eglSurface] for both draw and read; false if the context could not be made current. */ + fun makeCurrent(eglSurface: EGLSurface): Boolean = + EGL14.eglMakeCurrent(display, eglSurface, eglSurface, context) + + fun makeNothingCurrent(): Boolean = + EGL14.eglMakeCurrent(display, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_CONTEXT) + + fun swapBuffers(eglSurface: EGLSurface): Boolean = + EGL14.eglSwapBuffers(display, eglSurface) + + fun destroySurface(eglSurface: EGLSurface) { + if (eglSurface != EGL14.EGL_NO_SURFACE) EGL14.eglDestroySurface(display, eglSurface) + } + + /** Destroys the context and terminates the display. The caller must destroy its surfaces first. */ + fun release() { + if (display != EGL14.EGL_NO_DISPLAY) { + makeNothingCurrent() + if (context != EGL14.EGL_NO_CONTEXT) EGL14.eglDestroyContext(display, context) + EGL14.eglTerminate(display) + } + display = EGL14.EGL_NO_DISPLAY + context = EGL14.EGL_NO_CONTEXT + config = null + } +} diff --git a/android/src/main/java/com/morphview/internal/gl/GlUtils.kt b/android/src/main/java/com/morphview/internal/gl/GlUtils.kt new file mode 100644 index 0000000..dc41e06 --- /dev/null +++ b/android/src/main/java/com/morphview/internal/gl/GlUtils.kt @@ -0,0 +1,95 @@ +package com.morphview.internal.gl + +import android.opengl.GLES20 +import android.opengl.GLES30 +import java.nio.ByteBuffer +import java.nio.ByteOrder + +// Stateless GL helpers shared by the morph pipeline: shader/program building and fixed setup. + +// Vertex layout of the fullscreen quad. The attribute locations must match the `layout(location=...)` +// pins in the pipeline's vertex shader so one VAO is valid for every program. +private const val POS_LOCATION = 0 +private const val UV_LOCATION = 1 +private const val STRIDE_BYTES = 16 // 4 floats * 4 bytes +private const val UV_OFFSET_BYTES = 8 + +/** Compiles and links a program from [vertexSrc] and [fragmentSrc]; throws on compile/link failure. */ +internal fun linkProgram(vertexSrc: String, fragmentSrc: String): Int { + val vs = compileShader(GLES20.GL_VERTEX_SHADER, vertexSrc) + val fs = compileShader(GLES20.GL_FRAGMENT_SHADER, fragmentSrc) + val program = GLES20.glCreateProgram() + GLES20.glAttachShader(program, vs) + GLES20.glAttachShader(program, fs) + GLES20.glLinkProgram(program) + val status = IntArray(1) + GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, status, 0) + if (status[0] == 0) { + val log = GLES20.glGetProgramInfoLog(program) + GLES20.glDeleteProgram(program) + throw RuntimeException("GlUtils: program link failed: $log") + } + GLES20.glDeleteShader(vs) + GLES20.glDeleteShader(fs) + return program +} + +private fun compileShader(type: Int, src: String): Int { + val shader = GLES20.glCreateShader(type) + GLES20.glShaderSource(shader, src) + GLES20.glCompileShader(shader) + val status = IntArray(1) + GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, status, 0) + if (status[0] == 0) { + val log = GLES20.glGetShaderInfoLog(shader) + GLES20.glDeleteShader(shader) + throw RuntimeException("GlUtils: shader compile failed: $log") + } + return shader +} + +/** + * Static [-1,1] fullscreen quad VBO, 4 interleaved floats per vertex (pos.xy, uv.xy), 4 vertices. + * uv.y is flipped so a source bitmap's top row maps to NDC +1 (upright in the FBO). + */ +internal fun createFullscreenQuad(): Int { + val verts = floatArrayOf( + -1f, -1f, 0f, 1f, + 1f, -1f, 1f, 1f, + -1f, 1f, 0f, 0f, + 1f, 1f, 1f, 0f, + ) + val buffer = ByteBuffer.allocateDirect(verts.size * 4).order(ByteOrder.nativeOrder()).asFloatBuffer() + buffer.put(verts).position(0) + val ids = IntArray(1) + GLES20.glGenBuffers(1, ids, 0) + GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, ids[0]) + GLES20.glBufferData(GLES20.GL_ARRAY_BUFFER, verts.size * 4, buffer, GLES20.GL_STATIC_DRAW) + return ids[0] +} + +/** + * Creates a VAO that records [vbo] bound with the fullscreen-quad attribute layout (pos at + * [POS_LOCATION], uv at [UV_LOCATION]). Bind it once per frame instead of re-specifying the vertex + * attributes on every draw. Leaves no VAO bound. + */ +internal fun createQuadVao(vbo: Int): Int { + val ids = IntArray(1) + GLES30.glGenVertexArrays(1, ids, 0) + GLES30.glBindVertexArray(ids[0]) + GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, vbo) + GLES20.glEnableVertexAttribArray(POS_LOCATION) + GLES20.glVertexAttribPointer(POS_LOCATION, 2, GLES20.GL_FLOAT, false, STRIDE_BYTES, 0) + GLES20.glEnableVertexAttribArray(UV_LOCATION) + GLES20.glVertexAttribPointer(UV_LOCATION, 2, GLES20.GL_FLOAT, false, STRIDE_BYTES, UV_OFFSET_BYTES) + GLES30.glBindVertexArray(0) + return ids[0] +} + +/** CLAMP_TO_EDGE wrap + bilinear filtering on the currently bound GL_TEXTURE_2D. */ +internal fun setDefaultTextureParams() { + GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE) + GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE) + GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR) + GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR) +} diff --git a/android/src/main/java/com/morphview/internal/gl/RenderTarget.kt b/android/src/main/java/com/morphview/internal/gl/RenderTarget.kt new file mode 100644 index 0000000..f98850e --- /dev/null +++ b/android/src/main/java/com/morphview/internal/gl/RenderTarget.kt @@ -0,0 +1,56 @@ +package com.morphview.internal.gl + +import android.opengl.GLES20 + +/** + * An off-screen color texture and its framebuffer, used as a ping-pong blur target. Reallocated only + * when the size changes. Must be created and used on the GL thread. + */ +internal class RenderTarget { + + private var fbo = 0 + private var texture = 0 + private var width = 0 + private var height = 0 + + // (Re)allocates the texture + FBO when the size changes; a no-op otherwise. + fun ensureSize(newWidth: Int, newHeight: Int) { + if (texture != 0 && newWidth == width && newHeight == height) return + release() + width = newWidth + height = newHeight + val tex = IntArray(1) + val fb = IntArray(1) + GLES20.glGenTextures(1, tex, 0) + GLES20.glGenFramebuffers(1, fb, 0) + texture = tex[0] + fbo = fb[0] + GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, texture) + GLES20.glTexImage2D( + GLES20.GL_TEXTURE_2D, 0, GLES20.GL_RGBA, width, height, 0, + GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, null, + ) + setDefaultTextureParams() + GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, fbo) + GLES20.glFramebufferTexture2D( + GLES20.GL_FRAMEBUFFER, GLES20.GL_COLOR_ATTACHMENT0, GLES20.GL_TEXTURE_2D, texture, 0, + ) + GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, 0) + } + + // Binds this FBO as the draw target at its full size. + fun bind() { + GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, fbo) + GLES20.glViewport(0, 0, width, height) + } + + fun texture(): Int = texture + + private fun release() { + if (texture == 0) return + GLES20.glDeleteTextures(1, intArrayOf(texture), 0) + GLES20.glDeleteFramebuffers(1, intArrayOf(fbo), 0) + texture = 0 + fbo = 0 + } +} diff --git a/android/src/main/java/com/morphview/internal/gl/SourceTexture.kt b/android/src/main/java/com/morphview/internal/gl/SourceTexture.kt new file mode 100644 index 0000000..b603f1e --- /dev/null +++ b/android/src/main/java/com/morphview/internal/gl/SourceTexture.kt @@ -0,0 +1,37 @@ +package com.morphview.internal.gl + +import android.graphics.Bitmap +import android.opengl.GLES20 +import android.opengl.GLUtils + +/** + * A GL_TEXTURE_2D holding a source bitmap. [uploadIfChanged] re-uploads only when the bitmap identity + * changes, so steady-state frames skip the upload. Must be created and used on the GL thread. + */ +internal class SourceTexture { + + private var texture = 0 + private var lastBitmap: Bitmap? = null + + fun id(): Int = texture + + // Uploads bmp only when its identity changed since the last upload. + fun uploadIfChanged(bmp: Bitmap?) { + if (bmp === lastBitmap) return + if (bmp != null) { + ensureTexture() + GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, texture) + GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, bmp, 0) + } + lastBitmap = bmp + } + + private fun ensureTexture() { + if (texture != 0) return + val ids = IntArray(1) + GLES20.glGenTextures(1, ids, 0) + texture = ids[0] + GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, texture) + setDefaultTextureParams() + } +} diff --git a/android/src/main/java/com/morphview/internal/gl/SwapChain.kt b/android/src/main/java/com/morphview/internal/gl/SwapChain.kt new file mode 100644 index 0000000..7443b7c --- /dev/null +++ b/android/src/main/java/com/morphview/internal/gl/SwapChain.kt @@ -0,0 +1,99 @@ +package com.morphview.internal.gl + +import android.graphics.Bitmap +import android.graphics.ColorSpace +import android.graphics.ImageFormat +import android.hardware.HardwareBuffer +import android.media.Image +import android.media.ImageReader +import android.opengl.EGL14 +import android.opengl.EGLSurface + +/** + * Producer/consumer swap chain: an [ImageReader]-backed EGL window surface that the final pass renders + * into. [swapBuffers] queues a frame; [acquireLease] dequeues the latest as a [HardwareBuffer]-backed + * [Bitmap]. Owns the surface and reader, rebuilt on size change. Runs on the GL thread that owns [core]. + */ +internal class SwapChain(private val core: EglCore) { + + private var eglSurface: EGLSurface = EGL14.EGL_NO_SURFACE + private var imageReader: ImageReader? = null + private var width = 0 + private var height = 0 + + // Rebuilds the reader + output surface when the size changes; a no-op otherwise. + fun ensureSize(newWidth: Int, newHeight: Int) { + if (imageReader != null && newWidth == width && newHeight == height) return + if (eglSurface != EGL14.EGL_NO_SURFACE) { + core.makeNothingCurrent() + core.destroySurface(eglSurface) + eglSurface = EGL14.EGL_NO_SURFACE + } + imageReader?.close() + width = newWidth + height = newHeight + // PRIVATE lets gralloc pick a GPU-only layout; a wrap failure degrades to crossfade, never a crash. + val reader = ImageReader.newInstance( + newWidth, newHeight, ImageFormat.PRIVATE, MAX_IMAGES, + HardwareBuffer.USAGE_GPU_SAMPLED_IMAGE or HardwareBuffer.USAGE_GPU_COLOR_OUTPUT, + ) + imageReader = reader + eglSurface = core.createWindowSurface(reader.surface) + } + + fun makeCurrent(): Boolean = eglSurface != EGL14.EGL_NO_SURFACE && core.makeCurrent(eglSurface) + + fun swapBuffers() { + core.swapBuffers(eglSurface) + } + + // Wraps the latest rendered frame as a Lease, or null if none is ready or the buffer can't be wrapped. + fun acquireLease(): Lease? { + val image = imageReader?.acquireLatestImage() ?: return null + val buffer = image.hardwareBuffer + val bitmap = buffer?.let { + try { + Bitmap.wrapHardwareBuffer(it, SRGB) + } catch (e: Exception) { + null + } + } + if (buffer == null || bitmap == null) { + buffer?.close() + image.close() + return null + } + return Lease(image, buffer, bitmap) + } + + fun release() { + if (eglSurface != EGL14.EGL_NO_SURFACE) { + core.makeNothingCurrent() + core.destroySurface(eglSurface) + eglSurface = EGL14.EGL_NO_SURFACE + } + imageReader?.close() + imageReader = null + } + + /** A produced frame: [bitmap] is valid until [close], which recycles it and returns the buffer. */ + internal class Lease( + private val image: Image, + private val buffer: HardwareBuffer, + val bitmap: Bitmap, + ) { + fun close() { + // Recycle first to drop the buffer ref deterministically, then buffer, then image. + bitmap.recycle() + buffer.close() + image.close() + } + } + + private companion object { + // Buffer-queue depth: 2 retained (newest + previous) + 1 acquiring + 1 producer = 4. + private const val MAX_IMAGES = 4 + + private val SRGB = ColorSpace.get(ColorSpace.Named.SRGB) + } +}