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 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 transitionHeight = focusHalfHeight * 0.5f
@ -213,8 +214,8 @@ class ImageCaptureHandler(
for (y in 0 until height) {
for (x in 0 until width) {
// Rotate point around center
val dx = x - width / 2f
// Rotate point around focus center
val dx = x - centerX
val dy = y - centerY
val rotatedY = -dx * sinAngle + dy * cosAngle

View file

@ -4,13 +4,15 @@ 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 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 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 positionX: Float = 0.5f,
val positionY: Float = 0.5f,
val size: Float = 0.3f,
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 {
return copy(angle = angle + delta)
fun withAngle(newAngle: Float): BlurParameters {
return copy(angle = newAngle)
}
/**
* Returns a copy with the position clamped to valid range.
*/
fun withPosition(newPosition: Float): BlurParameters {
return copy(position = newPosition.coerceIn(0f, 1f))
fun withPosition(newX: Float, newY: Float): BlurParameters {
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
private var uTextureLocation: 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 uBlurAmountLocation: Int = 0
private var uResolutionLocation: Int = 0
@ -61,7 +62,8 @@ class TiltShiftShader(private val context: Context) {
// Get uniform locations
uTextureLocation = GLES20.glGetUniformLocation(programId, "uTexture")
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")
uBlurAmountLocation = GLES20.glGetUniformLocation(programId, "uBlurAmount")
uResolutionLocation = GLES20.glGetUniformLocation(programId, "uResolution")
@ -84,7 +86,8 @@ class TiltShiftShader(private val context: Context) {
// Set effect parameters
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(uBlurAmountLocation, params.blurAmount)
GLES20.glUniform2f(uResolutionLocation, width.toFloat(), height.toFloat())

View file

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

View file

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