Replace hardcoded portrait-only texture coordinate rotation with SurfaceTexture.getTransformMatrix(), so the camera preview and capture re-orient correctly when the device rotates. Also drive Preview/ImageCapture targetRotation from the live display rotation, fix the crop-to-fill aspect math to swap effective camera dimensions between portrait and landscape, and make the slider control panel scroll if it doesn't fit the shorter landscape height. Bump to 1.1.6. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
229 lines
9.5 KiB
Kotlin
229 lines
9.5 KiB
Kotlin
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
|
|
import kotlin.math.cos
|
|
import kotlin.math.sin
|
|
|
|
/**
|
|
* Manages OpenGL shader programs for the two-pass tilt-shift effect.
|
|
*
|
|
* Two programs:
|
|
* - **Passthrough**: copies camera texture (external OES) to an FBO, handling the
|
|
* coordinate transform via vertex/texcoord setup.
|
|
* - **Blur**: applies a directional Gaussian blur with tilt-shift mask.
|
|
* Used twice per frame (horizontal then vertical) via the [uBlurDirection] uniform.
|
|
*/
|
|
class TiltShiftShader(private val context: Context) {
|
|
|
|
private companion object {
|
|
val IDENTITY_MATRIX = floatArrayOf(
|
|
1f, 0f, 0f, 0f,
|
|
0f, 1f, 0f, 0f,
|
|
0f, 0f, 1f, 0f,
|
|
0f, 0f, 0f, 1f
|
|
)
|
|
}
|
|
|
|
// --- Passthrough program (camera → FBO) ---
|
|
|
|
private var passthroughProgramId: Int = 0
|
|
|
|
var passthroughPositionLoc: Int = 0
|
|
private set
|
|
var passthroughTexCoordLoc: Int = 0
|
|
private set
|
|
private var passthroughTextureLoc: Int = 0
|
|
private var passthroughTexMatrixLoc: Int = 0
|
|
private var passthroughMirrorLoc: Int = 0
|
|
|
|
// --- Blur program (FBO → FBO/screen) ---
|
|
|
|
private var blurProgramId: Int = 0
|
|
|
|
var blurPositionLoc: Int = 0
|
|
private set
|
|
var blurTexCoordLoc: Int = 0
|
|
private set
|
|
private var blurTextureLoc: Int = 0
|
|
private var blurTexMatrixLoc: Int = 0
|
|
private var blurMirrorLoc: Int = 0
|
|
private var blurModeLoc: Int = 0
|
|
private var blurPositionXLoc: Int = 0
|
|
private var blurPositionYLoc: Int = 0
|
|
private var blurSizeLoc: Int = 0
|
|
private var blurAmountLoc: Int = 0
|
|
private var blurFalloffLoc: Int = 0
|
|
private var blurAspectRatioLoc: Int = 0
|
|
private var blurResolutionLoc: Int = 0
|
|
private var blurCosAngleLoc: Int = 0
|
|
private var blurSinAngleLoc: Int = 0
|
|
private var blurDirectionLoc: Int = 0
|
|
|
|
/**
|
|
* Compiles and links both shader programs.
|
|
* Must be called from GL thread.
|
|
*/
|
|
fun initialize() {
|
|
val vertexSource = loadShaderSource(R.raw.tiltshift_vertex)
|
|
val vertexShader = compileShader(GLES20.GL_VERTEX_SHADER, vertexSource)
|
|
|
|
// Passthrough program
|
|
val passthroughFragSource = loadShaderSource(R.raw.tiltshift_passthrough_fragment)
|
|
val passthroughFragShader = compileShader(GLES20.GL_FRAGMENT_SHADER, passthroughFragSource)
|
|
passthroughProgramId = linkProgram(vertexShader, passthroughFragShader)
|
|
GLES20.glDeleteShader(passthroughFragShader)
|
|
|
|
passthroughPositionLoc = GLES20.glGetAttribLocation(passthroughProgramId, "aPosition")
|
|
passthroughTexCoordLoc = GLES20.glGetAttribLocation(passthroughProgramId, "aTexCoord")
|
|
passthroughTextureLoc = GLES20.glGetUniformLocation(passthroughProgramId, "uTexture")
|
|
passthroughTexMatrixLoc = GLES20.glGetUniformLocation(passthroughProgramId, "uTexMatrix")
|
|
passthroughMirrorLoc = GLES20.glGetUniformLocation(passthroughProgramId, "uMirrorX")
|
|
|
|
// Blur program
|
|
val blurFragSource = loadShaderSource(R.raw.tiltshift_fragment)
|
|
val blurFragShader = compileShader(GLES20.GL_FRAGMENT_SHADER, blurFragSource)
|
|
blurProgramId = linkProgram(vertexShader, blurFragShader)
|
|
GLES20.glDeleteShader(blurFragShader)
|
|
|
|
blurPositionLoc = GLES20.glGetAttribLocation(blurProgramId, "aPosition")
|
|
blurTexCoordLoc = GLES20.glGetAttribLocation(blurProgramId, "aTexCoord")
|
|
blurTextureLoc = GLES20.glGetUniformLocation(blurProgramId, "uTexture")
|
|
blurTexMatrixLoc = GLES20.glGetUniformLocation(blurProgramId, "uTexMatrix")
|
|
blurMirrorLoc = GLES20.glGetUniformLocation(blurProgramId, "uMirrorX")
|
|
blurModeLoc = GLES20.glGetUniformLocation(blurProgramId, "uMode")
|
|
blurPositionXLoc = GLES20.glGetUniformLocation(blurProgramId, "uPositionX")
|
|
blurPositionYLoc = GLES20.glGetUniformLocation(blurProgramId, "uPositionY")
|
|
blurSizeLoc = GLES20.glGetUniformLocation(blurProgramId, "uSize")
|
|
blurAmountLoc = GLES20.glGetUniformLocation(blurProgramId, "uBlurAmount")
|
|
blurFalloffLoc = GLES20.glGetUniformLocation(blurProgramId, "uFalloff")
|
|
blurAspectRatioLoc = GLES20.glGetUniformLocation(blurProgramId, "uAspectRatio")
|
|
blurResolutionLoc = GLES20.glGetUniformLocation(blurProgramId, "uResolution")
|
|
blurCosAngleLoc = GLES20.glGetUniformLocation(blurProgramId, "uCosAngle")
|
|
blurSinAngleLoc = GLES20.glGetUniformLocation(blurProgramId, "uSinAngle")
|
|
blurDirectionLoc = GLES20.glGetUniformLocation(blurProgramId, "uBlurDirection")
|
|
|
|
// Vertex shader is linked into both programs and can be freed
|
|
GLES20.glDeleteShader(vertexShader)
|
|
}
|
|
|
|
/**
|
|
* Activates the passthrough program and binds the camera texture.
|
|
*
|
|
* @param cameraTextureId The OES texture receiving camera frames.
|
|
* @param texMatrix 4x4 transform from SurfaceTexture.getTransformMatrix() —
|
|
* encodes sensor-to-display rotation and Y-flip.
|
|
* @param mirrorX true to horizontally mirror (front camera selfie view).
|
|
*/
|
|
fun usePassthrough(cameraTextureId: Int, texMatrix: FloatArray, mirrorX: Boolean) {
|
|
GLES20.glUseProgram(passthroughProgramId)
|
|
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
|
|
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, cameraTextureId)
|
|
GLES20.glUniform1i(passthroughTextureLoc, 0)
|
|
GLES20.glUniformMatrix4fv(passthroughTexMatrixLoc, 1, false, texMatrix, 0)
|
|
GLES20.glUniform1f(passthroughMirrorLoc, if (mirrorX) 1f else 0f)
|
|
}
|
|
|
|
/**
|
|
* Activates the blur program and sets all uniforms for one blur pass.
|
|
*
|
|
* @param fboTextureId The FBO color attachment to sample from.
|
|
* @param params Current blur parameters.
|
|
* @param width Surface width in pixels.
|
|
* @param height Surface height in pixels.
|
|
* @param dirX Blur direction X component (1 for horizontal pass, 0 for vertical).
|
|
* @param dirY Blur direction Y component (0 for horizontal pass, 1 for vertical).
|
|
*/
|
|
fun useBlurPass(
|
|
fboTextureId: Int,
|
|
params: BlurParameters,
|
|
width: Int,
|
|
height: Int,
|
|
dirX: Float,
|
|
dirY: Float
|
|
) {
|
|
GLES20.glUseProgram(blurProgramId)
|
|
|
|
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
|
|
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, fboTextureId)
|
|
GLES20.glUniform1i(blurTextureLoc, 0)
|
|
|
|
// FBO content is already in display orientation — pass identity matrix and no mirror.
|
|
GLES20.glUniformMatrix4fv(blurTexMatrixLoc, 1, false, IDENTITY_MATRIX, 0)
|
|
GLES20.glUniform1f(blurMirrorLoc, 0f)
|
|
|
|
GLES20.glUniform1i(blurModeLoc, if (params.mode == BlurMode.RADIAL) 1 else 0)
|
|
GLES20.glUniform1f(blurPositionXLoc, params.positionX)
|
|
GLES20.glUniform1f(blurPositionYLoc, params.positionY)
|
|
GLES20.glUniform1f(blurSizeLoc, params.size)
|
|
GLES20.glUniform1f(blurAmountLoc, params.blurAmount)
|
|
GLES20.glUniform1f(blurFalloffLoc, params.falloff)
|
|
GLES20.glUniform1f(blurAspectRatioLoc, params.aspectRatio)
|
|
GLES20.glUniform2f(blurResolutionLoc, width.toFloat(), height.toFloat())
|
|
|
|
// Raw screen-space angle (no camera rotation adjustment needed — FBO is already
|
|
// in screen orientation after the passthrough pass)
|
|
GLES20.glUniform1f(blurCosAngleLoc, cos(params.angle))
|
|
GLES20.glUniform1f(blurSinAngleLoc, sin(params.angle))
|
|
|
|
GLES20.glUniform2f(blurDirectionLoc, dirX, dirY)
|
|
}
|
|
|
|
/**
|
|
* Releases both shader programs.
|
|
*/
|
|
fun release() {
|
|
if (passthroughProgramId != 0) {
|
|
GLES20.glDeleteProgram(passthroughProgramId)
|
|
passthroughProgramId = 0
|
|
}
|
|
if (blurProgramId != 0) {
|
|
GLES20.glDeleteProgram(blurProgramId)
|
|
blurProgramId = 0
|
|
}
|
|
}
|
|
|
|
private fun linkProgram(vertexShader: Int, fragmentShader: Int): Int {
|
|
val programId = GLES20.glCreateProgram()
|
|
GLES20.glAttachShader(programId, vertexShader)
|
|
GLES20.glAttachShader(programId, fragmentShader)
|
|
GLES20.glLinkProgram(programId)
|
|
|
|
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")
|
|
}
|
|
|
|
return programId
|
|
}
|
|
|
|
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)
|
|
|
|
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
|
|
}
|
|
}
|