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, private val onFrameAvailable: () -> 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 // Camera resolution for aspect ratio correction (set from UI thread) @Volatile private var cameraWidth: Int = 0 @Volatile private var cameraHeight: Int = 0 @Volatile private var vertexBufferDirty: Boolean = false // 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 ) @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() // Allocate vertex buffer (8 floats = 4 vertices × 2 components) vertexBuffer = ByteBuffer.allocateDirect(8 * 4) .order(ByteOrder.nativeOrder()) .asFloatBuffer() // 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) // 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 { it.setOnFrameAvailableListener { onFrameAvailable() } onSurfaceTextureAvailable(it) } } override fun onSurfaceChanged(gl: GL10?, width: Int, height: Int) { GLES20.glViewport(0, 0, width, height) surfaceWidth = width surfaceHeight = height vertexBufferDirty = true } override fun onDrawFrame(gl: GL10?) { // Update texture with latest camera frame 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 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 /** * 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. * 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 } } }