Skip to content

Use TextureView's and animated transitions#19

Open
kiryldz wants to merge 23 commits into
mainfrom
kdz-use-textures
Open

Use TextureView's and animated transitions#19
kiryldz wants to merge 23 commits into
mainfrom
kdz-use-textures

Conversation

@kiryldz
Copy link
Copy Markdown
Owner

@kiryldz kiryldz commented May 14, 2026

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

  • CameraPreviewView now uses TextureView (composited in the view hierarchy) instead of SurfaceView, so the preview's bounds animate in lockstep with the surrounding layout — no more hard-edge jumps against the
    rest of the UI.
  • Click handlers animate weight to exactly 0.0f (previously 0.1f); Modifier.weight is coerced to a sub-pixel MIN_WEIGHT floor so the spec's "strictly positive" requirement is satisfied while the visible Box
    rounds to 0 px before being removed by finishedListener. No more 10 % sliver → "gone" pop.
  • Resize tween bumped from 0 ms to 1000 ms.

CoreEngine (Kotlin) — refit API

  • Implements TextureView.SurfaceTextureListener (replaces SurfaceHolder.Callback).
  • New fun refit(weight: Float): Boolean that the UI calls from a SideEffect on every animation frame. Internally filters by threshold crossings of REFIT_THRESHOLDS = floatArrayOf(0.1f, 0.5f) plus endpoint
    settlement at 0.0f/1.0f, and forwards only those events through a new nativeRefit() JNI binding.

BaseRenderer

  • New public refit() entry point; new virtual onRefit(w, h) (default no-op) for renderers that defer heavy size work to UI-driven checkpoints.
  • updateWindowSize is now coalesced via atomics (pendingViewportWidth/Height + a resizeTaskScheduled CAS) — multiple surfaceTextureSizeChanged callbacks collapse into a single render-thread task reading the
    latest dimensions.
  • onWindowSizeUpdated retains its per-tick contract: OpenGL keeps doing glViewport every tick, Vulkan overrides it as a no-op.

VulkanRenderer — swapchain teardown/recreate fixes

  • New recreateSwapChain(width, height) helper used by both onRefit and the VK_ERROR_OUT_OF_DATE_KHR recovery in renderImpl. It:
    • Uses vkQueueWaitIdle instead of vkDeviceWaitIdle.
    • Passes the old swapchain via VkSwapchainCreateInfoKHR.oldSwapchain so the driver can reuse internal allocations (no more from-scratch reallocation per resize).
    • Re-records command buffers after rebuild — fixes the corruption/flicker that was happening because submits kept referencing destroyed framebuffers.
    • Reallocates the cmdBuffer[] storage when the new swapchain image count differs.
  • cleanupSwapChain now delete[]s framebuffers/displayViews/displayImages (previously leaked); cleanup() also delete[]s cmdBuffer.
  • renderImpl's recovery now handles VK_ERROR_OUT_OF_DATE_KHR only; VK_SUBOPTIMAL_KHR no longer triggers an early-return that left the acquire semaphore signaled.

@kiryldz kiryldz self-assigned this May 14, 2026
@kiryldz kiryldz requested a review from Copilot May 14, 2026 18:23
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/SurfaceView to TextureView.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_KHR recovery 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.

Comment thread app/src/main/native/cpp/base_renderer.cpp Outdated
Comment thread app/src/main/java/com/dz/camerafast/CoreEngine.kt
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 6 comments.

Comment thread app/src/main/native/cpp/base_renderer.hpp Outdated
Comment thread app/src/main/native/cpp/vulkan_renderer.hpp Outdated
Comment thread app/src/main/java/com/dz/camerafast/CoreEngine.kt
Comment thread app/src/main/native/cpp/core_engine.cpp
Comment thread app/src/main/native/cpp/vulkan_renderer.cpp
Comment thread app/src/main/native/cpp/vulkan_renderer.cpp
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 renderer without ensuring the render thread runs onWindowDestroyed()/cleanup, and without releasing aNativeWindow / 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");

Comment thread app/src/main/java/com/dz/camerafast/CoreEngine.kt
Comment thread app/src/main/native/cpp/vulkan_renderer.hpp
Comment thread app/src/main/native/cpp/vulkan_renderer.cpp
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 4 comments.

Comment thread app/src/main/native/cpp/vulkan_renderer.hpp
Comment thread app/src/main/native/cpp/base_renderer.cpp Outdated
Comment thread app/src/main/native/cpp/base_renderer.hpp
Comment thread app/src/main/native/cpp/base_renderer.hpp Outdated
@kiryldz kiryldz requested a review from Copilot May 15, 2026 07:44
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 5 comments.

Comment thread app/src/main/native/cpp/vulkan_renderer.cpp
Comment thread app/src/main/native/cpp/vulkan_renderer.cpp
Comment thread app/src/main/native/cpp/vulkan_renderer.cpp
Comment thread app/src/main/native/cpp/base_renderer.cpp
Comment thread app/src/main/java/com/dz/camerafast/CoreEngine.kt Outdated
kiryldz and others added 5 commits May 15, 2026 12:41
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>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.

Comment thread app/src/main/native/cpp/base_renderer.cpp Outdated
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>
@kiryldz kiryldz requested a review from Copilot May 15, 2026 09:56
kiryldz and others added 2 commits May 15, 2026 13:06
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>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.

Comment thread app/src/main/native/cpp/base_renderer.cpp Outdated
Comment thread app/src/main/native/cpp/core_engine.cpp
Comment thread app/src/main/native/cpp/base_renderer.hpp
kiryldz and others added 2 commits May 15, 2026 13:19
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>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.

Comment thread app/src/main/java/com/dz/camerafast/CoreEngine.kt
Comment thread app/src/main/native/cpp/core_engine.cpp
Comment thread app/src/main/native/cpp/vulkan_renderer.cpp
kiryldz and others added 3 commits May 15, 2026 15:07
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>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: reading renderer on the camera worker thread while nativeDestroy() resets it on another thread is still a C++ data race (UB). Consider guarding all accesses to renderer with the same mutex (including nativeSetSurface, nativeUpdateWindowSize, nativeSendCameraFrame, and nativeDestroy) or restructuring shutdown so the camera thread is joined before renderer.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 releases aNativeWindow (and any other native resources owned by CoreEngine). If destroy() is called before onSurfaceTextureDestroyed/nativeSetSurface(null, …) runs, the ANativeWindow_fromSurface strong ref can leak for the remainder of the process. Consider explicitly calling renderer->resetWindow() (if non-null) and ANativeWindow_release(aNativeWindow) / aNativeWindow = nullptr as part of destroy (and similarly releasing gpuBuffer if owned).
void CoreEngine::nativeDestroy(JNIEnv &env) {
  LOGI("Core engine destroy started");
  renderer.reset();
  LOGI("Core engine destroy passed");

Comment thread app/src/main/native/cpp/core_engine.cpp
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>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()
  }

Comment thread app/src/main/native/cpp/core_engine.cpp
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.

Comment thread app/src/main/native/cpp/base_renderer.cpp Outdated
Comment thread app/src/main/native/cpp/core_engine.cpp
kiryldz and others added 2 commits May 15, 2026 16:57
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>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.

Comment on lines +94 to +97
if (reschedule) {
renderThread->scheduleTask([this] {
updateWindowSize(pendingViewportWidth, pendingViewportHeight);
});
Comment thread app/src/main/native/cpp/core_engine.cpp
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants