Reapply "Revert orientation tracking on the camera image"

This reverts commit c0bab85d63.
This commit is contained in:
Ole-Morten Duesund 2026-05-11 15:57:32 +02:00
commit b0691adfa3
7 changed files with 55 additions and 171 deletions

View file

@ -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<LifecycleOwner>? = 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).

View file

@ -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) {

View file

@ -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)

View file

@ -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 {

View file

@ -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)
) {

View file

@ -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;
}

View file

@ -1,4 +1,4 @@
versionMajor=1
versionMinor=1
versionPatch=9
versionCode=11
versionPatch=10
versionCode=12