diff --git a/CLAUDE.md b/CLAUDE.md index 249fe6a..6e1a655 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,21 +86,6 @@ Bitmaps emitted to `StateFlow`s are **never eagerly recycled** immediately after - Error/success dismiss indicators use cancellable `Job` tracking to prevent race conditions - `writeExifToUri()` returns boolean and logs at ERROR level on failure -### Orientation policy (do not change without asking) - -- Activity is `screenOrientation="portrait"` in the manifest. The GL surface and Compose layout therefore never rotate. -- The camera passthrough uses a fixed 90° texcoord rotation (`texCoordsBack`/`texCoordsFront`). There is no `setTargetRotation`/`setDisplayRotation`/`getTransformMatrix`-based rotation tracking — past attempts (v1.1.6 through v1.1.13) all introduced subtle bugs and were reverted. -- The camera image lives in the device's portrait frame and visually follows the phone as it tilts. Per-orientation correctness comes from the EXIF orientation tag written at capture (`OrientationDetector.degreesToExifOrientation(rotationToDegrees(deviceRotation), isFrontCamera)`), not from rotating the bitmap. -- `OrientationEventListener` (`viewModel.currentRotation`) is for EXIF only. It is **not** the same as `Display.rotation`: it fires at the 45° tilt threshold while the activity rotates later, so it must not drive anything that has to stay in sync with the GL surface. -- `SurfaceTexture.getTransformMatrix()` with a custom `SurfaceProvider` does not change on `Preview.targetRotation` updates; rebinding doesn't reliably help either. Don't go down that road again. - -### Local Android testing - -- AVD: `tilfluktsrom` (Pixel 6, API 35, Google APIs) at `~/.android/avd/`. Boot with `emulator -avd tilfluktsrom -no-snapshot-save -gpu swiftshader_indirect -no-audio`. -- `adb -s emulator-5554 emu rotate` cycles `ROTATION_0 → 3 → 2 → 1 → 0` (decrements by 90°), not the other way. -- The default virtual scene is too rotationally symmetric to verify which way is "up". For orientation testing, pass `-virtualscene-poster wall=/path/to/marker.png` and ensure the scene camera faces the wall, or just don't trust emulator screenshots as proof of correct orientation. -- Camera bitmaps captured at startup tend to look black for a few seconds while CameraX rebinds. Wait ≥10 s after `am start` before screenshotting. - ## Permissions | Permission | Purpose | Notes | diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9fed438..c56c4e9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -31,7 +31,7 @@ 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/camera/ImageCaptureHandler.kt b/app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt index b93db9a..b0401e7 100644 --- a/app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt +++ b/app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt @@ -24,7 +24,6 @@ import kotlin.coroutines.resume import kotlin.math.cos import kotlin.math.sin import kotlin.math.sqrt -import no.naiv.tiltshift.util.OrientationDetector /** * Handles capturing photos with the tilt-shift effect applied. @@ -122,19 +121,10 @@ class ImageCaptureHandler( var thumbnail: Bitmap? = null try { thumbnail = createThumbnail(captureResult.processed) - // Camera bitmap is in the device's portrait frame (CameraX - // rotated the sensor 90° because the activity is locked to - // portrait). Tag the EXIF with the user's physical tilt - // when they pressed the shutter so viewers display the - // photo right-side-up regardless of how the phone was held. - val exifOrientation = OrientationDetector.degreesToExifOrientation( - OrientationDetector.rotationToDegrees(deviceRotation), - isFrontCamera - ) val result = photoSaver.saveBitmapPair( original = captureResult.original, processed = captureResult.processed, - orientation = exifOrientation, + orientation = ExifInterface.ORIENTATION_NORMAL, location = location ) if (result is SaveResult.Success) { 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 d170d58..e8ff4bd 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,9 @@ 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 @@ -38,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 @@ -54,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 @@ -66,33 +72,14 @@ 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) @@ -104,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) @@ -145,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) --- @@ -169,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) --- @@ -203,11 +185,7 @@ class TiltShiftRenderer( } fun setFrontCamera(front: Boolean) { - if (isFrontCamera != front) { - isFrontCamera = front - currentTexCoords = if (front) texCoordsFront else texCoordsBack - updateTexCoordBuffer = true - } + isFrontCamera = front } fun setCameraResolution(width: Int, height: Int) { @@ -218,6 +196,49 @@ 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() @@ -254,16 +275,24 @@ class TiltShiftRenderer( /** * Recomputes camera vertex positions to achieve crop-to-fill. * - * 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. + * 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() { var scaleX = 1f var scaleY = 1f if (cameraWidth > 0 && cameraHeight > 0 && surfaceWidth > 0 && surfaceHeight > 0) { - val cameraRatio = cameraHeight.toFloat() / cameraWidth + 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 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 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 bbb706d..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,6 +165,13 @@ 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 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 214906f..34449a3 100644 --- a/version.properties +++ b/version.properties @@ -1,4 +1,4 @@ versionMajor=1 versionMinor=1 -versionPatch=15 -versionCode=17 +versionPatch=9 +versionCode=11