Fix CameraX GL surface: render-on-demand, crop-to-fill, lifecycle

- Switch GLSurfaceView from RENDERMODE_CONTINUOUSLY to RENDERMODE_WHEN_DIRTY
  with OnFrameAvailableListener, halving GPU work
- Add crop-to-fill aspect ratio correction so camera preview is not
  stretched on displays with non-16:9 aspect ratios
- Add LifecycleEventObserver to pause/resume GLSurfaceView with Activity
  lifecycle, preventing background rendering and GL context issues

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-03-05 13:58:17 +01:00
commit 7979ebd029
3 changed files with 107 additions and 18 deletions

View file

@ -59,6 +59,9 @@ class CameraManager(private val context: Context) {
private val _isFrontCamera = MutableStateFlow(false) private val _isFrontCamera = MutableStateFlow(false)
val isFrontCamera: StateFlow<Boolean> = _isFrontCamera.asStateFlow() val isFrontCamera: StateFlow<Boolean> = _isFrontCamera.asStateFlow()
private val _previewResolution = MutableStateFlow(Size(0, 0))
val previewResolution: StateFlow<Size> = _previewResolution.asStateFlow()
/** Background executor for image capture callbacks to avoid blocking the main thread. */ /** Background executor for image capture callbacks to avoid blocking the main thread. */
private val captureExecutor: ExecutorService = Executors.newSingleThreadExecutor() private val captureExecutor: ExecutorService = Executors.newSingleThreadExecutor()
@ -161,6 +164,7 @@ class CameraManager(private val context: Context) {
} }
surfaceSize = request.resolution surfaceSize = request.resolution
_previewResolution.value = surfaceSize
surfaceTexture.setDefaultBufferSize(surfaceSize.width, surfaceSize.height) surfaceTexture.setDefaultBufferSize(surfaceSize.width, surfaceSize.height)
val surface = Surface(surfaceTexture) val surface = Surface(surfaceTexture)

View file

@ -19,7 +19,8 @@ import javax.microedition.khronos.opengles.GL10
*/ */
class TiltShiftRenderer( class TiltShiftRenderer(
private val context: Context, private val context: Context,
private val onSurfaceTextureAvailable: (SurfaceTexture) -> Unit private val onSurfaceTextureAvailable: (SurfaceTexture) -> Unit,
private val onFrameAvailable: () -> Unit
) : GLSurfaceView.Renderer { ) : GLSurfaceView.Renderer {
private lateinit var shader: TiltShiftShader private lateinit var shader: TiltShiftShader
@ -39,13 +40,13 @@ class TiltShiftRenderer(
@Volatile @Volatile
private var isFrontCamera: Boolean = false private var isFrontCamera: Boolean = false
// Quad vertices (full screen) // Camera resolution for aspect ratio correction (set from UI thread)
private val vertices = floatArrayOf( @Volatile
-1f, -1f, // Bottom left private var cameraWidth: Int = 0
1f, -1f, // Bottom right @Volatile
-1f, 1f, // Top left private var cameraHeight: Int = 0
1f, 1f // Top right @Volatile
) private var vertexBufferDirty: Boolean = false
// Texture coordinates rotated 90° for portrait mode (back camera) // Texture coordinates rotated 90° for portrait mode (back camera)
// (Camera sensors are landscape-oriented, we rotate to portrait) // (Camera sensors are landscape-oriented, we rotate to portrait)
@ -75,11 +76,12 @@ class TiltShiftRenderer(
shader = TiltShiftShader(context) shader = TiltShiftShader(context)
shader.initialize() shader.initialize()
// Create vertex buffer // Allocate vertex buffer (8 floats = 4 vertices × 2 components)
vertexBuffer = ByteBuffer.allocateDirect(vertices.size * 4) vertexBuffer = ByteBuffer.allocateDirect(8 * 4)
.order(ByteOrder.nativeOrder()) .order(ByteOrder.nativeOrder())
.asFloatBuffer() .asFloatBuffer()
.put(vertices) // Fill with default full-screen quad; will be recomputed when camera resolution is known
vertexBuffer.put(floatArrayOf(-1f, -1f, 1f, -1f, -1f, 1f, 1f, 1f))
vertexBuffer.position(0) vertexBuffer.position(0)
// Create texture coordinate buffer // Create texture coordinate buffer
@ -102,6 +104,7 @@ class TiltShiftRenderer(
// Create SurfaceTexture for camera frames // Create SurfaceTexture for camera frames
surfaceTexture = SurfaceTexture(cameraTextureId).also { surfaceTexture = SurfaceTexture(cameraTextureId).also {
it.setOnFrameAvailableListener { onFrameAvailable() }
onSurfaceTextureAvailable(it) onSurfaceTextureAvailable(it)
} }
} }
@ -110,12 +113,19 @@ class TiltShiftRenderer(
GLES20.glViewport(0, 0, width, height) GLES20.glViewport(0, 0, width, height)
surfaceWidth = width surfaceWidth = width
surfaceHeight = height surfaceHeight = height
vertexBufferDirty = true
} }
override fun onDrawFrame(gl: GL10?) { override fun onDrawFrame(gl: GL10?) {
// Update texture with latest camera frame // Update texture with latest camera frame
surfaceTexture?.updateTexImage() surfaceTexture?.updateTexImage()
// Recompute vertex buffer for crop-to-fill when camera or surface dimensions change
if (vertexBufferDirty) {
recomputeVertices()
vertexBufferDirty = false
}
// Update texture coordinate buffer if camera changed // Update texture coordinate buffer if camera changed
if (updateTexCoordBuffer) { if (updateTexCoordBuffer) {
texCoordBuffer.clear() texCoordBuffer.clear()
@ -182,6 +192,53 @@ class TiltShiftRenderer(
@Volatile @Volatile
private var updateTexCoordBuffer = false private var updateTexCoordBuffer = false
/**
* Sets the camera preview resolution for crop-to-fill aspect ratio correction.
* Thread-safe vertex buffer is recomputed on the next frame.
*/
fun setCameraResolution(width: Int, height: Int) {
if (cameraWidth != width || cameraHeight != height) {
cameraWidth = width
cameraHeight = height
vertexBufferDirty = true
}
}
/**
* Recomputes vertex positions to achieve crop-to-fill.
*
* The camera sensor is landscape; after the 90° rotation applied via texture coordinates,
* the effective portrait dimensions are (cameraHeight × cameraWidth). We scale the vertex
* quad so the camera frame fills the screen without stretching the GPU clips the overflow.
*/
private fun recomputeVertices() {
var scaleX = 1f
var scaleY = 1f
if (cameraWidth > 0 && cameraHeight > 0 && surfaceWidth > 0 && surfaceHeight > 0) {
// After 90° rotation: portrait width = cameraHeight, portrait height = cameraWidth
val cameraRatio = cameraHeight.toFloat() / cameraWidth
val screenRatio = surfaceWidth.toFloat() / surfaceHeight
if (cameraRatio > screenRatio) {
// Camera wider than screen → crop sides
scaleX = cameraRatio / screenRatio
} else {
// Camera taller than screen → crop top/bottom
scaleY = screenRatio / cameraRatio
}
}
vertexBuffer.clear()
vertexBuffer.put(floatArrayOf(
-scaleX, -scaleY,
scaleX, -scaleY,
-scaleX, scaleY,
scaleX, scaleY
))
vertexBuffer.position(0)
}
/** /**
* Releases OpenGL resources. * Releases OpenGL resources.
* Must be called from GL thread. * Must be called from GL thread.

View file

@ -74,6 +74,8 @@ import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
@ -116,6 +118,7 @@ fun CameraScreen(
val minZoom by viewModel.cameraManager.minZoomRatio.collectAsState() val minZoom by viewModel.cameraManager.minZoomRatio.collectAsState()
val maxZoom by viewModel.cameraManager.maxZoomRatio.collectAsState() val maxZoom by viewModel.cameraManager.maxZoomRatio.collectAsState()
val isFrontCamera by viewModel.cameraManager.isFrontCamera.collectAsState() val isFrontCamera by viewModel.cameraManager.isFrontCamera.collectAsState()
val previewResolution by viewModel.cameraManager.previewResolution.collectAsState()
val cameraError by viewModel.cameraManager.error.collectAsState() val cameraError by viewModel.cameraManager.error.collectAsState()
// Gallery picker // Gallery picker
@ -161,6 +164,14 @@ fun CameraScreen(
glSurfaceView?.requestRender() glSurfaceView?.requestRender()
} }
// Update renderer with camera preview resolution for crop-to-fill
LaunchedEffect(previewResolution) {
if (previewResolution.width > 0) {
renderer?.setCameraResolution(previewResolution.width, previewResolution.height)
glSurfaceView?.requestRender()
}
}
// Start camera when surface texture is available // Start camera when surface texture is available
LaunchedEffect(surfaceTexture) { LaunchedEffect(surfaceTexture) {
surfaceTexture?.let { surfaceTexture?.let {
@ -172,14 +183,28 @@ fun CameraScreen(
LaunchedEffect(isGalleryPreview) { LaunchedEffect(isGalleryPreview) {
if (isGalleryPreview) { if (isGalleryPreview) {
glSurfaceView?.onPause() glSurfaceView?.onPause()
} else { } else if (lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
glSurfaceView?.onResume() glSurfaceView?.onResume()
} }
} }
// Cleanup GL resources on GL thread (ViewModel handles its own cleanup in onCleared) // Tie GLSurfaceView lifecycle to Activity lifecycle to prevent background rendering
DisposableEffect(Unit) { val currentIsGalleryPreview by rememberUpdatedState(isGalleryPreview)
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> {
if (!currentIsGalleryPreview) {
glSurfaceView?.onResume()
}
}
Lifecycle.Event.ON_PAUSE -> glSurfaceView?.onPause()
else -> {}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
glSurfaceView?.queueEvent { renderer?.release() } glSurfaceView?.queueEvent { renderer?.release() }
} }
} }
@ -208,13 +233,16 @@ fun CameraScreen(
GLSurfaceView(ctx).apply { GLSurfaceView(ctx).apply {
setEGLContextClientVersion(2) setEGLContextClientVersion(2)
val newRenderer = TiltShiftRenderer(ctx) { st -> val view = this
surfaceTexture = st val newRenderer = TiltShiftRenderer(
} context = ctx,
onSurfaceTextureAvailable = { st -> surfaceTexture = st },
onFrameAvailable = { view.requestRender() }
)
renderer = newRenderer renderer = newRenderer
setRenderer(newRenderer) setRenderer(newRenderer)
renderMode = GLSurfaceView.RENDERMODE_CONTINUOUSLY renderMode = GLSurfaceView.RENDERMODE_WHEN_DIRTY
glSurfaceView = this glSurfaceView = this
} }