2026-01-28 15:26:41 +01:00
|
|
|
|
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,
|
2026-03-05 13:58:17 +01:00
|
|
|
|
private val onSurfaceTextureAvailable: (SurfaceTexture) -> Unit,
|
|
|
|
|
|
private val onFrameAvailable: () -> Unit
|
2026-01-28 15:26:41 +01:00
|
|
|
|
) : 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
|
|
|
|
|
|
|
2026-01-29 17:03:26 +01:00
|
|
|
|
@Volatile
|
|
|
|
|
|
private var isFrontCamera: Boolean = false
|
|
|
|
|
|
|
2026-03-05 13:58:17 +01:00
|
|
|
|
// 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
|
2026-01-28 15:26:41 +01:00
|
|
|
|
|
2026-01-29 17:03:26 +01:00
|
|
|
|
// Texture coordinates rotated 90° for portrait mode (back camera)
|
2026-01-28 15:32:31 +01:00
|
|
|
|
// (Camera sensors are landscape-oriented, we rotate to portrait)
|
2026-01-29 17:03:26 +01:00
|
|
|
|
private val texCoordsBack = floatArrayOf(
|
2026-01-28 15:32:31 +01:00
|
|
|
|
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
|
2026-01-28 15:26:41 +01:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-01-29 17:03:26 +01:00
|
|
|
|
// 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
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-05 13:44:12 +01:00
|
|
|
|
@Volatile
|
2026-01-29 17:03:26 +01:00
|
|
|
|
private var currentTexCoords = texCoordsBack
|
|
|
|
|
|
|
2026-01-28 15:26:41 +01:00
|
|
|
|
override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
|
|
|
|
|
|
GLES20.glClearColor(0f, 0f, 0f, 1f)
|
|
|
|
|
|
|
|
|
|
|
|
// Initialize shader
|
|
|
|
|
|
shader = TiltShiftShader(context)
|
|
|
|
|
|
shader.initialize()
|
|
|
|
|
|
|
2026-03-05 13:58:17 +01:00
|
|
|
|
// Allocate vertex buffer (8 floats = 4 vertices × 2 components)
|
|
|
|
|
|
vertexBuffer = ByteBuffer.allocateDirect(8 * 4)
|
2026-01-28 15:26:41 +01:00
|
|
|
|
.order(ByteOrder.nativeOrder())
|
|
|
|
|
|
.asFloatBuffer()
|
2026-03-05 13:58:17 +01:00
|
|
|
|
// 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))
|
2026-01-28 15:26:41 +01:00
|
|
|
|
vertexBuffer.position(0)
|
|
|
|
|
|
|
|
|
|
|
|
// Create texture coordinate buffer
|
2026-01-29 17:03:26 +01:00
|
|
|
|
texCoordBuffer = ByteBuffer.allocateDirect(currentTexCoords.size * 4)
|
2026-01-28 15:26:41 +01:00
|
|
|
|
.order(ByteOrder.nativeOrder())
|
|
|
|
|
|
.asFloatBuffer()
|
2026-01-29 17:03:26 +01:00
|
|
|
|
.put(currentTexCoords)
|
2026-01-28 15:26:41 +01:00
|
|
|
|
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 {
|
2026-03-05 13:58:17 +01:00
|
|
|
|
it.setOnFrameAvailableListener { onFrameAvailable() }
|
2026-01-28 15:26:41 +01:00
|
|
|
|
onSurfaceTextureAvailable(it)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
override fun onSurfaceChanged(gl: GL10?, width: Int, height: Int) {
|
|
|
|
|
|
GLES20.glViewport(0, 0, width, height)
|
|
|
|
|
|
surfaceWidth = width
|
|
|
|
|
|
surfaceHeight = height
|
2026-03-05 13:58:17 +01:00
|
|
|
|
vertexBufferDirty = true
|
2026-01-28 15:26:41 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
override fun onDrawFrame(gl: GL10?) {
|
|
|
|
|
|
// Update texture with latest camera frame
|
|
|
|
|
|
surfaceTexture?.updateTexImage()
|
|
|
|
|
|
|
2026-03-05 13:58:17 +01:00
|
|
|
|
// Recompute vertex buffer for crop-to-fill when camera or surface dimensions change
|
|
|
|
|
|
if (vertexBufferDirty) {
|
|
|
|
|
|
recomputeVertices()
|
|
|
|
|
|
vertexBufferDirty = false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-29 17:03:26 +01:00
|
|
|
|
// Update texture coordinate buffer if camera changed
|
|
|
|
|
|
if (updateTexCoordBuffer) {
|
|
|
|
|
|
texCoordBuffer.clear()
|
|
|
|
|
|
texCoordBuffer.put(currentTexCoords)
|
|
|
|
|
|
texCoordBuffer.position(0)
|
|
|
|
|
|
updateTexCoordBuffer = false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-28 15:26:41 +01:00
|
|
|
|
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
|
|
|
|
|
|
|
|
|
|
|
|
// Use shader and set parameters
|
2026-01-29 17:07:44 +01:00
|
|
|
|
shader.use(cameraTextureId, blurParameters, surfaceWidth, surfaceHeight, isFrontCamera)
|
2026-01-28 15:26:41 +01:00
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-29 17:03:26 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* 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
|
|
|
|
|
|
|
2026-03-05 13:58:17 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* 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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-28 15:26:41 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* 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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|