Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
dfb2ae8
Use TextureView's and animated transitions
kiryldz May 14, 2026
98ca96b
PR fixes
kiryldz May 14, 2026
5f95b95
PR fixes v2
kiryldz May 15, 2026
a00d46f
PR fixes v3
kiryldz May 15, 2026
006b21a
PR fixes v4
kiryldz May 15, 2026
c448c0f
vk: drop frames on non-recoverable vkAcquireNextImageKHR results
kiryldz May 15, 2026
246d1c2
vk: log fatal vkQueuePresentKHR errors instead of swallowing them
kiryldz May 15, 2026
769fdb4
vk: make cleanupSwapChain idempotent
kiryldz May 15, 2026
254fc84
base: hold updateWindowSize scheduling slot until pending == applied
kiryldz May 15, 2026
de5a52f
core: detect refit endpoint settlement by transition into rest region
kiryldz May 15, 2026
7437920
base: keep updateWindowSize viewport read+write inside the same lock
kiryldz May 15, 2026
0786f9d
core: drop TextureView reference on surface destroy
kiryldz May 15, 2026
cb6166b
base: restore unconditional updateMvp at end of updateWindowSize task
kiryldz May 15, 2026
4828d69
base: also refresh MVP per applied size change in updateWindowSize loop
kiryldz May 15, 2026
2c9d5a8
core: bail out of nativeSetSurface if ANativeWindow_fromSurface fails
kiryldz May 15, 2026
97fdf5e
core: document onSurfaceTextureDestroyed return-value contract
kiryldz May 15, 2026
4932fbb
core: null-guard renderer in nativeSendCameraFrame
kiryldz May 15, 2026
3ec8963
vk: use discovered queueFamilyIndex for queue and command pool
kiryldz May 15, 2026
e87d183
core: serialize JNI entry points against nativeDestroy with a mutex
kiryldz May 15, 2026
e82444f
Potential fix for pull request finding
kiryldz May 15, 2026
5b8aadc
Potential fix for pull request finding
kiryldz May 15, 2026
40c6848
core: drop coreEngineMutex during CPU-fallback frame copy
kiryldz May 15, 2026
d7e2dfb
Potential fix for pull request finding
kiryldz May 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions app/src/main/java/com/dz/camerafast/Camera2.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import android.hardware.camera2.CameraCaptureSession.StateCallback
import android.hardware.camera2.CameraCharacteristics
import android.hardware.camera2.CameraDevice
import android.hardware.camera2.CameraManager
import android.media.Image
import android.media.ImageReader
import android.os.Build
import android.os.Handler
Expand Down Expand Up @@ -114,8 +115,8 @@ fun Camera2(
cameraCharacteristics.get(CameraCharacteristics.SENSOR_ORIENTATION)!!
imageReader.setOnImageAvailableListener(
{
val image = it.acquireLatestImage()
image.hardwareBuffer?.let { buffer ->
val image: Image? = it.acquireLatestImage()
image?.hardwareBuffer?.let { buffer ->
coreEngines.forEach { engine ->
engine.sendCameraFrame(
buffer = buffer,
Expand All @@ -125,7 +126,7 @@ fun Camera2(
}
buffer.close()
}
image.close()
image?.close()
},
cameraHandler
)
Expand Down
34 changes: 26 additions & 8 deletions app/src/main/java/com/dz/camerafast/CameraActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
Expand Down Expand Up @@ -73,8 +74,7 @@ class CameraActivity : ComponentActivity() {

val vulkanWeight by animateFloatAsState(
targetValue = vulkanWeightValue,
// TODO increase duration and try make dynamic view resize less laggy
animationSpec = tween(durationMillis = 0),
animationSpec = tween(durationMillis = 1000),
label = "Vulkan",
finishedListener = { endValue ->
displayMode = if (endValue == 1.0f) {
Expand All @@ -91,8 +91,7 @@ class CameraActivity : ComponentActivity() {

val openGlWeight by animateFloatAsState(
targetValue = openGlWeightValue,
// TODO increase duration and try make dynamic view resize less laggy
animationSpec = tween(durationMillis = 0),
animationSpec = tween(durationMillis = 1000),
label = "OpenGL",
finishedListener = { endValue ->
displayMode = if (endValue == 1.0f) {
Expand All @@ -103,6 +102,17 @@ class CameraActivity : ComponentActivity() {
}
)

// Hand each engine its own preview weight on every recomposition (each animation frame
// produces one). CoreEngine.refit filters internally and only fires a JNI call at key
// frames (threshold crossings + endpoint settlement), so a 0 -> 1 grow gets a few re-fits
// along the way instead of presenting one stale tiny buffer the whole time.
SideEffect {
previewEngineList.find { it.renderingMode == RenderingMode.VULKAN }!!
.refit(vulkanWeight)
previewEngineList.find { it.renderingMode == RenderingMode.OPEN_GL_ES }!!
.refit(openGlWeight)
}

if (cameraMode == CameraMode.CAMERA_X) {
CameraX(
coreEngines = previewEngineList,
Expand Down Expand Up @@ -134,15 +144,17 @@ class CameraActivity : ComponentActivity() {
Box(
contentAlignment = Alignment.TopStart,
modifier = Modifier
.weight(openGlWeight)
// Modifier.weight requires a strictly positive value,
// so floor at a sub-pixel weight
.weight(openGlWeight.coerceAtLeast(MIN_WEIGHT))
.fillMaxWidth()
) {
CameraPreviewView(
coreEngine = previewEngineList.find { it.renderingMode == RenderingMode.OPEN_GL_ES }!!,
modifier = Modifier
.matchParentSize()
.clickable(enabled = displayMode == DisplayMode.BOTH) {
vulkanWeightValue = 0.1f
vulkanWeightValue = 0.0f
}
)
Text(
Expand All @@ -157,15 +169,15 @@ class CameraActivity : ComponentActivity() {
Box(
contentAlignment = Alignment.TopStart,
modifier = Modifier
.weight(vulkanWeight)
.weight(vulkanWeight.coerceAtLeast(MIN_WEIGHT))
.fillMaxWidth()
) {
CameraPreviewView(
coreEngine = previewEngineList.find { it.renderingMode == RenderingMode.VULKAN }!!,
modifier = Modifier
.matchParentSize()
.clickable(enabled = displayMode == DisplayMode.BOTH) {
openGlWeightValue = 0.1f
openGlWeightValue = 0.0f
}
)
Text(
Expand Down Expand Up @@ -248,5 +260,11 @@ class CameraActivity : ComponentActivity() {

internal companion object {
internal const val TAG = "DzCamera"

// Minimum weight applied to a shrinking preview Box. Modifier.weight requires a strictly
// positive value, so we coerce the animated weight to this sub-pixel floor — the Box's actual
// height rounds to 0px well before the animation reaches it, making the final removal
// (in finishedListener) imperceptible.
private const val MIN_WEIGHT = 0.0001f
}
}
4 changes: 2 additions & 2 deletions app/src/main/java/com/dz/camerafast/CameraPreviewView.kt
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.dz.camerafast

import android.annotation.SuppressLint
import android.view.SurfaceView
import android.view.TextureView
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
Expand All @@ -15,7 +15,7 @@ fun CameraPreviewView(
AndroidView(
modifier = modifier,
factory = { context ->
SurfaceView(context).apply { coreEngine.surfaceHolder = this.holder }
TextureView(context).apply { coreEngine.textureView = this }
},
update = {
// could not be used efficiently as this will always be called from main thread
Expand Down
102 changes: 87 additions & 15 deletions app/src/main/java/com/dz/camerafast/CoreEngine.kt
Original file line number Diff line number Diff line change
@@ -1,25 +1,33 @@
package com.dz.camerafast

import android.graphics.PixelFormat
import android.graphics.SurfaceTexture
import android.hardware.HardwareBuffer
import android.util.Log
import android.view.Surface
import android.view.SurfaceHolder
import android.view.TextureView
import androidx.annotation.Keep

@Keep
class CoreEngine(
val renderingMode: RenderingMode,
) : SurfaceHolder.Callback {
) : TextureView.SurfaceTextureListener {

internal var surfaceHolder: SurfaceHolder? = null
private var surface: Surface? = null

internal var textureView: TextureView? = null
set(value) {
// remove callback for previous camera preview view if needed
field?.removeCallback(this)
// remove listener for previous camera preview view if needed
field?.surfaceTextureListener = null
field = value
// we will use RGBA_8888 here and use same config in render thread
field?.setFormat(PixelFormat.RGBA_8888)
field?.addCallback(this)
field?.surfaceTextureListener = this
// If the TextureView already has a SurfaceTexture (common when the view is re-attached or
// the listener is set after layout), the framework won't fire onSurfaceTextureAvailable —
// do it ourselves so the native surface still gets set up.
if (value?.isAvailable == true) {
value.surfaceTexture?.let { texture ->
onSurfaceTextureAvailable(texture, value.width, value.height)
}
}
}
Comment thread
kiryldz marked this conversation as resolved.

init {
Expand All @@ -31,17 +39,56 @@ class CoreEngine(
nativeSendCameraFrame(buffer, rotationDegrees, backCamera)
}

override fun surfaceCreated(p0: SurfaceHolder) {
// do nothing
private var previousWeight: Float = 1.0f

/**
* Called by the UI on every preview-weight change (e.g. from a Compose SideEffect over an
* animateFloatAsState value). When the transition crosses one of [REFIT_THRESHOLDS] or settles
* at an endpoint (0.0 / 1.0), the renderer is asked to re-fit its output to the current view
* bounds — that's a swapchain rebuild for Vulkan and a glViewport for OpenGL. Intermediate
* animation ticks are filtered out here so the JNI layer only sees the values worth reacting to.
*
* @return true if this transition actually triggered a native refit.
*/
fun refit(weight: Float): Boolean {
val triggered = shouldRefit(previousWeight, weight)
previousWeight = weight
if (triggered) {
nativeRefit()
}
return triggered
}

override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
Log.i(TAG, "Surface changed ${holder.surface}, format $format, width $width, height $height")
nativeSetSurface(holder.surface, width, height)
override fun onSurfaceTextureAvailable(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
Log.i(TAG, "Surface texture available, width $width, height $height")
Comment thread
kiryldz marked this conversation as resolved.
surface?.release()
surface = Surface(surfaceTexture).also { nativeSetSurface(it, width, height) }
}

override fun surfaceDestroyed(p0: SurfaceHolder) {
override fun onSurfaceTextureSizeChanged(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
Log.i(TAG, "Surface texture size changed, width $width, height $height")
nativeUpdateWindowSize(width, height)
}
Comment thread
kiryldz marked this conversation as resolved.

override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean {
nativeSetSurface(null, 0, 0)
surface?.release()
surface = null
Comment thread
kiryldz marked this conversation as resolved.
// Drop the TextureView reference (the setter also detaches our listener) so a destroyed view
// can be GC'd along with its Context. Guard with an identity check so an unrelated callback
// from a stale SurfaceTexture wouldn't accidentally null out a freshly-attached TextureView.
if (textureView?.surfaceTexture === surfaceTexture) {
textureView = null
}
Comment thread
kiryldz marked this conversation as resolved.
// Returning true tells TextureView to release the SurfaceTexture itself (per the contract on
// SurfaceTextureListener.onSurfaceTextureDestroyed: true = framework releases, false = we
// take ownership and must call SurfaceTexture.release() ourselves). We don't keep the
// SurfaceTexture beyond this callback, so framework-side release is correct.
return true
}

override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) {
// do nothing
}

fun destroy() {
Expand All @@ -55,12 +102,16 @@ class CoreEngine(

private external fun nativeSetSurface(surface: Surface?, width: Int, height: Int)

private external fun nativeUpdateWindowSize(width: Int, height: Int)

private external fun nativeSendCameraFrame(
buffer: HardwareBuffer,
rotationDegrees: Int,
backCamera: Boolean
)

private external fun nativeRefit()

private external fun nativeDestroy()

private external fun initialize(mode: Int)
Expand Down Expand Up @@ -110,6 +161,27 @@ class CoreEngine(
private companion object {
private const val TAG = "DzCoreKotlin"

// Weight values at which the renderer should re-fit (Vulkan rebuilds its swapchain, OpenGL
// runs glViewport). Crossings of these thresholds — plus endpoint settlement at 0.0 / 1.0 —
// are the only events forwarded to JNI. Intermediate animation ticks are dropped client-side
// so the native layer doesn't have to track previous values or implement crossing detection.
private val REFIT_THRESHOLDS = floatArrayOf(0.1f, 0.5f)

private fun shouldRefit(prev: Float, curr: Float): Boolean {
for (threshold in REFIT_THRESHOLDS) {
val crossedUp = prev < threshold && curr >= threshold
val crossedDown = prev > threshold && curr <= threshold
if (crossedUp || crossedDown) return true
}
// Endpoint settlement: detect transitions INTO the rest region rather than equality with
// exactly 0f / 1f. animateFloatAsState with tween lands precisely on the target today, but
// spring or other animation specs can overshoot or settle slightly past — using <= 0f /
// >= 1f keeps the final refit reliable across animation-spec changes.
val settledAtZero = curr <= 0.0f && prev > 0.0f
val settledAtOne = curr >= 1.0f && prev < 1.0f
return settledAtZero || settledAtOne
}

init {
System.loadLibrary("native-engine")
}
Expand Down
83 changes: 74 additions & 9 deletions app/src/main/native/cpp/base_renderer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,79 @@ void BaseRenderer::setWindow(ANativeWindow *window) {
}

void BaseRenderer::updateWindowSize(int width, int height) {
renderThread->scheduleTask([this, width, height] {
// calculate MVP on CPU, if we would know it will be updated more often -
// of course better move matrix calculation to GPU
if (viewportWidth != width || viewportHeight != height) {
viewportWidth = width;
viewportHeight = height;
LOGI("Update window size, width=%i, height=%i", viewportWidth, viewportHeight);
bool needsSchedule;
{
std::lock_guard<std::mutex> lock(resizeMutex);
pendingViewportWidth = width;
pendingViewportHeight = height;
needsSchedule = !resizeTaskScheduled;
resizeTaskScheduled = true;
}
if (!needsSchedule) {
return;
}
renderThread->scheduleTask([this] {
int width;
int height;
bool sizeChanged;
{
// Process at most one pending size per scheduled task so the render thread yields back
// to the looper between resize ticks instead of draining an arbitrarily long animation
// inside a single callback. Producers still coalesce naturally by updating the pending
// dimensions under the same lock.
std::lock_guard<std::mutex> lock(resizeMutex);
width = pendingViewportWidth;
height = pendingViewportHeight;
sizeChanged = (viewportWidth != width || viewportHeight != height);
if (sizeChanged) {
viewportWidth = width;
viewportHeight = height;
}
}

if (!sizeChanged) {
// Refresh MVP even when the size was unchanged: producers (e.g. surface re-creation on
// background → foreground) can re-fire updateWindowSize with the same dimensions, and the
// rest of the renderer relies on the MVP being current.
updateMvp();
} else {
LOGI("Update window size, width=%i, height=%i", width, height);
// Per-tick lightweight hook — OpenGL's glViewport must run on every layout tick or the
// aspect ratio of rendered content goes wrong during a resize. Heavy size-driven work
// (Vulkan swapchain rebuild) is gated by onRefit() instead.
onWindowSizeUpdated(width, height);
// Refresh MVP for this applied size before yielding back to the looper. Otherwise the
// projection / aspect ratio would stay stale across the animation: OpenGL's glViewport
// would track the current viewport but the uMvpMatrix (and Vulkan's UBO) would not.
updateMvp();
}

bool reschedule = false;
{
// If a producer updated the pending size while this task was running, keep the scheduled
// slot claimed and post a fresh task so the looper can interleave other render-thread work
// before we apply the newer size. Otherwise release the slot.
std::lock_guard<std::mutex> lock(resizeMutex);
if (pendingViewportWidth != viewportWidth || pendingViewportHeight != viewportHeight) {
reschedule = true;
} else {
resizeTaskScheduled = false;
}
}

if (reschedule) {
renderThread->scheduleTask([this] {
updateWindowSize(pendingViewportWidth, pendingViewportHeight);
});
Comment on lines +94 to +97
}
});
}

void BaseRenderer::refit() {
renderThread->scheduleTask([this] {
if (viewportWidth > 0 && viewportHeight > 0) {
onRefit(viewportWidth, viewportHeight);
}
// update MVP in any case to cover the use-case of brining app to background and back
updateMvp();
});
}

Expand All @@ -61,6 +123,9 @@ void BaseRenderer::resetWindow() {
}

void BaseRenderer::updateMvp() {
if (viewportWidth <= 0 || viewportHeight <= 0) {
return;
}
float viewportRatio =
static_cast<float>(viewportWidth) / static_cast<float>(viewportHeight);
float ratio = viewportRatio * bufferImageRatio;
Expand Down
Loading