Implement two-pass separable Gaussian blur for camera preview

Replace the single-pass 9-tap directional blur with a three-pass
pipeline: passthrough (camera→FBO), horizontal blur (FBO-A→FBO-B),
vertical blur (FBO-B→screen). This produces a true 2D Gaussian with
a 13-tap kernel per pass, eliminating the visible banding/streaking
of the old approach.

Key changes:
- TiltShiftRenderer: FBO ping-pong with two color textures, separate
  fullscreen quad for blur passes (no crop-to-fill), drawQuad helper
- TiltShiftShader: manages two programs (passthrough + blur), blur
  program uses raw screen-space angle (no camera rotation adjustment)
- tiltshift_fragment.glsl: rewritten for sampler2D in screen space,
  aspect correction on X axis (height-normalized), uBlurDirection
  uniform for H/V selection, wider falloff (3x multiplier)
- New tiltshift_passthrough_fragment.glsl for camera→FBO copy
- TiltShiftOverlay: shrink PINCH_SIZE zone (1.3x, was 2.0x) so
  pinch-to-zoom is reachable over more of the screen
- CameraManager: optimistic zoom update fixes pinch-to-zoom stalling
  (stale zoomRatio base prevented delta accumulation)

Bump version to 1.1.3.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-03-18 17:38:50 +01:00
commit f3baa723be
7 changed files with 397 additions and 298 deletions

View file

@ -4,57 +4,167 @@ import android.content.Context
import android.opengl.GLES11Ext
import android.opengl.GLES20
import no.naiv.tiltshift.R
import kotlin.math.cos
import kotlin.math.sin
import java.io.BufferedReader
import java.io.InputStreamReader
import kotlin.math.cos
import kotlin.math.sin
/**
* Manages OpenGL shader programs for the tilt-shift effect.
* 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) {
var programId: Int = 0
private set
// --- Passthrough program (camera → FBO) ---
// Attribute locations
var aPositionLocation: Int = 0
private set
var aTexCoordLocation: Int = 0
private set
private var passthroughProgramId: Int = 0
// Uniform locations
private var uTextureLocation: Int = 0
private var uModeLocation: Int = 0
private var uIsFrontCameraLocation: Int = 0
private var uAngleLocation: Int = 0
private var uPositionXLocation: Int = 0
private var uPositionYLocation: Int = 0
private var uSizeLocation: Int = 0
private var uBlurAmountLocation: Int = 0
private var uFalloffLocation: Int = 0
private var uAspectRatioLocation: Int = 0
private var uResolutionLocation: Int = 0
private var uCosAngleLocation: Int = 0
private var uSinAngleLocation: Int = 0
var passthroughPositionLoc: Int = 0
private set
var passthroughTexCoordLoc: Int = 0
private set
private var passthroughTextureLoc: 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 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 the shader program.
* Compiles and links both shader programs.
* 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()
// 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")
// 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")
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.
*/
fun usePassthrough(cameraTextureId: Int) {
GLES20.glUseProgram(passthroughProgramId)
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, cameraTextureId)
GLES20.glUniform1i(passthroughTextureLoc, 0)
}
/**
* 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)
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)
// Check for link errors
val linkStatus = IntArray(1)
GLES20.glGetProgramiv(programId, GLES20.GL_LINK_STATUS, linkStatus, 0)
if (linkStatus[0] == 0) {
@ -63,72 +173,7 @@ class TiltShiftShader(private val context: Context) {
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")
uModeLocation = GLES20.glGetUniformLocation(programId, "uMode")
uIsFrontCameraLocation = GLES20.glGetUniformLocation(programId, "uIsFrontCamera")
uAngleLocation = GLES20.glGetUniformLocation(programId, "uAngle")
uPositionXLocation = GLES20.glGetUniformLocation(programId, "uPositionX")
uPositionYLocation = GLES20.glGetUniformLocation(programId, "uPositionY")
uSizeLocation = GLES20.glGetUniformLocation(programId, "uSize")
uBlurAmountLocation = GLES20.glGetUniformLocation(programId, "uBlurAmount")
uFalloffLocation = GLES20.glGetUniformLocation(programId, "uFalloff")
uAspectRatioLocation = GLES20.glGetUniformLocation(programId, "uAspectRatio")
uResolutionLocation = GLES20.glGetUniformLocation(programId, "uResolution")
uCosAngleLocation = GLES20.glGetUniformLocation(programId, "uCosAngle")
uSinAngleLocation = GLES20.glGetUniformLocation(programId, "uSinAngle")
// 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, isFrontCamera: Boolean = false) {
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.glUniform1i(uModeLocation, if (params.mode == BlurMode.RADIAL) 1 else 0)
GLES20.glUniform1i(uIsFrontCameraLocation, if (isFrontCamera) 1 else 0)
GLES20.glUniform1f(uAngleLocation, params.angle)
GLES20.glUniform1f(uPositionXLocation, params.positionX)
GLES20.glUniform1f(uPositionYLocation, params.positionY)
GLES20.glUniform1f(uSizeLocation, params.size)
GLES20.glUniform1f(uBlurAmountLocation, params.blurAmount)
GLES20.glUniform1f(uFalloffLocation, params.falloff)
GLES20.glUniform1f(uAspectRatioLocation, params.aspectRatio)
GLES20.glUniform2f(uResolutionLocation, width.toFloat(), height.toFloat())
// Precompute angle trig on CPU to avoid per-fragment transcendental calls.
// The adjusted angle accounts for the 90deg coordinate transform.
val adjustedAngle = if (isFrontCamera) {
-params.angle - (Math.PI / 2).toFloat()
} else {
params.angle + (Math.PI / 2).toFloat()
}
GLES20.glUniform1f(uCosAngleLocation, cos(adjustedAngle))
GLES20.glUniform1f(uSinAngleLocation, sin(adjustedAngle))
}
/**
* Releases shader resources.
*/
fun release() {
if (programId != 0) {
GLES20.glDeleteProgram(programId)
programId = 0
}
return programId
}
private fun loadShaderSource(resourceId: Int): String {
@ -142,7 +187,6 @@ class TiltShiftShader(private val context: Context) {
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) {