tilt-shift-camera/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt

199 lines
6.4 KiB
Kotlin
Raw Normal View History

package no.naiv.tiltshift.effect
import android.content.Context
import android.graphics.SurfaceTexture
import android.opengl.GLES11Ext
import android.opengl.GLES20
import android.opengl.GLSurfaceView
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.FloatBuffer
import javax.microedition.khronos.egl.EGLConfig
import javax.microedition.khronos.opengles.GL10
/**
* OpenGL renderer for applying tilt-shift effect to camera preview.
*
* This renderer receives camera frames via SurfaceTexture and applies
* the tilt-shift blur effect using GLSL shaders.
*/
class TiltShiftRenderer(
private val context: Context,
private val onSurfaceTextureAvailable: (SurfaceTexture) -> Unit
) : GLSurfaceView.Renderer {
private lateinit var shader: TiltShiftShader
private var surfaceTexture: SurfaceTexture? = null
private var cameraTextureId: Int = 0
private lateinit var vertexBuffer: FloatBuffer
private lateinit var texCoordBuffer: FloatBuffer
private var surfaceWidth: Int = 0
private var surfaceHeight: Int = 0
// Current effect parameters (updated from UI thread)
@Volatile
var blurParameters: BlurParameters = BlurParameters.DEFAULT
@Volatile
private var isFrontCamera: Boolean = false
// Quad vertices (full screen)
private val vertices = floatArrayOf(
-1f, -1f, // Bottom left
1f, -1f, // Bottom right
-1f, 1f, // Top left
1f, 1f // Top right
)
// Texture coordinates rotated 90° for portrait mode (back camera)
// (Camera sensors are landscape-oriented, we rotate to portrait)
private val texCoordsBack = floatArrayOf(
1f, 1f, // Bottom left of screen -> bottom right of texture
1f, 0f, // Bottom right of screen -> top right of texture
0f, 1f, // Top left of screen -> bottom left of texture
0f, 0f // Top right of screen -> top left of texture
)
// Texture coordinates for front camera (mirrored + rotated)
// Front camera needs horizontal mirror for natural selfie view
private val texCoordsFront = floatArrayOf(
0f, 1f, // Bottom left of screen
0f, 0f, // Bottom right of screen
1f, 1f, // Top left of screen
1f, 0f // Top right of screen
)
Fix concurrency, lifecycle, performance, and config issues from audit Concurrency & bitmap lifecycle: - Defer bitmap recycling by one cycle so Compose finishes drawing before native memory is freed (preview bitmaps, thumbnails) - Make galleryPreviewSource @Volatile for cross-thread visibility - Join preview job before recycling source bitmap in cancelGalleryPreview() to prevent use-after-free during CPU blur loop - Add @Volatile to TiltShiftRenderer.currentTexCoords (UI/GL thread race) - Fix error dismiss race with cancellable Job tracking Lifecycle & resource management: - Release GL resources via glSurfaceView.queueEvent (must run on GL thread) - Pause GLSurfaceView when entering gallery preview mode - Shut down captureExecutor in CameraManager.release() (thread leak) - Use WeakReference for lifecycleOwnerRef to avoid Activity GC delay - Fix thumbnail bitmap leak on coroutine cancellation (add to finally) - Guarantee imageProxy.close() in finally block Performance: - Compute gradient mask at 1/4 resolution with bilinear upscale (~93% less per-pixel trig work, ~75% less mask memory) - Precompute cos/sin on CPU, pass as uCosAngle/uSinAngle uniforms (eliminates per-fragment transcendental calls in GLSL) - Unroll 9-tap Gaussian blur kernel (avoids integer-branched weight lookup that de-optimizes on mobile GPUs) - Add 80ms debounce to preview recomputation during slider drags Silent failure fixes: - Check bitmap.compress() return value; report error on failure - Log all loadBitmapFromUri null paths (stream, dimensions, decode) - Surface preview computation errors and ActivityNotFoundException to user - Return boolean from writeExifToUri, log at ERROR level - Wrap gallery preview downscale in try-catch (OOM protection) Config: - Add ACCESS_MEDIA_LOCATION permission (GPS EXIF on Android 10+) - Accept coarse-only location grant for geotags - Remove dead adjustResize (no effect with edge-to-edge) - Set windowBackground to black (eliminates white flash on cold start) - Add values-night theme for dark mode - Remove overly broad ProGuard keeps (CameraX/GMS ship consumer rules) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 13:44:12 +01:00
@Volatile
private var currentTexCoords = texCoordsBack
override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
GLES20.glClearColor(0f, 0f, 0f, 1f)
// Initialize shader
shader = TiltShiftShader(context)
shader.initialize()
// Create vertex buffer
vertexBuffer = ByteBuffer.allocateDirect(vertices.size * 4)
.order(ByteOrder.nativeOrder())
.asFloatBuffer()
.put(vertices)
vertexBuffer.position(0)
// Create texture coordinate buffer
texCoordBuffer = ByteBuffer.allocateDirect(currentTexCoords.size * 4)
.order(ByteOrder.nativeOrder())
.asFloatBuffer()
.put(currentTexCoords)
texCoordBuffer.position(0)
// Create camera texture
val textures = IntArray(1)
GLES20.glGenTextures(1, textures, 0)
cameraTextureId = textures[0]
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, cameraTextureId)
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR)
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR)
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE)
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE)
// Create SurfaceTexture for camera frames
surfaceTexture = SurfaceTexture(cameraTextureId).also {
onSurfaceTextureAvailable(it)
}
}
override fun onSurfaceChanged(gl: GL10?, width: Int, height: Int) {
GLES20.glViewport(0, 0, width, height)
surfaceWidth = width
surfaceHeight = height
}
override fun onDrawFrame(gl: GL10?) {
// Update texture with latest camera frame
surfaceTexture?.updateTexImage()
// Update texture coordinate buffer if camera changed
if (updateTexCoordBuffer) {
texCoordBuffer.clear()
texCoordBuffer.put(currentTexCoords)
texCoordBuffer.position(0)
updateTexCoordBuffer = false
}
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
// Use shader and set parameters
shader.use(cameraTextureId, blurParameters, surfaceWidth, surfaceHeight, isFrontCamera)
// Set vertex positions
GLES20.glEnableVertexAttribArray(shader.aPositionLocation)
GLES20.glVertexAttribPointer(
shader.aPositionLocation,
2,
GLES20.GL_FLOAT,
false,
0,
vertexBuffer
)
// Set texture coordinates
GLES20.glEnableVertexAttribArray(shader.aTexCoordLocation)
GLES20.glVertexAttribPointer(
shader.aTexCoordLocation,
2,
GLES20.GL_FLOAT,
false,
0,
texCoordBuffer
)
// Draw quad
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
// Cleanup
GLES20.glDisableVertexAttribArray(shader.aPositionLocation)
GLES20.glDisableVertexAttribArray(shader.aTexCoordLocation)
}
/**
* Updates blur parameters. Thread-safe.
*/
fun updateParameters(params: BlurParameters) {
blurParameters = params
}
/**
* Sets whether using front camera. Updates texture coordinates accordingly.
* Thread-safe - actual buffer update happens on next frame.
*/
fun setFrontCamera(front: Boolean) {
if (isFrontCamera != front) {
isFrontCamera = front
currentTexCoords = if (front) texCoordsFront else texCoordsBack
// Buffer will be updated on next draw
updateTexCoordBuffer = true
}
}
@Volatile
private var updateTexCoordBuffer = false
/**
* Releases OpenGL resources.
* Must be called from GL thread.
*/
fun release() {
shader.release()
surfaceTexture?.release()
surfaceTexture = null
if (cameraTextureId != 0) {
GLES20.glDeleteTextures(1, intArrayOf(cameraTextureId), 0)
cameraTextureId = 0
}
}
}