Initial implementation of Tilt-Shift Camera Android app
A dedicated camera app for tilt-shift photography with: - Real-time OpenGL ES 2.0 shader-based blur preview - Touch gesture controls (drag, rotate, pinch) for adjusting effect - CameraX integration for camera preview and high-res capture - EXIF metadata with GPS location support - MediaStore integration for saving to gallery - Jetpack Compose UI with haptic feedback Tech stack: Kotlin, CameraX, OpenGL ES 2.0, Jetpack Compose Min SDK: 26 (Android 8.0), Target SDK: 35 (Android 15) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
commit
07e10ac9c3
38 changed files with 3489 additions and 0 deletions
54
app/src/main/java/no/naiv/tiltshift/effect/BlurParameters.kt
Normal file
54
app/src/main/java/no/naiv/tiltshift/effect/BlurParameters.kt
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
package no.naiv.tiltshift.effect
|
||||
|
||||
/**
|
||||
* Parameters controlling the tilt-shift blur effect.
|
||||
*
|
||||
* @param angle The rotation angle of the blur gradient in radians (0 = horizontal blur bands)
|
||||
* @param position The center position of the in-focus region (0.0 to 1.0, relative to screen)
|
||||
* @param size The size of the in-focus region (0.0 to 1.0, as fraction of screen height)
|
||||
* @param blurAmount The intensity of the blur effect (0.0 to 1.0)
|
||||
*/
|
||||
data class BlurParameters(
|
||||
val angle: Float = 0f,
|
||||
val position: Float = 0.5f,
|
||||
val size: Float = 0.3f,
|
||||
val blurAmount: Float = 0.8f
|
||||
) {
|
||||
companion object {
|
||||
val DEFAULT = BlurParameters()
|
||||
|
||||
// Constraints
|
||||
const val MIN_SIZE = 0.1f
|
||||
const val MAX_SIZE = 0.8f
|
||||
const val MIN_BLUR = 0.0f
|
||||
const val MAX_BLUR = 1.0f
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a copy with the angle adjusted by the given delta.
|
||||
*/
|
||||
fun withAngleDelta(delta: Float): BlurParameters {
|
||||
return copy(angle = angle + delta)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a copy with the position clamped to valid range.
|
||||
*/
|
||||
fun withPosition(newPosition: Float): BlurParameters {
|
||||
return copy(position = newPosition.coerceIn(0f, 1f))
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a copy with the size clamped to valid range.
|
||||
*/
|
||||
fun withSize(newSize: Float): BlurParameters {
|
||||
return copy(size = newSize.coerceIn(MIN_SIZE, MAX_SIZE))
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a copy with the blur amount clamped to valid range.
|
||||
*/
|
||||
fun withBlurAmount(amount: Float): BlurParameters {
|
||||
return copy(blurAmount = amount.coerceIn(MIN_BLUR, MAX_BLUR))
|
||||
}
|
||||
}
|
||||
159
app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt
Normal file
159
app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
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
|
||||
|
||||
// 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 (flip Y for camera)
|
||||
private val texCoords = floatArrayOf(
|
||||
0f, 1f, // Bottom left
|
||||
1f, 1f, // Bottom right
|
||||
0f, 0f, // Top left
|
||||
1f, 0f // Top right
|
||||
)
|
||||
|
||||
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(texCoords.size * 4)
|
||||
.order(ByteOrder.nativeOrder())
|
||||
.asFloatBuffer()
|
||||
.put(texCoords)
|
||||
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()
|
||||
|
||||
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
|
||||
|
||||
// Use shader and set parameters
|
||||
shader.use(cameraTextureId, blurParameters, surfaceWidth, surfaceHeight)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
}
|
||||
126
app/src/main/java/no/naiv/tiltshift/effect/TiltShiftShader.kt
Normal file
126
app/src/main/java/no/naiv/tiltshift/effect/TiltShiftShader.kt
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
package no.naiv.tiltshift.effect
|
||||
|
||||
import android.content.Context
|
||||
import android.opengl.GLES11Ext
|
||||
import android.opengl.GLES20
|
||||
import no.naiv.tiltshift.R
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
|
||||
/**
|
||||
* Manages OpenGL shader programs for the tilt-shift effect.
|
||||
*/
|
||||
class TiltShiftShader(private val context: Context) {
|
||||
|
||||
var programId: Int = 0
|
||||
private set
|
||||
|
||||
// Attribute locations
|
||||
var aPositionLocation: Int = 0
|
||||
private set
|
||||
var aTexCoordLocation: Int = 0
|
||||
private set
|
||||
|
||||
// Uniform locations
|
||||
private var uTextureLocation: Int = 0
|
||||
private var uAngleLocation: Int = 0
|
||||
private var uPositionLocation: Int = 0
|
||||
private var uSizeLocation: Int = 0
|
||||
private var uBlurAmountLocation: Int = 0
|
||||
private var uResolutionLocation: Int = 0
|
||||
|
||||
/**
|
||||
* Compiles and links the shader program.
|
||||
* Must be called from GL thread.
|
||||
*/
|
||||
fun initialize() {
|
||||
val vertexSource = loadShaderSource(R.raw.tiltshift_vertex)
|
||||
val fragmentSource = loadShaderSource(R.raw.tiltshift_fragment)
|
||||
|
||||
val vertexShader = compileShader(GLES20.GL_VERTEX_SHADER, vertexSource)
|
||||
val fragmentShader = compileShader(GLES20.GL_FRAGMENT_SHADER, fragmentSource)
|
||||
|
||||
programId = GLES20.glCreateProgram()
|
||||
GLES20.glAttachShader(programId, vertexShader)
|
||||
GLES20.glAttachShader(programId, fragmentShader)
|
||||
GLES20.glLinkProgram(programId)
|
||||
|
||||
// Check for link errors
|
||||
val linkStatus = IntArray(1)
|
||||
GLES20.glGetProgramiv(programId, GLES20.GL_LINK_STATUS, linkStatus, 0)
|
||||
if (linkStatus[0] == 0) {
|
||||
val error = GLES20.glGetProgramInfoLog(programId)
|
||||
GLES20.glDeleteProgram(programId)
|
||||
throw RuntimeException("Shader program link failed: $error")
|
||||
}
|
||||
|
||||
// Get attribute locations
|
||||
aPositionLocation = GLES20.glGetAttribLocation(programId, "aPosition")
|
||||
aTexCoordLocation = GLES20.glGetAttribLocation(programId, "aTexCoord")
|
||||
|
||||
// Get uniform locations
|
||||
uTextureLocation = GLES20.glGetUniformLocation(programId, "uTexture")
|
||||
uAngleLocation = GLES20.glGetUniformLocation(programId, "uAngle")
|
||||
uPositionLocation = GLES20.glGetUniformLocation(programId, "uPosition")
|
||||
uSizeLocation = GLES20.glGetUniformLocation(programId, "uSize")
|
||||
uBlurAmountLocation = GLES20.glGetUniformLocation(programId, "uBlurAmount")
|
||||
uResolutionLocation = GLES20.glGetUniformLocation(programId, "uResolution")
|
||||
|
||||
// Clean up shaders (they're linked into program now)
|
||||
GLES20.glDeleteShader(vertexShader)
|
||||
GLES20.glDeleteShader(fragmentShader)
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses the shader program and sets uniforms.
|
||||
*/
|
||||
fun use(textureId: Int, params: BlurParameters, width: Int, height: Int) {
|
||||
GLES20.glUseProgram(programId)
|
||||
|
||||
// Bind camera texture
|
||||
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
|
||||
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textureId)
|
||||
GLES20.glUniform1i(uTextureLocation, 0)
|
||||
|
||||
// Set effect parameters
|
||||
GLES20.glUniform1f(uAngleLocation, params.angle)
|
||||
GLES20.glUniform1f(uPositionLocation, params.position)
|
||||
GLES20.glUniform1f(uSizeLocation, params.size)
|
||||
GLES20.glUniform1f(uBlurAmountLocation, params.blurAmount)
|
||||
GLES20.glUniform2f(uResolutionLocation, width.toFloat(), height.toFloat())
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases shader resources.
|
||||
*/
|
||||
fun release() {
|
||||
if (programId != 0) {
|
||||
GLES20.glDeleteProgram(programId)
|
||||
programId = 0
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadShaderSource(resourceId: Int): String {
|
||||
val inputStream = context.resources.openRawResource(resourceId)
|
||||
val reader = BufferedReader(InputStreamReader(inputStream))
|
||||
return reader.use { it.readText() }
|
||||
}
|
||||
|
||||
private fun compileShader(type: Int, source: String): Int {
|
||||
val shader = GLES20.glCreateShader(type)
|
||||
GLES20.glShaderSource(shader, source)
|
||||
GLES20.glCompileShader(shader)
|
||||
|
||||
// Check for compile errors
|
||||
val compileStatus = IntArray(1)
|
||||
GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compileStatus, 0)
|
||||
if (compileStatus[0] == 0) {
|
||||
val error = GLES20.glGetShaderInfoLog(shader)
|
||||
GLES20.glDeleteShader(shader)
|
||||
val shaderType = if (type == GLES20.GL_VERTEX_SHADER) "vertex" else "fragment"
|
||||
throw RuntimeException("$shaderType shader compilation failed: $error")
|
||||
}
|
||||
|
||||
return shader
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue