Hand the frame back cleanly when a wipe ends

Reported as one or two frames of the OUTGOING photo after every transition, before
the incoming one appears. Three things conspired, all at the moment the wipe ends.

The overlay is a translucent SurfaceView with setZOrderOnTop(true) and a clear colour
of (0,0,0,0). onDrawFrame() cleared unconditionally, before testing whether there was
anything to draw. finish() left RENDERMODE_CONTINUOUSLY on and only POSTED the content
swap and the hide to the main thread, so the GL thread got at least one more frame in
first: it cleared to fully transparent while the overlay was still visible, showing
straight through to the ImageView — which still held the previous photo, because the
swap had not run yet. Not a black flash; a see-through one. The same clear ran on the
failed/hard-cut path.

So: clear only when a frame is actually going to be drawn over it, and stop the render
loop in finish() on the GL thread rather than waiting for the main thread to park the
overlay. What stays on screen is then the wipe's final frame, which is the destination
image, and it is correct to leave it there.

That still left the hand-off itself racing. Hiding a Z-ordered SurfaceView is a
SurfaceFlinger transaction that is not synchronised with the app drawing the newly
mounted bitmap, so the hide can land a vsync before the paint and uncover the old photo
anyway. The overlay now lingers briefly before parking. It costs nothing to look at —
both layers are showing the same picture — and it removes the race rather than
narrowing it.

Measured on the panel with 64x36 frame classification over screen recordings: the old
photo reappeared after 1 of 4 wipes before, 0 of 14 after the first two changes. That
sampling runs through a virtual display and cannot see every composited frame, so it
bounds the problem rather than proving absence — hence closing the last gap by
construction instead of by measurement.

The web player never had this: it calls mount() and then hides the canvas synchronously
in one task, so both land in the same paint.
This commit is contained in:
ScreenTinker 2026-07-28 22:23:23 -05:00
parent bcb1b5c7a3
commit d913397559

View file

@ -89,6 +89,14 @@ class TransitionGLView(context: Context) : GLSurfaceView(context) {
private val renderer = TxRenderer()
@Volatile private var incoming: Job? = null
private companion object {
// ~4 frames at 60Hz. How long the finished overlay lingers (showing the destination image)
// before it is hidden, so the SurfaceFlinger hide can't beat the app's draw of the same image
// underneath. Generous on purpose: a slow panel misses more vsyncs, and the wait costs
// nothing visually because both layers hold identical pixels.
const val PARK_DELAY_MS = 64L
}
init {
setEGLContextClientVersion(2)
setEGLConfigChooser(8, 8, 8, 8, 0, 0) // alpha channel -> translucent surface
@ -138,9 +146,14 @@ class TransitionGLView(context: Context) : GLSurfaceView(context) {
override fun onDrawFrame(gl: GL10?) {
incoming?.let { j -> incoming = null; active?.let { supersede(it) }; setup(j) } // pick up a new request
val j = active
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
// Clear ONLY when a frame is actually going to be drawn over it. Clearing
// unconditionally painted the overlay black on every frame where there was nothing to
// draw — and the overlay is still VISIBLE at that moment, because finish() only POSTS
// the hide to the main thread. That was the one-or-two frame blank seen after each
// wipe, and the same flash on the failed/hard-cut path.
if (j == null) return
if (j.failed) { finish(j); return }
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
if (j.startNs == 0L) j.startNs = System.nanoTime()
val p = ((System.nanoTime() - j.startNs).toFloat() / (j.durationMs * 1_000_000f)).coerceIn(0f, 1f)
draw(j, p)
@ -198,6 +211,12 @@ class TransitionGLView(context: Context) : GLSurfaceView(context) {
// the overlay IF no newer wipe is queued.
private fun finish(j: Job) {
active = null
// Stop the render loop HERE, on the GL thread, rather than waiting for parkIfIdle() to
// run on the main thread. The front buffer currently holds the wipe's last frame — which
// is the destination image — so leaving it untouched and visible is seamless while the
// real content is mounted underneath. Any frame drawn in that gap is a regression:
// there is nothing left to draw, so it can only be an empty one.
renderMode = RENDERMODE_WHEN_DIRTY
releaseGl()
swapOnMain(j)
parkIfIdle()
@ -217,7 +236,23 @@ class TransitionGLView(context: Context) : GLSurfaceView(context) {
// @Volatile and mutated only on the main thread (play), the same thread this posted block runs on,
// so a newer play() either already set incoming (we skip park) or runs after (it re-shows) —
// race-free, and never leaves a new wipe hidden.
private fun parkIfIdle() { post { if (incoming == null) { visibility = GONE; renderMode = RENDERMODE_WHEN_DIRTY } } }
private fun parkIfIdle() {
post {
if (incoming != null) return@post
// The overlay is currently showing the wipe's FINAL frame, which is the same picture
// that was just mounted underneath it. Hiding is therefore free to wait — and it must.
//
// This is a SurfaceView with setZOrderOnTop(true): its visibility change is applied by
// SurfaceFlinger in a transaction that is NOT synchronised with the app drawing the
// ImageView's new bitmap. Hiding in the same message-loop turn as the swap can land a
// vsync EARLIER than that draw, uncovering the previous photo for one frame. Holding
// the overlay (identical pixels) for a few frames removes the race outright; there is
// nothing to see during the wait because both layers show the same image.
postDelayed({
if (incoming == null) { visibility = GONE; renderMode = RENDERMODE_WHEN_DIRTY }
}, PARK_DELAY_MS)
}
}
private fun releaseGl() {
if (texFrom != 0) { GLES20.glDeleteTextures(1, intArrayOf(texFrom), 0); texFrom = 0 }