Make camera image follow device rotation (4-orientation texcoord table)

Re-add landscape support, this time via four precomputed texcoord
buffers — one per Surface.ROTATION_* — instead of going through
SurfaceTexture.getTransformMatrix() (which doesn't honour
Preview.targetRotation for custom SurfaceProviders) or the manual
matrix composition attempts in v1.1.6–1.1.11.

For each device orientation the renderer picks the texcoord set that
both compensates for the 90° CW sensor mount and the activity's own
rotation under screenOrientation="fullSensor", so world-up stays at
clip-space-top. recomputeVertices swaps effective camera dimensions
between portrait and landscape so crop-to-fill picks the right aspect.

Verified empirically in the emulator across all four Display.rotation
values (sky-yellow band always lands at the top of the screen).

Bump to 1.1.12.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-05-11 16:12:29 +02:00
commit a2dfa7db3d
3 changed files with 64 additions and 23 deletions

View file

@ -6,6 +6,7 @@ import android.opengl.GLES11Ext
import android.opengl.GLES20 import android.opengl.GLES20
import android.opengl.GLSurfaceView import android.opengl.GLSurfaceView
import android.util.Log import android.util.Log
import android.view.Surface
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder
import java.nio.FloatBuffer import java.nio.FloatBuffer
@ -69,26 +70,32 @@ class TiltShiftRenderer(
@Volatile @Volatile
private var vertexBufferDirty: Boolean = false private var vertexBufferDirty: Boolean = false
// Texture coordinates rotated 90° for portrait mode (back camera) // Texture coordinates for the back camera, indexed by Surface.ROTATION_*.
// (Camera sensors are landscape-oriented, we rotate to portrait) // The base orientation (index 0) applies the 90° CCW rotation that maps
private val texCoordsBack = floatArrayOf( // the landscape sensor frame to a portrait display. Indices 1/2/3 layer
1f, 1f, // Bottom left of screen -> bottom right of texture // additional CCW rotations on top so the activity's rotation is
1f, 0f, // Bottom right of screen -> top right of texture // compensated and world-up stays at clip-space-top.
0f, 1f, // Top left of screen -> bottom left of texture private val texCoordsBackByRotation = arrayOf(
0f, 0f // Top right of screen -> top left of texture 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
) )
// Texture coordinates for front camera (mirrored + rotated) // Front camera variants: same as back, but horizontally mirrored
// Front camera needs horizontal mirror for natural selfie view // for the natural selfie view.
private val texCoordsFront = floatArrayOf( private val texCoordsFrontByRotation = arrayOf(
0f, 1f, // Bottom left of screen floatArrayOf(0f, 1f, 0f, 0f, 1f, 1f, 1f, 0f), // ROTATION_0
0f, 0f, // Bottom right of screen floatArrayOf(0f, 0f, 1f, 0f, 0f, 1f, 1f, 1f), // ROTATION_90
1f, 1f, // Top left of screen floatArrayOf(1f, 0f, 1f, 1f, 0f, 0f, 0f, 1f), // ROTATION_180
1f, 0f // Top right of screen floatArrayOf(1f, 1f, 0f, 1f, 1f, 0f, 0f, 0f) // ROTATION_270
) )
@Volatile @Volatile
private var currentTexCoords = texCoordsBack private var displayRotation: Int = Surface.ROTATION_0
@Volatile
private var currentTexCoords = texCoordsBackByRotation[Surface.ROTATION_0]
@Volatile @Volatile
private var updateTexCoordBuffer = false private var updateTexCoordBuffer = false
@ -205,8 +212,7 @@ class TiltShiftRenderer(
fun setFrontCamera(front: Boolean) { fun setFrontCamera(front: Boolean) {
if (isFrontCamera != front) { if (isFrontCamera != front) {
isFrontCamera = front isFrontCamera = front
currentTexCoords = if (front) texCoordsFront else texCoordsBack refreshTexCoords()
updateTexCoordBuffer = true
} }
} }
@ -218,6 +224,27 @@ 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.
*/
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
}
fun release() { fun release() {
shader.release() shader.release()
surfaceTexture?.release() surfaceTexture?.release()
@ -254,16 +281,22 @@ class TiltShiftRenderer(
/** /**
* Recomputes camera vertex positions to achieve crop-to-fill. * Recomputes camera vertex positions to achieve crop-to-fill.
* *
* The camera sensor is landscape; after the 90° rotation applied via texture coordinates, * The camera sensor is landscape; after the orientation-dependent texcoord
* the effective portrait dimensions are (cameraHeight × cameraWidth). We scale the vertex * rotation, the effective dimensions seen on screen are either swapped
* quad so the camera frame fills the surface without stretching the GPU clips the overflow. * (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() { private fun recomputeVertices() {
var scaleX = 1f var scaleX = 1f
var scaleY = 1f var scaleY = 1f
if (cameraWidth > 0 && cameraHeight > 0 && surfaceWidth > 0 && surfaceHeight > 0) { 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 val screenRatio = surfaceWidth.toFloat() / surfaceHeight
if (cameraRatio > screenRatio) { if (cameraRatio > screenRatio) {

View file

@ -164,6 +164,14 @@ 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()
LaunchedEffect(currentRotation, renderer) {
renderer?.setDisplayRotation(currentRotation)
glSurfaceView?.requestRender()
}
// Start camera when surface texture is available // Start camera when surface texture is available
LaunchedEffect(surfaceTexture) { LaunchedEffect(surfaceTexture) {
surfaceTexture?.let { surfaceTexture?.let {

View file

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