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:
parent
4d755dce31
commit
7979ebd029
3 changed files with 107 additions and 18 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue