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

@ -174,24 +174,14 @@ class CameraManager(private val context: Context) {
} }
/** /**
* Sets the zoom ratio. Updates UI state only after the camera confirms the change. * Sets the zoom ratio. Updates UI state immediately so that rapid pinch-to-zoom
* gestures accumulate correctly (each frame uses the latest ratio as its base).
* If the camera rejects the value, the next successful set corrects the state.
*/ */
fun setZoom(ratio: Float) { fun setZoom(ratio: Float) {
val clamped = ratio.coerceIn(_minZoomRatio.value, _maxZoomRatio.value) val clamped = ratio.coerceIn(_minZoomRatio.value, _maxZoomRatio.value)
val future = camera?.cameraControl?.setZoomRatio(clamped) _zoomRatio.value = clamped
if (future != null) { camera?.cameraControl?.setZoomRatio(clamped)
future.addListener({
try {
future.get()
_zoomRatio.value = clamped
} catch (e: Exception) {
Log.w(TAG, "Zoom operation failed", e)
}
}, ContextCompat.getMainExecutor(context))
} else {
// Optimistic update when camera not available (e.g. during init)
_zoomRatio.value = clamped
}
} }
/** /**

View file

@ -5,6 +5,7 @@ import android.graphics.SurfaceTexture
import android.opengl.GLES11Ext import android.opengl.GLES11Ext
import android.opengl.GLES20 import android.opengl.GLES20
import android.opengl.GLSurfaceView import android.opengl.GLSurfaceView
import android.util.Log
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder
import java.nio.FloatBuffer import java.nio.FloatBuffer
@ -12,10 +13,16 @@ import javax.microedition.khronos.egl.EGLConfig
import javax.microedition.khronos.opengles.GL10 import javax.microedition.khronos.opengles.GL10
/** /**
* OpenGL renderer for applying tilt-shift effect to camera preview. * OpenGL renderer for applying tilt-shift effect to camera preview
* using a two-pass separable Gaussian blur.
* *
* This renderer receives camera frames via SurfaceTexture and applies * Rendering pipeline (3 draw calls per frame):
* the tilt-shift blur effect using GLSL shaders. * 1. **Passthrough**: camera texture FBO-A (handles coordinate transform via vertex/texcoord)
* 2. **Horizontal blur**: FBO-A FBO-B (13-tap Gaussian, tilt-shift mask)
* 3. **Vertical blur**: FBO-B screen (13-tap Gaussian, tilt-shift mask)
*
* The passthrough decouples the camera's rotated coordinate system from the blur
* passes, which work entirely in screen space.
*/ */
class TiltShiftRenderer( class TiltShiftRenderer(
private val context: Context, private val context: Context,
@ -23,16 +30,30 @@ class TiltShiftRenderer(
private val onFrameAvailable: () -> Unit private val onFrameAvailable: () -> Unit
) : GLSurfaceView.Renderer { ) : GLSurfaceView.Renderer {
companion object {
private const val TAG = "TiltShiftRenderer"
}
private lateinit var shader: TiltShiftShader private lateinit var shader: TiltShiftShader
private var surfaceTexture: SurfaceTexture? = null private var surfaceTexture: SurfaceTexture? = null
private var cameraTextureId: Int = 0 private var cameraTextureId: Int = 0
private lateinit var vertexBuffer: FloatBuffer // Camera quad: crop-to-fill vertices + rotated texcoords (pass 1 only)
private lateinit var texCoordBuffer: FloatBuffer private lateinit var cameraVertexBuffer: FloatBuffer
private lateinit var cameraTexCoordBuffer: FloatBuffer
// Fullscreen quad for blur passes (no crop, standard texcoords)
private lateinit var fullscreenVertexBuffer: FloatBuffer
private lateinit var fullscreenTexCoordBuffer: FloatBuffer
private var surfaceWidth: Int = 0 private var surfaceWidth: Int = 0
private var surfaceHeight: Int = 0 private var surfaceHeight: Int = 0
// FBO resources: one framebuffer, two color textures for ping-pong
private var fboId: Int = 0
private var fboTexA: Int = 0
private var fboTexB: Int = 0
// Current effect parameters (updated from UI thread) // Current effect parameters (updated from UI thread)
@Volatile @Volatile
var blurParameters: BlurParameters = BlurParameters.DEFAULT var blurParameters: BlurParameters = BlurParameters.DEFAULT
@ -69,27 +90,33 @@ class TiltShiftRenderer(
@Volatile @Volatile
private var currentTexCoords = texCoordsBack private var currentTexCoords = texCoordsBack
@Volatile
private var updateTexCoordBuffer = false
override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) { override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
GLES20.glClearColor(0f, 0f, 0f, 1f) GLES20.glClearColor(0f, 0f, 0f, 1f)
// Initialize shader
shader = TiltShiftShader(context) shader = TiltShiftShader(context)
shader.initialize() shader.initialize()
// Allocate vertex buffer (8 floats = 4 vertices × 2 components) // Camera quad vertex buffer (crop-to-fill, recomputed when resolution is known)
vertexBuffer = ByteBuffer.allocateDirect(8 * 4) cameraVertexBuffer = allocateFloatBuffer(8)
.order(ByteOrder.nativeOrder()) cameraVertexBuffer.put(floatArrayOf(-1f, -1f, 1f, -1f, -1f, 1f, 1f, 1f))
.asFloatBuffer() cameraVertexBuffer.position(0)
// 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 // Camera texcoord buffer (rotated for portrait)
texCoordBuffer = ByteBuffer.allocateDirect(currentTexCoords.size * 4) cameraTexCoordBuffer = allocateFloatBuffer(8)
.order(ByteOrder.nativeOrder()) cameraTexCoordBuffer.put(currentTexCoords)
.asFloatBuffer() cameraTexCoordBuffer.position(0)
.put(currentTexCoords)
texCoordBuffer.position(0) // Fullscreen quad for blur passes (standard coords)
fullscreenVertexBuffer = allocateFloatBuffer(8)
fullscreenVertexBuffer.put(floatArrayOf(-1f, -1f, 1f, -1f, -1f, 1f, 1f, 1f))
fullscreenVertexBuffer.position(0)
fullscreenTexCoordBuffer = allocateFloatBuffer(8)
fullscreenTexCoordBuffer.put(floatArrayOf(0f, 0f, 1f, 0f, 0f, 1f, 1f, 1f))
fullscreenTexCoordBuffer.position(0)
// Create camera texture // Create camera texture
val textures = IntArray(1) val textures = IntArray(1)
@ -114,88 +141,75 @@ class TiltShiftRenderer(
surfaceWidth = width surfaceWidth = width
surfaceHeight = height surfaceHeight = height
vertexBufferDirty = true vertexBufferDirty = true
recreateFBOs(width, height)
} }
override fun onDrawFrame(gl: GL10?) { override fun onDrawFrame(gl: GL10?) {
// Update texture with latest camera frame
surfaceTexture?.updateTexImage() surfaceTexture?.updateTexImage()
// Recompute vertex buffer for crop-to-fill when camera or surface dimensions change
if (vertexBufferDirty) { if (vertexBufferDirty) {
recomputeVertices() recomputeVertices()
vertexBufferDirty = false vertexBufferDirty = false
} }
// Update texture coordinate buffer if camera changed
if (updateTexCoordBuffer) { if (updateTexCoordBuffer) {
texCoordBuffer.clear() cameraTexCoordBuffer.clear()
texCoordBuffer.put(currentTexCoords) cameraTexCoordBuffer.put(currentTexCoords)
texCoordBuffer.position(0) cameraTexCoordBuffer.position(0)
updateTexCoordBuffer = false updateTexCoordBuffer = false
} }
val params = blurParameters
// --- Pass 1: Camera → FBO-A (passthrough with crop-to-fill) ---
GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, fboId)
GLES20.glFramebufferTexture2D(
GLES20.GL_FRAMEBUFFER, GLES20.GL_COLOR_ATTACHMENT0,
GLES20.GL_TEXTURE_2D, fboTexA, 0
)
GLES20.glViewport(0, 0, surfaceWidth, surfaceHeight)
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT) GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
shader.usePassthrough(cameraTextureId)
// Use shader and set parameters drawQuad(
shader.use(cameraTextureId, blurParameters, surfaceWidth, surfaceHeight, isFrontCamera) shader.passthroughPositionLoc, shader.passthroughTexCoordLoc,
cameraVertexBuffer, cameraTexCoordBuffer
// Set vertex positions
GLES20.glEnableVertexAttribArray(shader.aPositionLocation)
GLES20.glVertexAttribPointer(
shader.aPositionLocation,
2,
GLES20.GL_FLOAT,
false,
0,
vertexBuffer
) )
// Set texture coordinates // --- Pass 2: FBO-A → FBO-B (horizontal blur) ---
GLES20.glEnableVertexAttribArray(shader.aTexCoordLocation) GLES20.glFramebufferTexture2D(
GLES20.glVertexAttribPointer( GLES20.GL_FRAMEBUFFER, GLES20.GL_COLOR_ATTACHMENT0,
shader.aTexCoordLocation, GLES20.GL_TEXTURE_2D, fboTexB, 0
2, )
GLES20.GL_FLOAT, GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
false, shader.useBlurPass(fboTexA, params, surfaceWidth, surfaceHeight, 1f, 0f)
0, drawQuad(
texCoordBuffer shader.blurPositionLoc, shader.blurTexCoordLoc,
fullscreenVertexBuffer, fullscreenTexCoordBuffer
) )
// Draw quad // --- Pass 3: FBO-B → screen (vertical blur) ---
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4) GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, 0)
GLES20.glViewport(0, 0, surfaceWidth, surfaceHeight)
// Cleanup GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
GLES20.glDisableVertexAttribArray(shader.aPositionLocation) shader.useBlurPass(fboTexB, params, surfaceWidth, surfaceHeight, 0f, 1f)
GLES20.glDisableVertexAttribArray(shader.aTexCoordLocation) drawQuad(
shader.blurPositionLoc, shader.blurTexCoordLoc,
fullscreenVertexBuffer, fullscreenTexCoordBuffer
)
} }
/**
* Updates blur parameters. Thread-safe.
*/
fun updateParameters(params: BlurParameters) { fun updateParameters(params: BlurParameters) {
blurParameters = params blurParameters = params
} }
/**
* Sets whether using front camera. Updates texture coordinates accordingly.
* Thread-safe - actual buffer update happens on next frame.
*/
fun setFrontCamera(front: Boolean) { fun setFrontCamera(front: Boolean) {
if (isFrontCamera != front) { if (isFrontCamera != front) {
isFrontCamera = front isFrontCamera = front
currentTexCoords = if (front) texCoordsFront else texCoordsBack currentTexCoords = if (front) texCoordsFront else texCoordsBack
// Buffer will be updated on next draw
updateTexCoordBuffer = true 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) { fun setCameraResolution(width: Int, height: Int) {
if (cameraWidth != width || cameraHeight != height) { if (cameraWidth != width || cameraHeight != height) {
cameraWidth = width cameraWidth = width
@ -204,45 +218,6 @@ class TiltShiftRenderer(
} }
} }
/**
* 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() { fun release() {
shader.release() shader.release()
surfaceTexture?.release() surfaceTexture?.release()
@ -252,5 +227,117 @@ class TiltShiftRenderer(
GLES20.glDeleteTextures(1, intArrayOf(cameraTextureId), 0) GLES20.glDeleteTextures(1, intArrayOf(cameraTextureId), 0)
cameraTextureId = 0 cameraTextureId = 0
} }
deleteFBOs()
}
// --- Private helpers ---
private fun drawQuad(
positionLoc: Int,
texCoordLoc: Int,
vertices: FloatBuffer,
texCoords: FloatBuffer
) {
GLES20.glEnableVertexAttribArray(positionLoc)
GLES20.glVertexAttribPointer(positionLoc, 2, GLES20.GL_FLOAT, false, 0, vertices)
GLES20.glEnableVertexAttribArray(texCoordLoc)
GLES20.glVertexAttribPointer(texCoordLoc, 2, GLES20.GL_FLOAT, false, 0, texCoords)
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
GLES20.glDisableVertexAttribArray(positionLoc)
GLES20.glDisableVertexAttribArray(texCoordLoc)
}
/**
* Recomputes camera 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 surface 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) {
val cameraRatio = cameraHeight.toFloat() / cameraWidth
val screenRatio = surfaceWidth.toFloat() / surfaceHeight
if (cameraRatio > screenRatio) {
scaleX = cameraRatio / screenRatio
} else {
scaleY = screenRatio / cameraRatio
}
}
cameraVertexBuffer.clear()
cameraVertexBuffer.put(floatArrayOf(
-scaleX, -scaleY,
scaleX, -scaleY,
-scaleX, scaleY,
scaleX, scaleY
))
cameraVertexBuffer.position(0)
}
private fun recreateFBOs(width: Int, height: Int) {
deleteFBOs()
// Create two color textures for ping-pong
val texIds = IntArray(2)
GLES20.glGenTextures(2, texIds, 0)
fboTexA = texIds[0]
fboTexB = texIds[1]
for (texId in texIds) {
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, texId)
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR)
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR)
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE)
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE)
GLES20.glTexImage2D(
GLES20.GL_TEXTURE_2D, 0, GLES20.GL_RGBA,
width, height, 0,
GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, null
)
}
// Create single FBO (we swap the attached texture for ping-pong)
val fbos = IntArray(1)
GLES20.glGenFramebuffers(1, fbos, 0)
fboId = fbos[0]
// Verify with texture A
GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, fboId)
GLES20.glFramebufferTexture2D(
GLES20.GL_FRAMEBUFFER, GLES20.GL_COLOR_ATTACHMENT0,
GLES20.GL_TEXTURE_2D, fboTexA, 0
)
val status = GLES20.glCheckFramebufferStatus(GLES20.GL_FRAMEBUFFER)
if (status != GLES20.GL_FRAMEBUFFER_COMPLETE) {
Log.e(TAG, "FBO incomplete: $status")
}
GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, 0)
}
private fun deleteFBOs() {
if (fboId != 0) {
GLES20.glDeleteFramebuffers(1, intArrayOf(fboId), 0)
fboId = 0
}
if (fboTexA != 0 || fboTexB != 0) {
GLES20.glDeleteTextures(2, intArrayOf(fboTexA, fboTexB), 0)
fboTexA = 0
fboTexB = 0
}
}
private fun allocateFloatBuffer(floatCount: Int): FloatBuffer {
return ByteBuffer.allocateDirect(floatCount * 4)
.order(ByteOrder.nativeOrder())
.asFloatBuffer()
} }
} }

View file

@ -4,57 +4,167 @@ import android.content.Context
import android.opengl.GLES11Ext import android.opengl.GLES11Ext
import android.opengl.GLES20 import android.opengl.GLES20
import no.naiv.tiltshift.R import no.naiv.tiltshift.R
import kotlin.math.cos
import kotlin.math.sin
import java.io.BufferedReader import java.io.BufferedReader
import java.io.InputStreamReader 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) { class TiltShiftShader(private val context: Context) {
var programId: Int = 0 // --- Passthrough program (camera → FBO) ---
private set
// Attribute locations private var passthroughProgramId: Int = 0
var aPositionLocation: Int = 0
private set
var aTexCoordLocation: Int = 0
private set
// Uniform locations var passthroughPositionLoc: Int = 0
private var uTextureLocation: Int = 0 private set
private var uModeLocation: Int = 0 var passthroughTexCoordLoc: Int = 0
private var uIsFrontCameraLocation: Int = 0 private set
private var uAngleLocation: Int = 0 private var passthroughTextureLoc: Int = 0
private var uPositionXLocation: Int = 0
private var uPositionYLocation: Int = 0 // --- Blur program (FBO → FBO/screen) ---
private var uSizeLocation: Int = 0
private var uBlurAmountLocation: Int = 0 private var blurProgramId: Int = 0
private var uFalloffLocation: Int = 0
private var uAspectRatioLocation: Int = 0 var blurPositionLoc: Int = 0
private var uResolutionLocation: Int = 0 private set
private var uCosAngleLocation: Int = 0 var blurTexCoordLoc: Int = 0
private var uSinAngleLocation: 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. * Must be called from GL thread.
*/ */
fun initialize() { fun initialize() {
val vertexSource = loadShaderSource(R.raw.tiltshift_vertex) val vertexSource = loadShaderSource(R.raw.tiltshift_vertex)
val fragmentSource = loadShaderSource(R.raw.tiltshift_fragment)
val vertexShader = compileShader(GLES20.GL_VERTEX_SHADER, vertexSource) 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, vertexShader)
GLES20.glAttachShader(programId, fragmentShader) GLES20.glAttachShader(programId, fragmentShader)
GLES20.glLinkProgram(programId) GLES20.glLinkProgram(programId)
// Check for link errors
val linkStatus = IntArray(1) val linkStatus = IntArray(1)
GLES20.glGetProgramiv(programId, GLES20.GL_LINK_STATUS, linkStatus, 0) GLES20.glGetProgramiv(programId, GLES20.GL_LINK_STATUS, linkStatus, 0)
if (linkStatus[0] == 0) { if (linkStatus[0] == 0) {
@ -63,72 +173,7 @@ class TiltShiftShader(private val context: Context) {
throw RuntimeException("Shader program link failed: $error") throw RuntimeException("Shader program link failed: $error")
} }
// Get attribute locations return programId
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
}
} }
private fun loadShaderSource(resourceId: Int): String { private fun loadShaderSource(resourceId: Int): String {
@ -142,7 +187,6 @@ class TiltShiftShader(private val context: Context) {
GLES20.glShaderSource(shader, source) GLES20.glShaderSource(shader, source)
GLES20.glCompileShader(shader) GLES20.glCompileShader(shader)
// Check for compile errors
val compileStatus = IntArray(1) val compileStatus = IntArray(1)
GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compileStatus, 0) GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compileStatus, 0)
if (compileStatus[0] == 0) { if (compileStatus[0] == 0) {

View file

@ -232,9 +232,9 @@ private fun determineGestureType(
return when { return when {
// Very center of focus zone -> rotation (small area) // Very center of focus zone -> rotation (small area)
distFromCenter < focusSize * 0.3f -> GestureType.ROTATE distFromCenter < focusSize * 0.3f -> GestureType.ROTATE
// Near the blur effect -> size adjustment (large area) // Near the blur boundary -> size adjustment
distFromCenter < focusSize * 2.0f -> GestureType.PINCH_SIZE distFromCenter < focusSize * 1.3f -> GestureType.PINCH_SIZE
// Far outside -> camera zoom // Outside the effect -> camera zoom
else -> GestureType.PINCH_ZOOM else -> GestureType.PINCH_ZOOM
} }
} }

View file

@ -1,71 +1,58 @@
#extension GL_OES_EGL_image_external : require // Fragment shader for tilt-shift blur pass (two-pass separable Gaussian)
// Reads from a sampler2D (FBO texture already in screen orientation).
// Fragment shader for tilt-shift effect // Used twice: once for horizontal blur, once for vertical blur.
// Supports both linear and radial blur modes
precision mediump float; precision mediump float;
// Camera texture (external texture for camera preview) uniform sampler2D uTexture;
uniform samplerExternalOES uTexture;
// Effect parameters // Effect parameters
uniform int uMode; // 0 = linear, 1 = radial uniform int uMode; // 0 = linear, 1 = radial
uniform int uIsFrontCamera; // 0 = back camera, 1 = front camera uniform float uPositionX; // Horizontal center of focus (0-1, screen space)
uniform float uAngle; // Rotation angle in radians uniform float uPositionY; // Vertical center of focus (0-1, screen space, 0 = top)
uniform float uPositionX; // Horizontal center of focus (0-1)
uniform float uPositionY; // Vertical center of focus (0-1)
uniform float uSize; // Size of in-focus region (0-1) uniform float uSize; // Size of in-focus region (0-1)
uniform float uBlurAmount; // Maximum blur intensity (0-1) uniform float uBlurAmount; // Maximum blur intensity (0-1)
uniform float uFalloff; // Transition sharpness (0-1, higher = more gradual) uniform float uFalloff; // Transition sharpness (0-1, higher = more gradual)
uniform float uAspectRatio; // Ellipse aspect ratio for radial mode uniform float uAspectRatio; // Ellipse aspect ratio for radial mode
uniform vec2 uResolution; // Texture resolution for proper sampling uniform vec2 uResolution; // Surface resolution for proper sampling
// Precomputed trig for the adjusted angle (avoids per-fragment cos/sin calls) // Precomputed trig for the raw screen-space angle
uniform float uCosAngle; uniform float uCosAngle;
uniform float uSinAngle; uniform float uSinAngle;
// Blur direction: (1,0) for horizontal pass, (0,1) for vertical pass
uniform vec2 uBlurDirection;
varying vec2 vTexCoord; varying vec2 vTexCoord;
// Calculate signed distance from the focus region for LINEAR mode // Calculate distance from the focus region for LINEAR mode
float linearFocusDistance(vec2 uv) { // Works in screen space: X right (0-1), Y down (0-1)
// Center point of the focus region // Distances are normalized to the Y axis (height) to match the overlay,
// Transform from screen coordinates to texture coordinates // which defines focus size as a fraction of screen height.
// Back camera: Screen (x,y) -> Texture (y, 1-x) float linearFocusDistance(vec2 screenPos) {
// Front camera: Screen (x,y) -> Texture (1-y, 1-x) (additional X flip for mirror) vec2 center = vec2(uPositionX, uPositionY);
vec2 center; vec2 offset = screenPos - center;
if (uIsFrontCamera == 1) {
center = vec2(1.0 - uPositionY, 1.0 - uPositionX);
} else {
center = vec2(uPositionY, 1.0 - uPositionX);
}
vec2 offset = uv - center;
// Correct for screen aspect ratio to make coordinate space square // Scale X into the same physical units as Y (height-normalized)
float screenAspect = uResolution.x / uResolution.y; float screenAspect = uResolution.x / uResolution.y;
offset.y *= screenAspect; offset.x *= screenAspect;
// Use precomputed cos/sin for the adjusted angle // Perpendicular distance to the rotated focus line
float rotatedY = -offset.x * uSinAngle + offset.y * uCosAngle; float rotatedY = -offset.x * uSinAngle + offset.y * uCosAngle;
return abs(rotatedY); return abs(rotatedY);
} }
// Calculate signed distance from the focus region for RADIAL mode // Calculate distance from the focus region for RADIAL mode
float radialFocusDistance(vec2 uv) { float radialFocusDistance(vec2 screenPos) {
// Center point of the focus region vec2 center = vec2(uPositionX, uPositionY);
vec2 center; vec2 offset = screenPos - center;
if (uIsFrontCamera == 1) {
center = vec2(1.0 - uPositionY, 1.0 - uPositionX);
} else {
center = vec2(uPositionY, 1.0 - uPositionX);
}
vec2 offset = uv - center;
// Correct for screen aspect ratio // Scale X into the same physical units as Y (height-normalized)
float screenAspect = uResolution.x / uResolution.y; float screenAspect = uResolution.x / uResolution.y;
offset.y *= screenAspect; offset.x *= screenAspect;
// Use precomputed cos/sin for rotation // Rotate offset
vec2 rotated = vec2( vec2 rotated = vec2(
offset.x * uCosAngle - offset.y * uSinAngle, offset.x * uCosAngle - offset.y * uSinAngle,
offset.x * uSinAngle + offset.y * uCosAngle offset.x * uSinAngle + offset.y * uCosAngle
@ -74,83 +61,59 @@ float radialFocusDistance(vec2 uv) {
// Apply ellipse aspect ratio // Apply ellipse aspect ratio
rotated.x /= uAspectRatio; rotated.x /= uAspectRatio;
// Distance from center (elliptical)
return length(rotated); return length(rotated);
} }
// Calculate blur factor based on distance from focus // Calculate blur factor based on distance from focus
float blurFactor(float dist) { float blurFactor(float dist) {
float halfSize = uSize * 0.5; float halfSize = uSize * 0.5;
// Falloff range scales with the falloff parameter float transitionSize = halfSize * uFalloff * 3.0;
float transitionSize = halfSize * uFalloff;
if (dist < halfSize) { if (dist < halfSize) {
return 0.0; // In focus region return 0.0;
} }
// Smooth falloff using smoothstep
float normalizedDist = (dist - halfSize) / max(transitionSize, 0.001); float normalizedDist = (dist - halfSize) / max(transitionSize, 0.001);
return smoothstep(0.0, 1.0, normalizedDist) * uBlurAmount; return smoothstep(0.0, 1.0, normalizedDist) * uBlurAmount;
} }
// Sample with Gaussian blur (9-tap, sigma ~= 2.0, unrolled for GLSL ES 1.00 compatibility)
vec4 sampleBlurred(vec2 uv, float blur) {
if (blur < 0.01) {
return texture2D(uTexture, uv);
}
vec2 texelSize = 1.0 / uResolution;
// For radial mode, blur in radial direction from center
// For linear mode, blur perpendicular to focus line
vec2 blurDir;
if (uMode == 1) {
// Radial: blur away from center
vec2 center;
if (uIsFrontCamera == 1) {
center = vec2(1.0 - uPositionY, 1.0 - uPositionX);
} else {
center = vec2(uPositionY, 1.0 - uPositionX);
}
vec2 toCenter = uv - center;
float len = length(toCenter);
if (len > 0.001) {
blurDir = toCenter / len;
} else {
blurDir = vec2(1.0, 0.0);
}
} else {
// Linear: blur perpendicular to focus line using precomputed trig
blurDir = vec2(uCosAngle, uSinAngle);
}
// Scale blur radius by blur amount
float radius = blur * 20.0;
vec2 step = blurDir * texelSize * radius;
// Unrolled 9-tap Gaussian blur (avoids integer-branched weight lookup)
vec4 color = vec4(0.0);
color += texture2D(uTexture, uv + step * -4.0) * 0.0162;
color += texture2D(uTexture, uv + step * -3.0) * 0.0540;
color += texture2D(uTexture, uv + step * -2.0) * 0.1216;
color += texture2D(uTexture, uv + step * -1.0) * 0.1933;
color += texture2D(uTexture, uv) * 0.2258;
color += texture2D(uTexture, uv + step * 1.0) * 0.1933;
color += texture2D(uTexture, uv + step * 2.0) * 0.1216;
color += texture2D(uTexture, uv + step * 3.0) * 0.0540;
color += texture2D(uTexture, uv + step * 4.0) * 0.0162;
return color;
}
void main() { void main() {
// Convert FBO texture coords to screen space (flip Y: GL bottom-up → screen top-down)
vec2 screenPos = vec2(vTexCoord.x, 1.0 - vTexCoord.y);
float dist; float dist;
if (uMode == 1) { if (uMode == 1) {
dist = radialFocusDistance(vTexCoord); dist = radialFocusDistance(screenPos);
} else { } else {
dist = linearFocusDistance(vTexCoord); dist = linearFocusDistance(screenPos);
} }
float blur = blurFactor(dist); float blur = blurFactor(dist);
gl_FragColor = sampleBlurred(vTexCoord, blur); if (blur < 0.01) {
gl_FragColor = texture2D(uTexture, vTexCoord);
return;
}
// 13-tap separable Gaussian (sigma ~= 2.5)
// Each pass blurs in one direction; combined gives a full 2D Gaussian.
vec2 texelSize = 1.0 / uResolution;
float radius = blur * 20.0;
vec2 step = uBlurDirection * texelSize * radius;
vec4 color = vec4(0.0);
color += texture2D(uTexture, vTexCoord + step * -6.0) * 0.0090;
color += texture2D(uTexture, vTexCoord + step * -5.0) * 0.0218;
color += texture2D(uTexture, vTexCoord + step * -4.0) * 0.0448;
color += texture2D(uTexture, vTexCoord + step * -3.0) * 0.0784;
color += texture2D(uTexture, vTexCoord + step * -2.0) * 0.1169;
color += texture2D(uTexture, vTexCoord + step * -1.0) * 0.1486;
color += texture2D(uTexture, vTexCoord) * 0.1610;
color += texture2D(uTexture, vTexCoord + step * 1.0) * 0.1486;
color += texture2D(uTexture, vTexCoord + step * 2.0) * 0.1169;
color += texture2D(uTexture, vTexCoord + step * 3.0) * 0.0784;
color += texture2D(uTexture, vTexCoord + step * 4.0) * 0.0448;
color += texture2D(uTexture, vTexCoord + step * 5.0) * 0.0218;
color += texture2D(uTexture, vTexCoord + step * 6.0) * 0.0090;
gl_FragColor = color;
} }

View file

@ -0,0 +1,15 @@
#extension GL_OES_EGL_image_external : require
// Passthrough fragment shader: copies camera texture to FBO
// This separates the camera coordinate transform (handled by vertex/texcoord setup)
// from the blur passes, which then work entirely in screen space.
precision mediump float;
uniform samplerExternalOES uTexture;
varying vec2 vTexCoord;
void main() {
gl_FragColor = texture2D(uTexture, vTexCoord);
}

View file

@ -1,4 +1,4 @@
versionMajor=1 versionMajor=1
versionMinor=1 versionMinor=1
versionPatch=2 versionPatch=3
versionCode=4 versionCode=5