diff --git a/app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt b/app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt index 51d42ed..7f7bed5 100644 --- a/app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt +++ b/app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt @@ -2,8 +2,10 @@ package no.naiv.tiltshift.camera import android.content.Context import android.graphics.SurfaceTexture +import android.hardware.display.DisplayManager import android.util.Log import android.util.Size +import android.view.Display import android.view.Surface import androidx.camera.core.Camera import androidx.camera.core.CameraSelector @@ -70,6 +72,14 @@ class CameraManager(private val context: Context) { /** Weak reference to avoid preventing Activity GC across config changes. */ private var lifecycleOwnerRef: WeakReference? = null + /** + * Target rotation passed to CameraX use cases. Drives the SurfaceTexture + * transform matrix and the rotation metadata on captured images. + * Initialized to the display rotation when the camera binds; updated by + * [setTargetRotation] when the device orientation changes. + */ + private var targetRotation: Int = Surface.ROTATION_0 + /** * Starts the camera with the given lifecycle owner. * The surfaceTextureProvider should return the SurfaceTexture from the GL renderer. @@ -80,6 +90,13 @@ class CameraManager(private val context: Context) { ) { this.surfaceTextureProvider = surfaceTextureProvider this.lifecycleOwnerRef = WeakReference(lifecycleOwner) + // Capture initial display rotation so the very first frame is oriented correctly, + // before the OrientationEventListener has had a chance to fire. + // Note: Context.getDisplay() throws on Application contexts; DisplayManager works + // for any context type and returns the default display. + targetRotation = (context.getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager) + ?.getDisplay(Display.DEFAULT_DISPLAY)?.rotation + ?: Surface.ROTATION_0 val cameraProviderFuture = ProcessCameraProvider.getInstance(context) cameraProviderFuture.addListener({ @@ -110,11 +127,13 @@ class CameraManager(private val context: Context) { preview = Preview.Builder() .setResolutionSelector(resolutionSelector) + .setTargetRotation(targetRotation) .build() // Image capture use case val captureBuilder = ImageCapture.Builder() .setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY) + .setTargetRotation(targetRotation) imageCapture = captureBuilder.build() @@ -173,6 +192,23 @@ class CameraManager(private val context: Context) { } } + /** + * Updates the target rotation for Preview and ImageCapture use cases. + * + * Rebinds the use cases so CameraX issues a fresh SurfaceRequest with a + * resolution matching the new rotation and a corresponding texture transform + * matrix. Calling `preview.targetRotation = rotation` alone is insufficient + * for a custom SurfaceProvider — the new rotation only takes effect on + * subsequently bound streams, leaving the live SurfaceTexture matrix and + * buffer size stale (which made the preview appear locked to the original + * portrait orientation when the device was rotated to landscape). + */ + fun setTargetRotation(rotation: Int) { + if (targetRotation == rotation) return + targetRotation = rotation + lifecycleOwnerRef?.get()?.let { bindCameraUseCases(it) } + } + /** * 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). diff --git a/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt b/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt index dc2b332..f7f247e 100644 --- a/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt +++ b/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt @@ -5,6 +5,7 @@ import android.graphics.SurfaceTexture import android.opengl.GLES11Ext import android.opengl.GLES20 import android.opengl.GLSurfaceView +import android.opengl.Matrix import android.util.Log import android.view.Surface import java.nio.ByteBuffer @@ -39,9 +40,8 @@ class TiltShiftRenderer( private var surfaceTexture: SurfaceTexture? = null private var cameraTextureId: Int = 0 - // Camera quad: crop-to-fill vertices + rotated texcoords (pass 1 only) + // Camera quad: crop-to-fill vertices, standard texcoords (rotation comes from texMatrix) private lateinit var cameraVertexBuffer: FloatBuffer - private lateinit var cameraTexCoordBuffer: FloatBuffer // Fullscreen quad for blur passes (no crop, standard texcoords) private lateinit var fullscreenVertexBuffer: FloatBuffer @@ -55,6 +55,11 @@ class TiltShiftRenderer( private var fboTexA: Int = 0 private var fboTexB: Int = 0 + // SurfaceTexture transform matrix, refreshed each frame on the GL thread. + private val texMatrix = FloatArray(16) + // Sensor-to-buffer matrix from SurfaceTexture before display-rotation correction. + private val sensorMatrix = FloatArray(16) + // Current effect parameters (updated from UI thread) @Volatile var blurParameters: BlurParameters = BlurParameters.DEFAULT @@ -67,38 +72,13 @@ class TiltShiftRenderer( private var cameraWidth: Int = 0 @Volatile private var cameraHeight: Int = 0 - @Volatile - private var vertexBufferDirty: Boolean = false - - // Texture coordinates for the back camera, indexed by Surface.ROTATION_*. - // The base orientation (index 0) applies the 90° CCW rotation that maps - // the landscape sensor frame to a portrait display. Indices 1/2/3 layer - // additional CCW rotations on top so the activity's rotation is - // compensated and world-up stays at clip-space-top. - private val texCoordsBackByRotation = arrayOf( - floatArrayOf(1f, 1f, 1f, 0f, 0f, 1f, 0f, 0f), // ROTATION_0 - floatArrayOf(1f, 0f, 0f, 0f, 1f, 1f, 0f, 1f), // ROTATION_90 - floatArrayOf(0f, 0f, 0f, 1f, 1f, 0f, 1f, 1f), // ROTATION_180 - floatArrayOf(0f, 1f, 1f, 1f, 0f, 0f, 1f, 0f) // ROTATION_270 - ) - - // Front camera variants: same as back, but horizontally mirrored - // for the natural selfie view. - private val texCoordsFrontByRotation = arrayOf( - floatArrayOf(0f, 1f, 0f, 0f, 1f, 1f, 1f, 0f), // ROTATION_0 - floatArrayOf(0f, 0f, 1f, 0f, 0f, 1f, 1f, 1f), // ROTATION_90 - floatArrayOf(1f, 0f, 1f, 1f, 0f, 0f, 0f, 1f), // ROTATION_180 - floatArrayOf(1f, 1f, 0f, 1f, 1f, 0f, 0f, 0f) // ROTATION_270 - ) + /** Display rotation as a Surface.ROTATION_* constant; affects effective aspect. */ @Volatile private var displayRotation: Int = Surface.ROTATION_0 @Volatile - private var currentTexCoords = texCoordsBackByRotation[Surface.ROTATION_0] - - @Volatile - private var updateTexCoordBuffer = false + private var vertexBufferDirty: Boolean = false override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) { GLES20.glClearColor(0f, 0f, 0f, 1f) @@ -111,12 +91,8 @@ class TiltShiftRenderer( cameraVertexBuffer.put(floatArrayOf(-1f, -1f, 1f, -1f, -1f, 1f, 1f, 1f)) cameraVertexBuffer.position(0) - // Camera texcoord buffer (rotated for portrait) - cameraTexCoordBuffer = allocateFloatBuffer(8) - cameraTexCoordBuffer.put(currentTexCoords) - cameraTexCoordBuffer.position(0) - - // Fullscreen quad for blur passes (standard coords) + // Fullscreen quad for blur passes (standard coords). The same buffer is reused + // for the camera passthrough texcoords — rotation is applied via uTexMatrix. fullscreenVertexBuffer = allocateFloatBuffer(8) fullscreenVertexBuffer.put(floatArrayOf(-1f, -1f, 1f, -1f, -1f, 1f, 1f, 1f)) fullscreenVertexBuffer.position(0) @@ -152,20 +128,19 @@ class TiltShiftRenderer( } override fun onDrawFrame(gl: GL10?) { - surfaceTexture?.updateTexImage() + val st = surfaceTexture + st?.updateTexImage() + // SurfaceTexture's transform matrix only handles the sensor-to-buffer + // orientation; for a custom SurfaceProvider it does NOT vary with + // Preview.targetRotation. Apply the display-rotation correction here. + st?.getTransformMatrix(sensorMatrix) + composeTexMatrix() if (vertexBufferDirty) { recomputeVertices() vertexBufferDirty = false } - if (updateTexCoordBuffer) { - cameraTexCoordBuffer.clear() - cameraTexCoordBuffer.put(currentTexCoords) - cameraTexCoordBuffer.position(0) - updateTexCoordBuffer = false - } - val params = blurParameters // --- Pass 1: Camera → FBO-A (passthrough with crop-to-fill) --- @@ -176,10 +151,10 @@ class TiltShiftRenderer( ) GLES20.glViewport(0, 0, surfaceWidth, surfaceHeight) GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT) - shader.usePassthrough(cameraTextureId) + shader.usePassthrough(cameraTextureId, texMatrix, isFrontCamera) drawQuad( shader.passthroughPositionLoc, shader.passthroughTexCoordLoc, - cameraVertexBuffer, cameraTexCoordBuffer + cameraVertexBuffer, fullscreenTexCoordBuffer ) // --- Pass 2: FBO-A → FBO-B (horizontal blur) --- @@ -210,10 +185,7 @@ class TiltShiftRenderer( } fun setFrontCamera(front: Boolean) { - if (isFrontCamera != front) { - isFrontCamera = front - refreshTexCoords() - } + isFrontCamera = front } fun setCameraResolution(width: Int, height: Int) { @@ -224,25 +196,47 @@ class TiltShiftRenderer( } } - /** - * Updates the active display rotation. The texture-coordinate buffer is - * rebuilt so the camera image stays world-aligned as the activity rotates - * with the device under screenOrientation="fullSensor", and the - * crop-to-fill math picks the correct effective aspect ratio. - */ + /** Updates the display rotation so crop-to-fill picks the right effective aspect. */ fun setDisplayRotation(rotation: Int) { if (displayRotation != rotation) { displayRotation = rotation - refreshTexCoords() vertexBufferDirty = true } } - private fun refreshTexCoords() { - val table = if (isFrontCamera) texCoordsFrontByRotation else texCoordsBackByRotation - val idx = displayRotation.coerceIn(0, table.size - 1) - currentTexCoords = table[idx] - updateTexCoordBuffer = true + /** + * Combines the sensor-to-buffer matrix with a rotation that compensates for + * the activity's current rotation. The activity rotates with the device + * (screenOrientation="fullSensor"), so the GL clip-space "up" direction + * tracks the device rather than the world. To keep world-up at screen-up + * regardless of orientation, rotate the texcoord sampling pattern by the + * inverse of the activity rotation. Without this correction, the two + * landscape orientations would render the same matrix and one would appear + * upside-down on a real device. + */ + private fun composeTexMatrix() { + // Inverse of the activity rotation: rotating the sampling pattern by + // -activityAngle puts the world-aligned point originally at screen P + // at the same screen P after the activity has rotated. + val angle = when (displayRotation) { + Surface.ROTATION_90 -> 90f + Surface.ROTATION_180 -> 180f + Surface.ROTATION_270 -> -90f + else -> 0f + } + if (angle == 0f) { + System.arraycopy(sensorMatrix, 0, texMatrix, 0, 16) + return + } + // Build rotation around the (0.5, 0.5) texcoord center. + Matrix.setIdentityM(texMatrix, 0) + Matrix.translateM(texMatrix, 0, 0.5f, 0.5f, 0f) + Matrix.rotateM(texMatrix, 0, angle, 0f, 0f, 1f) + Matrix.translateM(texMatrix, 0, -0.5f, -0.5f, 0f) + // texMatrix = sensorMatrix * rotation (rotation is applied to the + // texcoord first, then the sensor-to-buffer transform). + val rot = texMatrix.copyOf() + Matrix.multiplyMM(texMatrix, 0, sensorMatrix, 0, rot, 0) } fun release() { @@ -281,10 +275,11 @@ class TiltShiftRenderer( /** * Recomputes camera vertex positions to achieve crop-to-fill. * - * The camera sensor is landscape; after the orientation-dependent texcoord - * rotation, the effective dimensions seen on screen are either swapped - * (portrait orientations) or kept (landscape orientations). We scale the - * vertex quad so the camera frame fills the surface without stretching — + * The camera buffer is the sensor's native landscape resolution. The texMatrix + * rotates it to match the display, so the *effective* displayed dimensions + * depend on the display rotation: in portrait the buffer is rotated 90° + * (effective width = cameraHeight), in landscape it is unrotated. + * We scale the vertex quad so the frame fills the surface without stretching — * the GPU clips the overflow. */ private fun recomputeVertices() { @@ -296,6 +291,7 @@ class TiltShiftRenderer( displayRotation == Surface.ROTATION_180 val effectiveW = if (isPortrait) cameraHeight else cameraWidth val effectiveH = if (isPortrait) cameraWidth else cameraHeight + val cameraRatio = effectiveW.toFloat() / effectiveH val screenRatio = surfaceWidth.toFloat() / surfaceHeight diff --git a/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftShader.kt b/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftShader.kt index cdafd8f..ebbfb18 100644 --- a/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftShader.kt +++ b/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftShader.kt @@ -20,6 +20,15 @@ import kotlin.math.sin */ 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 @@ -29,6 +38,8 @@ class TiltShiftShader(private val context: Context) { 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) --- @@ -39,6 +50,8 @@ class TiltShiftShader(private val context: Context) { 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 @@ -68,6 +81,8 @@ class TiltShiftShader(private val context: Context) { 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) @@ -78,6 +93,8 @@ class TiltShiftShader(private val context: Context) { 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") @@ -96,12 +113,19 @@ class TiltShiftShader(private val context: Context) { /** * 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) { + 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) } /** @@ -128,6 +152,10 @@ class TiltShiftShader(private val context: Context) { 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) diff --git a/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt b/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt index d5999ed..167b376 100644 --- a/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt +++ b/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt @@ -112,6 +112,7 @@ fun CameraScreen( val isFrontCamera by viewModel.cameraManager.isFrontCamera.collectAsState() val previewResolution by viewModel.cameraManager.previewResolution.collectAsState() val cameraError by viewModel.cameraManager.error.collectAsState() + val currentRotation by viewModel.currentRotation.collectAsState() // Gallery picker val galleryLauncher = rememberLauncherForActivityResult( @@ -164,11 +165,10 @@ fun CameraScreen( } } - // Forward device rotation to renderer so the camera image stays - // world-aligned as the activity rotates with the device. - val currentRotation by viewModel.currentRotation.collectAsState() + // Forward device rotation to renderer (aspect math) and CameraX (target rotation) LaunchedEffect(currentRotation, renderer) { renderer?.setDisplayRotation(currentRotation) + viewModel.cameraManager.setTargetRotation(currentRotation) glSurfaceView?.requestRender() } diff --git a/app/src/main/java/no/naiv/tiltshift/ui/ControlPanel.kt b/app/src/main/java/no/naiv/tiltshift/ui/ControlPanel.kt index ad94ad0..dbc5112 100644 --- a/app/src/main/java/no/naiv/tiltshift/ui/ControlPanel.kt +++ b/app/src/main/java/no/naiv/tiltshift/ui/ControlPanel.kt @@ -12,7 +12,10 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.RestartAlt import androidx.compose.material3.Icon @@ -110,8 +113,10 @@ fun ControlPanel( Column( modifier = modifier .width(200.dp) + .wrapContentHeight() .clip(RoundedCornerShape(16.dp)) .background(AppColors.OverlayDarker) + .verticalScroll(rememberScrollState()) .padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { diff --git a/app/src/main/res/raw/tiltshift_vertex.glsl b/app/src/main/res/raw/tiltshift_vertex.glsl index e009424..ca79319 100644 --- a/app/src/main/res/raw/tiltshift_vertex.glsl +++ b/app/src/main/res/raw/tiltshift_vertex.glsl @@ -1,12 +1,22 @@ -// Vertex shader for tilt-shift effect -// Passes through position and calculates texture coordinates +// Vertex shader for tilt-shift effect. +// +// uTexMatrix: applied to texcoords. For the passthrough pass it carries the +// SurfaceTexture transform (sensor → display rotation, plus Y-flip). For the +// blur passes it is identity. +// uMirrorX: 1.0 to horizontally mirror texcoords (front-camera selfie view), +// 0.0 otherwise. Applied AFTER uTexMatrix. attribute vec4 aPosition; attribute vec2 aTexCoord; +uniform mat4 uTexMatrix; +uniform float uMirrorX; + varying vec2 vTexCoord; void main() { gl_Position = aPosition; - vTexCoord = aTexCoord; + vec2 tc = (uTexMatrix * vec4(aTexCoord, 0.0, 1.0)).xy; + if (uMirrorX > 0.5) tc.x = 1.0 - tc.x; + vTexCoord = tc; } diff --git a/version.properties b/version.properties index aaf41eb..6ca2659 100644 --- a/version.properties +++ b/version.properties @@ -1,4 +1,4 @@ versionMajor=1 versionMinor=1 -versionPatch=12 -versionCode=14 +versionPatch=11 +versionCode=13