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 f7f247e..dc2b332 100644 --- a/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt +++ b/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt @@ -5,7 +5,6 @@ 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 @@ -40,8 +39,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 +55,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,13 +67,38 @@ 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 vertexBufferDirty: Boolean = false + private var currentTexCoords = texCoordsBackByRotation[Surface.ROTATION_0] + + @Volatile + private var updateTexCoordBuffer = false override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) { GLES20.glClearColor(0f, 0f, 0f, 1f) @@ -91,8 +111,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 +152,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 +176,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 +210,10 @@ class TiltShiftRenderer( } fun setFrontCamera(front: Boolean) { - isFrontCamera = front + if (isFrontCamera != front) { + isFrontCamera = front + refreshTexCoords() + } } fun setCameraResolution(width: Int, height: Int) { @@ -196,47 +224,25 @@ class TiltShiftRenderer( } } - /** Updates the display rotation so crop-to-fill picks the right effective aspect. */ + /** + * 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. + */ fun setDisplayRotation(rotation: Int) { if (displayRotation != rotation) { displayRotation = rotation + refreshTexCoords() 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) + private fun refreshTexCoords() { + val table = if (isFrontCamera) texCoordsFrontByRotation else texCoordsBackByRotation + val idx = displayRotation.coerceIn(0, table.size - 1) + currentTexCoords = table[idx] + updateTexCoordBuffer = true } fun release() { @@ -275,11 +281,10 @@ 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 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 GPU clips the overflow. */ private fun recomputeVertices() { @@ -291,7 +296,6 @@ 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 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..d5999ed 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,10 +164,11 @@ fun CameraScreen( } } - // Forward device rotation to renderer (aspect math) and CameraX (target rotation) + // 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() 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 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 6ca2659..aaf41eb 100644 --- a/version.properties +++ b/version.properties @@ -1,4 +1,4 @@ versionMajor=1 versionMinor=1 -versionPatch=11 -versionCode=13 +versionPatch=12 +versionCode=14