Add 2D focus positioning, fix rotation tracking, sync preview with effect

2D positioning:
- Add positionX parameter to BlurParameters (was only Y before)
- Update shader with uPositionX and uPositionY uniforms
- Single-finger drag now moves focus center anywhere on screen
- Update gradient mask generation for capture

Rotation tracking:
- Remove dampening from rotation gesture (1:1 tracking)
- Rotate gesture now directly tracks finger movement
- Preview effect rotates in sync with overlay

Overlay and shader sync:
- Both now use same positionX, positionY, angle parameters
- Preview blur effect matches overlay visualization

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-01-28 15:55:17 +01:00
commit e53155e8ee
5 changed files with 53 additions and 41 deletions

View file

@ -204,7 +204,8 @@ class ImageCaptureHandler(
val mask = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) val mask = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val pixels = IntArray(width * height) val pixels = IntArray(width * height)
val centerY = height * params.position val centerX = width * params.positionX
val centerY = height * params.positionY
val focusHalfHeight = height * params.size * 0.5f val focusHalfHeight = height * params.size * 0.5f
val transitionHeight = focusHalfHeight * 0.5f val transitionHeight = focusHalfHeight * 0.5f
@ -213,8 +214,8 @@ class ImageCaptureHandler(
for (y in 0 until height) { for (y in 0 until height) {
for (x in 0 until width) { for (x in 0 until width) {
// Rotate point around center // Rotate point around focus center
val dx = x - width / 2f val dx = x - centerX
val dy = y - centerY val dy = y - centerY
val rotatedY = -dx * sinAngle + dy * cosAngle val rotatedY = -dx * sinAngle + dy * cosAngle

View file

@ -4,13 +4,15 @@ package no.naiv.tiltshift.effect
* Parameters controlling the tilt-shift blur effect. * Parameters controlling the tilt-shift blur effect.
* *
* @param angle The rotation angle of the blur gradient in radians (0 = horizontal blur bands) * @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 positionX The horizontal center of the in-focus region (0.0 to 1.0)
* @param positionY The vertical center of the in-focus region (0.0 to 1.0)
* @param size The size of the in-focus region (0.0 to 1.0, as fraction of screen height) * @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) * @param blurAmount The intensity of the blur effect (0.0 to 1.0)
*/ */
data class BlurParameters( data class BlurParameters(
val angle: Float = 0f, val angle: Float = 0f,
val position: Float = 0.5f, val positionX: Float = 0.5f,
val positionY: Float = 0.5f,
val size: Float = 0.3f, val size: Float = 0.3f,
val blurAmount: Float = 0.8f val blurAmount: Float = 0.8f
) { ) {
@ -25,17 +27,20 @@ data class BlurParameters(
} }
/** /**
* Returns a copy with the angle adjusted by the given delta. * Returns a copy with the angle set to the given value.
*/ */
fun withAngleDelta(delta: Float): BlurParameters { fun withAngle(newAngle: Float): BlurParameters {
return copy(angle = angle + delta) return copy(angle = newAngle)
} }
/** /**
* Returns a copy with the position clamped to valid range. * Returns a copy with the position clamped to valid range.
*/ */
fun withPosition(newPosition: Float): BlurParameters { fun withPosition(newX: Float, newY: Float): BlurParameters {
return copy(position = newPosition.coerceIn(0f, 1f)) return copy(
positionX = newX.coerceIn(0f, 1f),
positionY = newY.coerceIn(0f, 1f)
)
} }
/** /**

View file

@ -24,7 +24,8 @@ class TiltShiftShader(private val context: Context) {
// Uniform locations // Uniform locations
private var uTextureLocation: Int = 0 private var uTextureLocation: Int = 0
private var uAngleLocation: Int = 0 private var uAngleLocation: Int = 0
private var uPositionLocation: Int = 0 private var uPositionXLocation: Int = 0
private var uPositionYLocation: Int = 0
private var uSizeLocation: Int = 0 private var uSizeLocation: Int = 0
private var uBlurAmountLocation: Int = 0 private var uBlurAmountLocation: Int = 0
private var uResolutionLocation: Int = 0 private var uResolutionLocation: Int = 0
@ -61,7 +62,8 @@ class TiltShiftShader(private val context: Context) {
// Get uniform locations // Get uniform locations
uTextureLocation = GLES20.glGetUniformLocation(programId, "uTexture") uTextureLocation = GLES20.glGetUniformLocation(programId, "uTexture")
uAngleLocation = GLES20.glGetUniformLocation(programId, "uAngle") uAngleLocation = GLES20.glGetUniformLocation(programId, "uAngle")
uPositionLocation = GLES20.glGetUniformLocation(programId, "uPosition") uPositionXLocation = GLES20.glGetUniformLocation(programId, "uPositionX")
uPositionYLocation = GLES20.glGetUniformLocation(programId, "uPositionY")
uSizeLocation = GLES20.glGetUniformLocation(programId, "uSize") uSizeLocation = GLES20.glGetUniformLocation(programId, "uSize")
uBlurAmountLocation = GLES20.glGetUniformLocation(programId, "uBlurAmount") uBlurAmountLocation = GLES20.glGetUniformLocation(programId, "uBlurAmount")
uResolutionLocation = GLES20.glGetUniformLocation(programId, "uResolution") uResolutionLocation = GLES20.glGetUniformLocation(programId, "uResolution")
@ -84,7 +86,8 @@ class TiltShiftShader(private val context: Context) {
// Set effect parameters // Set effect parameters
GLES20.glUniform1f(uAngleLocation, params.angle) GLES20.glUniform1f(uAngleLocation, params.angle)
GLES20.glUniform1f(uPositionLocation, params.position) GLES20.glUniform1f(uPositionXLocation, params.positionX)
GLES20.glUniform1f(uPositionYLocation, params.positionY)
GLES20.glUniform1f(uSizeLocation, params.size) GLES20.glUniform1f(uSizeLocation, params.size)
GLES20.glUniform1f(uBlurAmountLocation, params.blurAmount) GLES20.glUniform1f(uBlurAmountLocation, params.blurAmount)
GLES20.glUniform2f(uResolutionLocation, width.toFloat(), height.toFloat()) GLES20.glUniform2f(uResolutionLocation, width.toFloat(), height.toFloat())

View file

@ -33,17 +33,17 @@ import kotlin.math.sin
*/ */
private enum class GestureType { private enum class GestureType {
NONE, NONE,
DRAG_POSITION, // Single finger drag to move focus position DRAG_POSITION, // Single finger drag to move focus center
ROTATE, // Two-finger rotation ROTATE, // Two-finger rotation
PINCH_SIZE, // Pinch near blur edges to resize PINCH_SIZE, // Pinch near blur edges to resize
PINCH_ZOOM // Pinch in center to zoom camera PINCH_ZOOM // Pinch in center to zoom camera
} }
// Sensitivity factors for gesture controls (lower = less sensitive) // Sensitivity factors for gesture controls (lower = less sensitive)
private const val POSITION_SENSITIVITY = 0.15f // Drag to move focus line // Rotation uses 1:1 tracking (no dampening) for natural feel
private const val ROTATION_SENSITIVITY = 0.2f // Two-finger rotation private const val POSITION_SENSITIVITY = 0.5f // Drag to move focus center
private const val SIZE_SENSITIVITY = 0.25f // Pinch to resize blur zone private const val SIZE_SENSITIVITY = 0.3f // Pinch to resize blur zone
private const val ZOOM_SENSITIVITY = 0.4f // Pinch to zoom camera private const val ZOOM_SENSITIVITY = 0.5f // Pinch to zoom camera
/** /**
* Overlay that shows tilt-shift effect controls and handles gestures. * Overlay that shows tilt-shift effect controls and handles gestures.
@ -56,10 +56,10 @@ fun TiltShiftOverlay(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
var currentGesture by remember { mutableStateOf(GestureType.NONE) } var currentGesture by remember { mutableStateOf(GestureType.NONE) }
var initialZoom by remember { mutableFloatStateOf(1f) }
var initialAngle by remember { mutableFloatStateOf(0f) } var initialAngle by remember { mutableFloatStateOf(0f) }
var initialSize by remember { mutableFloatStateOf(0.3f) } var initialSize by remember { mutableFloatStateOf(0.3f) }
var initialPosition by remember { mutableFloatStateOf(0.5f) } var initialPositionX by remember { mutableFloatStateOf(0.5f) }
var initialPositionY by remember { mutableFloatStateOf(0.5f) }
Canvas( Canvas(
modifier = modifier modifier = modifier
@ -70,15 +70,15 @@ fun TiltShiftOverlay(
currentGesture = GestureType.NONE currentGesture = GestureType.NONE
var previousCentroid = firstDown.position var previousCentroid = firstDown.position
var previousPointerCount = 1
var accumulatedRotation = 0f var accumulatedRotation = 0f
var accumulatedZoom = 1f var accumulatedZoom = 1f
var accumulatedDragX = 0f
var accumulatedDragY = 0f var accumulatedDragY = 0f
initialAngle = params.angle initialAngle = params.angle
initialSize = params.size initialSize = params.size
initialPosition = params.position initialPositionX = params.positionX
initialZoom = 1f initialPositionY = params.positionY
do { do {
val event = awaitPointerEvent() val event = awaitPointerEvent()
@ -110,8 +110,8 @@ fun TiltShiftOverlay(
when (currentGesture) { when (currentGesture) {
GestureType.ROTATE -> { GestureType.ROTATE -> {
// Apply dampening to rotation // 1:1 rotation tracking - no dampening
accumulatedRotation += rotation * ROTATION_SENSITIVITY accumulatedRotation += rotation
val newAngle = initialAngle + accumulatedRotation val newAngle = initialAngle + accumulatedRotation
onParamsChange(params.copy(angle = newAngle)) onParamsChange(params.copy(angle = newAngle))
} }
@ -132,24 +132,25 @@ fun TiltShiftOverlay(
} }
} }
// Single finger // Single finger - drag to move focus center (2D)
pointers.size == 1 -> { pointers.size == 1 -> {
if (currentGesture == GestureType.NONE) { if (currentGesture == GestureType.NONE) {
currentGesture = GestureType.DRAG_POSITION currentGesture = GestureType.DRAG_POSITION
} }
if (currentGesture == GestureType.DRAG_POSITION) { if (currentGesture == GestureType.DRAG_POSITION) {
// Apply dampening to position drag val deltaX = (centroid.x - previousCentroid.x) / size.width
val deltaY = (centroid.y - previousCentroid.y) / size.height val deltaY = (centroid.y - previousCentroid.y) / size.height
accumulatedDragX += deltaX * POSITION_SENSITIVITY
accumulatedDragY += deltaY * POSITION_SENSITIVITY accumulatedDragY += deltaY * POSITION_SENSITIVITY
val newPosition = (initialPosition + accumulatedDragY).coerceIn(0f, 1f) val newX = (initialPositionX + accumulatedDragX).coerceIn(0f, 1f)
onParamsChange(params.copy(position = newPosition)) val newY = (initialPositionY + accumulatedDragY).coerceIn(0f, 1f)
onParamsChange(params.copy(positionX = newX, positionY = newY))
} }
} }
} }
previousCentroid = centroid previousCentroid = centroid
previousPointerCount = pointers.size
// Consume all pointer changes // Consume all pointer changes
pointers.forEach { it.consume() } pointers.forEach { it.consume() }
@ -172,12 +173,13 @@ private fun determineGestureType(
height: Float, height: Float,
params: BlurParameters params: BlurParameters
): GestureType { ): GestureType {
// Calculate distance from focus center line // Calculate distance from focus center
val focusCenterY = height * params.position val focusCenterX = width * params.positionX
val focusCenterY = height * params.positionY
val focusHalfHeight = height * params.size * 0.5f val focusHalfHeight = height * params.size * 0.5f
// Rotate centroid to align with focus line // Rotate centroid to align with focus line
val dx = centroid.x - width / 2f val dx = centroid.x - focusCenterX
val dy = centroid.y - focusCenterY val dy = centroid.y - focusCenterY
val rotatedY = -dx * sin(params.angle) + dy * cos(params.angle) val rotatedY = -dx * sin(params.angle) + dy * cos(params.angle)
@ -207,8 +209,8 @@ private fun DrawScope.drawTiltShiftOverlay(params: BlurParameters) {
val width = size.width val width = size.width
val height = size.height val height = size.height
val centerX = width / 2f val centerX = width * params.positionX
val centerY = height * params.position val centerY = height * params.positionY
val focusHalfHeight = height * params.size * 0.5f val focusHalfHeight = height * params.size * 0.5f
val angleDegrees = params.angle * (180f / PI.toFloat()) val angleDegrees = params.angle * (180f / PI.toFloat())

View file

@ -10,7 +10,8 @@ uniform samplerExternalOES uTexture;
// Effect parameters // Effect parameters
uniform float uAngle; // Rotation angle in radians uniform float uAngle; // Rotation angle in radians
uniform float uPosition; // Center position of focus (0-1) 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 vec2 uResolution; // Texture resolution for proper sampling uniform vec2 uResolution; // Texture resolution for proper sampling
@ -19,15 +20,15 @@ varying vec2 vTexCoord;
// Calculate signed distance from the focus line // Calculate signed distance from the focus line
float focusDistance(vec2 uv) { float focusDistance(vec2 uv) {
// Rotate coordinate system around center // Center point of the focus region
vec2 center = vec2(0.5, uPosition); vec2 center = vec2(uPositionX, uPositionY);
vec2 rotated = uv - center; vec2 offset = uv - center;
float cosA = cos(uAngle); float cosA = cos(uAngle);
float sinA = sin(uAngle); float sinA = sin(uAngle);
// After rotation, measure vertical distance from center line // After rotation, measure perpendicular distance from center line
float rotatedY = -rotated.x * sinA + rotated.y * cosA; float rotatedY = -offset.x * sinA + offset.y * cosA;
return abs(rotatedY); return abs(rotatedY);
} }