Use TextureView's and animated transitions#19
Conversation
There was a problem hiding this comment.
Pull request overview
Migrate camera previews from SurfaceView to TextureView to enable smooth layout-driven preview resizing, while adding a UI-driven “refit” mechanism that lets renderers (especially Vulkan) rebuild/adjust at key animation frames and fixes swapchain recreate/teardown issues that caused resize corruption.
Changes:
- Switch preview surface plumbing from
SurfaceHolder.Callback/SurfaceViewtoTextureView.SurfaceTextureListener/TextureView. - Add a UI-driven
refit()path from Compose → Kotlin → JNI → native renderer, and coalesce per-tick size updates. - Improve Vulkan swapchain recreation (reuse old swapchain, re-record command buffers, fix teardown leaks) and tighten
VK_ERROR_OUT_OF_DATE_KHRrecovery behavior.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| app/src/main/native/cpp/vulkan_renderer.hpp | Add onRefit() hook usage and declare swapchain recreation helpers / signature changes. |
| app/src/main/native/cpp/vulkan_renderer.cpp | Implement swapchain recreation improvements and swapchain cleanup fixes; adjust acquire error handling. |
| app/src/main/native/cpp/core_engine.hpp | Register new JNI entry point nativeRefit. |
| app/src/main/native/cpp/core_engine.cpp | Implement nativeRefit() forwarding into the renderer. |
| app/src/main/native/cpp/base_renderer.hpp | Add refit() API and atomics for coalescing resize tasks. |
| app/src/main/native/cpp/base_renderer.cpp | Coalesce window-size updates via atomics and add render-thread refit() scheduling. |
| app/src/main/java/com/dz/camerafast/CoreEngine.kt | Switch to TextureView.SurfaceTextureListener and add threshold-filtered refit(weight) behavior. |
| app/src/main/java/com/dz/camerafast/CameraPreviewView.kt | Replace SurfaceView preview with TextureView. |
| app/src/main/java/com/dz/camerafast/CameraActivity.kt | Smooth resize animation, weight-to-zero behavior, and drive refit() via SideEffect. |
| app/src/main/java/com/dz/camerafast/Camera2.kt | Null-safe Image handling around acquireLatestImage() and close(). |
Comments suppressed due to low confidence (1)
app/src/main/native/cpp/base_renderer.cpp:49
- The scheduled task loads width and height from two independent atomics. Even with stronger memory ordering, it can observe a mixed pair (new width with old height, or vice versa) if a producer updates between the two loads, which can briefly produce incorrect aspect ratios/MVP. If you need consistency, consider packing (width,height) into a single std::atomic<uint64_t> (or use a sequence counter) and reading it atomically in one load.
resizeTaskScheduled.store(false, std::memory_order_release);
const int width = pendingViewportWidth.load(std::memory_order_relaxed);
const int height = pendingViewportHeight.load(std::memory_order_relaxed);
if (viewportWidth != width || viewportHeight != height) {
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (1)
app/src/main/native/cpp/core_engine.cpp:108
- nativeDestroy() currently just resets
rendererwithout ensuring the render thread runs onWindowDestroyed()/cleanup, and without releasingaNativeWindow/gpuBuffer. If destroy happens before the Java-side surface teardown callback, Vulkan/GL resources (and the fallback AHardwareBuffer) can leak. Consider calling renderer->resetWindow() (or otherwise invoking cleanup) when aNativeWindow is set, releasing aNativeWindow, and releasing/freeing gpuBuffer before renderer.reset().
void CoreEngine::nativeDestroy(JNIEnv &env) {
LOGI("Core engine destroy started");
renderer.reset();
LOGI("Core engine destroy passed");
VK_ERROR_OUT_OF_DATE_KHR was the only code being handled, but the call can also return VK_ERROR_SURFACE_LOST_KHR, VK_ERROR_DEVICE_LOST, etc. Those used to fall through to vkQueueSubmit / vkQueuePresentKHR with an uninitialised nextIndex — UB. Now anything that isn't VK_SUCCESS or VK_SUBOPTIMAL_KHR (which still indicates a usable acquisition) logs and returns early so the next render iteration can retry. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
OUT_OF_DATE_KHR / SUBOPTIMAL_KHR already route through recreateSwapChain, but other failure codes (SURFACE_LOST, DEVICE_LOST, ...) used to be silently ignored, hiding presentation failures. Now any non-VK_SUCCESS that isn't an explicit recreate trigger gets logged at error level. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cleanupSwapChain previously left swapchainInfo.swapchain and swapchainInfo.swapchainLength untouched, so a second call (or a teardown path that overlaps with another) could re-destroy a dead handle or loop over already-freed arrays. Reset both to null/0 at the end, and add an early-return guard at the top. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
resizeTaskScheduled was cleared at the start of the task, so a producer firing while the task was still running could schedule a second task in parallel and undercut the coalescing. Now the slot stays held for the whole duration; the task body is a small loop that re-reads pending dims until they match what's been applied, then releases the slot. Updates that arrive mid-task are picked up on the next iteration instead of being lost or doubling the worker count. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
shouldRefit() relied on exact float equality with 0f / 1f to fire the final refit. animateFloatAsState with tween lands precisely on those values today, but a future switch to spring (or any spec that overshoots / settles slightly past) would skip the settlement refit entirely. Test for transitions INTO the rest region instead (curr <= 0f && prev > 0f, symmetric for 1f) so it stays correct regardless of animation curve. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously the scheduled task read viewportWidth/viewportHeight inside the resizeMutex critical section but wrote them outside of it, which made the locking pattern inconsistent (and would be a cross-thread data race if anything other than the render thread ever touched these fields). Move the write under the same lock as the compare; the heavy hook (onWindowSizeUpdated + updateMvp) still runs unlocked so producers on the main thread aren't blocked on it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CoreEngine held a strong reference to the TextureView via the textureView property, and nothing ever cleared it. If the preview was removed (e.g. switching display modes) and not replaced, the detached View — and its Context — would stay alive for the rest of the Activity lifetime. Clear the reference inside onSurfaceTextureDestroyed (which also detaches our listener via the existing setter), guarded by an identity check on the SurfaceTexture so a stale callback can't null out a freshly-attached view. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous (pre-coalescing) implementation always called updateMvp at the end of every updateWindowSize task — annotated as needed for the background → foreground re-fire case. After moving to a loop with an early-return when pending == applied, that "always update MVP" semantic was accidentally lost: a same-size re-fire would just clear the scheduled flag and exit without refreshing the matrix. Restore the original behavior by always running updateMvp() once on the way out, regardless of whether a size was actually applied this round. Per-iteration updateMvp is dropped from inside the loop body (the only MVP-affecting state from inside this task is viewportWidth/Height, and we always exit through the same MVP-refresh path that captures their final values). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous commit moved updateMvp() to the exit branch only, which fixed the same-size-refresh case but inadvertently regressed the per-frame projection refresh during a resize animation: every iteration that applied a new size would update viewport bookkeeping and call onWindowSizeUpdated, but leave the uMvpMatrix / Vulkan UBO pointing at the previous frame's aspect ratio. OpenGL's glViewport would track the new size correctly while the MVP stayed stale, producing a visible mismatch. Restore the inner-loop updateMvp() call after each onWindowSizeUpdated. The exit-branch updateMvp() stays for the background → foreground same-size case. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ANativeWindow_fromSurface can return null (e.g. when the underlying Surface was already invalidated). Previously the code would continue into the same-window branch (release a null ref) and then call renderer->updateWindowSize anyway, driving render-thread size work without a valid native window. Detect null up front, log, and return before touching any renderer state. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Inline the Android contract for SurfaceTextureListener.onSurfaceTexture\ Destroyed so it's obvious from the call site: returning true asks the framework to release the SurfaceTexture, returning false transfers ownership to us. We don't retain the SurfaceTexture beyond this callback, so the framework-side release we get with the existing return true is correct — no caller-side release needed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
nativeSendCameraFrame is invoked from the camera worker thread and can fire after nativeDestroy() has already reset the renderer unique_ptr. The two previously-unchecked renderer->processCameraFrame() calls would deref null and crash. Add an early null check matching the one we already have on nativeUpdateWindowSize. This is not race-free against a concurrent destroy (the renderer could go null between the check and the use); fully fixing that would require a mutex / shared-ownership refactor. The check covers the common ordering where destroy completes before the worker fires. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
createVulkanDevice already finds the graphics-capable queue family by iterating queueFamilyProperties and stores it in deviceInfo.queueFamilyIndex, but then hardcoded 0 was used both when calling vkGetDeviceQueue and when populating VkCommandPoolCreateInfo.queueFamilyIndex. On a device where the graphics queue family isn't at index 0 this would either return an invalid VkQueue or allocate command buffers belonging to a different family than the one we submit/present on — making recreateSwapChain's vkQueueWaitIdle wait the wrong queue. Plumb deviceInfo.queueFamilyIndex through both sites. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (2)
app/src/main/native/cpp/core_engine.cpp:81
- This early-return reduces the chance of dereferencing a null
renderer, but it does not make the code thread-safe: readingrendereron the camera worker thread whilenativeDestroy()resets it on another thread is still a C++ data race (UB). Consider guarding all accesses torendererwith the same mutex (includingnativeSetSurface,nativeUpdateWindowSize,nativeSendCameraFrame, andnativeDestroy) or restructuring shutdown so the camera thread is joined beforerenderer.reset()happens.
/** called from worker thread **/
void CoreEngine::nativeSendCameraFrame(JNIEnv &env, const jni::Object<HardwareBuffer> &buffer,
jni::jint rotationDegrees, jni::jboolean backCamera) {
// Camera worker can fire after nativeDestroy() has reset the renderer. Skip the frame rather
// than deref a moved-from unique_ptr — this is not race-free against a concurrent destroy
// (would need a mutex for that) but covers the common ordering where destroy completes first.
if (!renderer) {
return;
}
app/src/main/native/cpp/core_engine.cpp:120
nativeDestroy()resets the renderer but never releasesaNativeWindow(and any other native resources owned byCoreEngine). Ifdestroy()is called beforeonSurfaceTextureDestroyed/nativeSetSurface(null, …)runs, theANativeWindow_fromSurfacestrong ref can leak for the remainder of the process. Consider explicitly callingrenderer->resetWindow()(if non-null) andANativeWindow_release(aNativeWindow)/aNativeWindow = nullptras part of destroy (and similarly releasinggpuBufferif owned).
void CoreEngine::nativeDestroy(JNIEnv &env) {
LOGI("Core engine destroy started");
renderer.reset();
LOGI("Core engine destroy passed");
CoreEngine's JNI methods can be invoked concurrently from several threads — Android main (nativeSetSurface / nativeUpdateWindowSize / nativeRefit), the camera worker (nativeSendCameraFrame), and the JVM finalizer (nativeDestroy). The previous null-checks on `renderer` covered the most common ordering but didn't make any of these accesses actually race-free: reading the unique_ptr on one thread while nativeDestroy() resets it on another is a C++ data race / UB, and there was a TOCTOU window even with the null-check. Add a single coreEngineMutex and lock it at the entry of every JNI method (including nativeDestroy). Lock contention is minimal — these are infrequent JNI hops and each method's payload is either a quick schedule-a-task or already serialized internally. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
app/src/main/java/com/dz/camerafast/CoreEngine.kt:96
- CoreEngine.destroy() currently only calls nativeDestroy(). If this is invoked before TextureView teardown, the renderer can be destroyed without ever receiving nativeSetSurface(null)/resetWindow, and Surface/TextureView callbacks may still fire afterward against a destroyed native peer. Consider making destroy() proactively detach the SurfaceTextureListener, release the held Surface, and call nativeSetSurface(null, 0, 0) before nativeDestroy so native render resources are deterministically torn down and no further callbacks can reach JNI.
fun destroy() {
nativeDestroy()
}
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
nativeSendCameraFrame held coreEngineMutex across the entire body of the CPU-fallback branch, including the two AHardwareBuffer_locks and the memcpy of a full image. On devices that hit this path it stalls all main-thread JNI calls — nativeUpdateWindowSize / nativeRefit / nativeSetSurface — behind every camera frame, which is most visible as choppiness during a resize animation. Switch to std::unique_lock and drop the mutex around the slow part. Before releasing, we AHardwareBuffer_acquire the current gpuBuffer so a concurrent nativeDestroy can release its own reference without the underlying buffer being freed underneath the camera worker. After re-acquiring the lock we re-check the renderer (destroy may have fired during the unlocked window) and drop the frame if so. The fast GPU path keeps the existing single-critical-section structure since it doesn't do any blocking work. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| if (reschedule) { | ||
| renderThread->scheduleTask([this] { | ||
| updateWindowSize(pendingViewportWidth, pendingViewportHeight); | ||
| }); |
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Summary
Migrate camera previews from SurfaceView to TextureView and make the dual-preview resize animation smooth on both OpenGL and Vulkan. Also fixes several latent bugs in the Vulkan swapchain teardown/recreate path
that were producing visible corruption on resize.
UI / Compose
rest of the UI.
rounds to 0 px before being removed by finishedListener. No more 10 % sliver → "gone" pop.
CoreEngine (Kotlin) — refit API
settlement at 0.0f/1.0f, and forwards only those events through a new nativeRefit() JNI binding.
BaseRenderer
latest dimensions.
VulkanRenderer — swapchain teardown/recreate fixes