From b0691adfa384bcee9acaa0437b18f7dae35b881b Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Mon, 11 May 2026 15:57:32 +0200 Subject: [PATCH] Reapply "Revert orientation tracking on the camera image" This reverts commit c0bab85d63d66180400ae88d3a5bf5eda5e97ee5. --- .../no/naiv/tiltshift/camera/CameraManager.kt | 36 ----- .../tiltshift/effect/TiltShiftRenderer.kt | 131 +++++++----------- .../naiv/tiltshift/effect/TiltShiftShader.kt | 30 +--- .../java/no/naiv/tiltshift/ui/CameraScreen.kt | 8 -- .../java/no/naiv/tiltshift/ui/ControlPanel.kt | 5 - app/src/main/res/raw/tiltshift_vertex.glsl | 16 +-- version.properties | 4 +- 7 files changed, 57 insertions(+), 173 deletions(-) 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 7f7bed5..51d42ed 100644 --- a/app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt +++ b/app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt @@ -2,10 +2,8 @@ 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 @@ -72,14 +70,6 @@ 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. @@ -90,13 +80,6 @@ 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({ @@ -127,13 +110,11 @@ 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() @@ -192,23 +173,6 @@ 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 e8ff4bd..d170d58 100644 --- a/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt +++ b/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt @@ -5,9 +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 import java.nio.ByteOrder import java.nio.FloatBuffer @@ -40,8 +38,9 @@ class TiltShiftRenderer( private var surfaceTexture: SurfaceTexture? = null private var cameraTextureId: Int = 0 - // Camera quad: crop-to-fill vertices, standard texcoords (rotation comes from texMatrix) + // Camera quad: crop-to-fill vertices + rotated texcoords (pass 1 only) 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,11 +54,6 @@ 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 @@ -72,14 +66,33 @@ class TiltShiftRenderer( private var cameraWidth: Int = 0 @Volatile private var cameraHeight: Int = 0 - - /** Display rotation as a Surface.ROTATION_* constant; affects effective aspect. */ - @Volatile - private var displayRotation: Int = Surface.ROTATION_0 - @Volatile private var vertexBufferDirty: Boolean = false + // Texture coordinates rotated 90° for portrait mode (back camera) + // (Camera sensors are landscape-oriented, we rotate to portrait) + private val texCoordsBack = floatArrayOf( + 1f, 1f, // Bottom left of screen -> bottom right of texture + 1f, 0f, // Bottom right of screen -> top right of texture + 0f, 1f, // Top left of screen -> bottom left of texture + 0f, 0f // Top right of screen -> top left of texture + ) + + // Texture coordinates for front camera (mirrored + rotated) + // Front camera needs horizontal mirror for natural selfie view + private val texCoordsFront = floatArrayOf( + 0f, 1f, // Bottom left of screen + 0f, 0f, // Bottom right of screen + 1f, 1f, // Top left of screen + 1f, 0f // Top right of screen + ) + + @Volatile + private var currentTexCoords = texCoordsBack + + @Volatile + private var updateTexCoordBuffer = false + override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) { GLES20.glClearColor(0f, 0f, 0f, 1f) @@ -91,8 +104,12 @@ class TiltShiftRenderer( cameraVertexBuffer.put(floatArrayOf(-1f, -1f, 1f, -1f, -1f, 1f, 1f, 1f)) cameraVertexBuffer.position(0) - // Fullscreen quad for blur passes (standard coords). The same buffer is reused - // for the camera passthrough texcoords — rotation is applied via uTexMatrix. + // Camera texcoord buffer (rotated for portrait) + cameraTexCoordBuffer = allocateFloatBuffer(8) + cameraTexCoordBuffer.put(currentTexCoords) + cameraTexCoordBuffer.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) @@ -128,19 +145,20 @@ class TiltShiftRenderer( } override fun onDrawFrame(gl: GL10?) { - 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() + surfaceTexture?.updateTexImage() 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) --- @@ -151,10 +169,10 @@ class TiltShiftRenderer( ) GLES20.glViewport(0, 0, surfaceWidth, surfaceHeight) GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT) - shader.usePassthrough(cameraTextureId, texMatrix, isFrontCamera) + shader.usePassthrough(cameraTextureId) drawQuad( shader.passthroughPositionLoc, shader.passthroughTexCoordLoc, - cameraVertexBuffer, fullscreenTexCoordBuffer + cameraVertexBuffer, cameraTexCoordBuffer ) // --- Pass 2: FBO-A → FBO-B (horizontal blur) --- @@ -185,7 +203,11 @@ class TiltShiftRenderer( } fun setFrontCamera(front: Boolean) { - isFrontCamera = front + if (isFrontCamera != front) { + isFrontCamera = front + currentTexCoords = if (front) texCoordsFront else texCoordsBack + updateTexCoordBuffer = true + } } fun setCameraResolution(width: Int, height: Int) { @@ -196,49 +218,6 @@ class TiltShiftRenderer( } } - /** Updates the display rotation so crop-to-fill picks the right effective aspect. */ - fun setDisplayRotation(rotation: Int) { - if (displayRotation != rotation) { - displayRotation = rotation - vertexBufferDirty = 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() { shader.release() surfaceTexture?.release() @@ -275,24 +254,16 @@ class TiltShiftRenderer( /** * Recomputes camera vertex positions to achieve crop-to-fill. * - * 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. + * 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 isPortrait = displayRotation == Surface.ROTATION_0 || - displayRotation == Surface.ROTATION_180 - val effectiveW = if (isPortrait) cameraHeight else cameraWidth - val effectiveH = if (isPortrait) cameraWidth else cameraHeight - - val cameraRatio = effectiveW.toFloat() / effectiveH + val cameraRatio = cameraHeight.toFloat() / cameraWidth val screenRatio = surfaceWidth.toFloat() / surfaceHeight if (cameraRatio > screenRatio) { 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 ebbfb18..cdafd8f 100644 --- a/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftShader.kt +++ b/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftShader.kt @@ -20,15 +20,6 @@ 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 @@ -38,8 +29,6 @@ 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) --- @@ -50,8 +39,6 @@ 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 @@ -81,8 +68,6 @@ 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) @@ -93,8 +78,6 @@ 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") @@ -113,19 +96,12 @@ 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, texMatrix: FloatArray, mirrorX: Boolean) { + fun usePassthrough(cameraTextureId: Int) { 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) } /** @@ -152,10 +128,6 @@ 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 167b376..bbb706d 100644 --- a/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt +++ b/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt @@ -112,7 +112,6 @@ 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( @@ -165,13 +164,6 @@ fun CameraScreen( } } - // Forward device rotation to renderer (aspect math) and CameraX (target rotation) - LaunchedEffect(currentRotation, renderer) { - renderer?.setDisplayRotation(currentRotation) - viewModel.cameraManager.setTargetRotation(currentRotation) - glSurfaceView?.requestRender() - } - // Start camera when surface texture is available LaunchedEffect(surfaceTexture) { surfaceTexture?.let { 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 dbc5112..ad94ad0 100644 --- a/app/src/main/java/no/naiv/tiltshift/ui/ControlPanel.kt +++ b/app/src/main/java/no/naiv/tiltshift/ui/ControlPanel.kt @@ -12,10 +12,7 @@ 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 @@ -113,10 +110,8 @@ 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 ca79319..e009424 100644 --- a/app/src/main/res/raw/tiltshift_vertex.glsl +++ b/app/src/main/res/raw/tiltshift_vertex.glsl @@ -1,22 +1,12 @@ -// 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. +// Vertex shader for tilt-shift effect +// Passes through position and calculates texture coordinates attribute vec4 aPosition; attribute vec2 aTexCoord; -uniform mat4 uTexMatrix; -uniform float uMirrorX; - varying vec2 vTexCoord; void main() { gl_Position = aPosition; - vec2 tc = (uTexMatrix * vec4(aTexCoord, 0.0, 1.0)).xy; - if (uMirrorX > 0.5) tc.x = 1.0 - tc.x; - vTexCoord = tc; + vTexCoord = aTexCoord; } diff --git a/version.properties b/version.properties index 34449a3..ba10bb5 100644 --- a/version.properties +++ b/version.properties @@ -1,4 +1,4 @@ versionMajor=1 versionMinor=1 -versionPatch=9 -versionCode=11 +versionPatch=10 +versionCode=12