Project-wide notes for future sessions. Captures non-obvious context — architecture intent, instrumentation contracts, build gotchas, baseline findings. Read this before diving into code, especially before touching the camera→GPU pipeline or the build.
Default to no comments. Only add one when the why isn't obvious from the code — a non-trivial invariant, a thread/race subtlety, a cross-file contract, or a workaround. Identifiers carry the what; long block-comments explaining the what are noise. If you find yourself writing one, ask whether the explanation belongs in this file (architecture / design intent) or a skill (workflow steps) instead.
Android demo app showing how to feed AHardwareBuffers from the camera into both an OpenGL ES 3 and a Vulkan 1.3 renderer with zero CPU copies (when the buffer's USAGE_GPU_SAMPLED_IMAGE flag is set), then present to a TextureView. Almost all rendering lives in C++ via the NDK; Kotlin is glue + Compose UI.
Single activity (CameraActivity). Default view is dual-pane — OpenGL preview on top, Vulkan preview on bottom — clickable to collapse one side. Edge-to-edge UI: previews go behind transparent system bars; the header label and bottom button row respect insets.
CameraX (RGBA_8888) or Camera2 (PRIVATE)
↓ (analyzer / OnImageAvailable, on a single worker thread)
imageProxy.image.hardwareBuffer + FrameTrace.nextFrameId()
↓ CoreEngine.sendCameraFrame() ← one call per engine (GL + VK)
nativeSendCameraFrame (JNI)
↓
CoreEngine::nativeSendCameraFrame (C++, worker thread)
↓ GPU-sampled path: renderer->processCameraFrame(buf, …, frameId)
↓ CPU fallback path: memcpy into a self-allocated GPU buffer, then same
BaseRenderer::processCameraFrame
↓ schedules a task on the renderer's LooperThread
[render thread]
↓ hwBufferToTexture() ← becomes EGLImage / VkImage backed by external memory
↓ postChoreographerCallback()
[Choreographer doFrame on render thread]
↓ renderImpl() ← draws + eglSwapBuffers / vkQueuePresentKHR
Key files:
app/src/main/java/com/dz/camerafast/CameraX.kt,Camera2.kt— analyzer entry points.app/src/main/java/com/dz/camerafast/CoreEngine.kt— Kotlin-side JNI surface +TextureView.SurfaceTextureListener.app/src/main/native/cpp/core_engine.{hpp,cpp}— JNI peer; holds the renderer and theANativeWindow.app/src/main/native/cpp/base_renderer.{hpp,cpp}— render-thread Looper, frame-task scheduling, viewport/MVP coalescing, thependingPresentFrameIdinterlock.app/src/main/native/cpp/{opengl,vulkan}_renderer.{hpp,cpp}— backend-specific code; the only place EGL/GL or Vulkan symbols are touched.
The CPU fallback in nativeSendCameraFrame exists because CameraX's ImageAnalysis delivers HW buffers with USAGE_CPU_* flags rather than USAGE_GPU_SAMPLED_IMAGE. We re-allocate a GPU buffer once, memcpy into it each frame (drops the engine mutex during the slow memcpy so main-thread JNI calls aren't blocked), then hand it to the renderer. Camera2 with ImageFormat.PRIVATE + USAGE_GPU_SAMPLED_IMAGE skips that copy entirely — that's the point of supporting both APIs.
Per camera frame, the analyzer assigns a process-wide monotonic frameId: Int via FrameTrace.nextFrameId(). That id is the cookie of four ATrace async sections per renderer, plus one sync sub-section nested inside the renderer's renderImpl:
| Section | Kind | Begin (where) | End (where) | What it measures |
|---|---|---|---|---|
dz.frame_to_native.<gl|vk> |
async | Kotlin analyzer | BaseRenderer::processCameraFrame JNI entry |
Kotlin glue overhead |
dz.frame_native_proc.<gl|vk> |
async | processCameraFrame entry |
end of hwBufferToTexture on render thread |
Native processing |
dz.frame_to_screen.<gl|vk> |
async | end of hwBufferToTexture |
after eglSwapBuffers / vkQueuePresentKHR |
Submit→present (mostly vsync wait at 60 Hz vs 30 fps camera) |
dz.frame_render.<gl|vk> |
sync | renderImpl entry |
just before eglSwapBuffers / vkQueuePresentKHR |
Actual GPU command submission only — subtract from frame_to_screen to isolate vsync wait |
dz.frame_e2e.<gl|vk> |
async | Kotlin analyzer (same point as to_native begin) |
swap/present returned | End-to-end |
A counter track tracks dropped frames per renderer:
| Counter | When incremented |
|---|---|
dz.dropped_frames.<gl|vk> |
When a newer camera frame's texture is staged before the previous one's Choreographer callback fires |
Section + counter name constants live in one place — app/src/main/native/cpp/frame_trace.hpp. The Kotlin FrameTrace object exposes @JvmStatic external methods backed by app/src/main/native/cpp/frame_trace.cpp; its FRAME_E2E_GL / FRAME_E2E_VK / FRAME_TO_NATIVE_GL / FRAME_TO_NATIVE_VK @JvmField vals are populated from C++ at class-load. Don't add new constants to Kotlin — extend traceNames in frame_trace.hpp and add a getter.
Design decisions worth remembering:
frameIdis anInt, not the sensor timestamp. ATrace's async-section cookie is 32-bit; sensor timestamps in nanoseconds overflow. Astd::atomic<int32_t>inframe_trace.cpp(exposed asFrameTrace.nextFrameId()) avoids that. We useframeIdrather than "cookie" in our wrappers because we always pass a frame number; the term "cookie" is only kept where the underlying ATrace API uses it.- Superseded frames' async slices are closed at the supersede point, not left dangling. This keeps the Perfetto UI clean (no slices extending to infinity). The closed-on-supersede durations are within ~1/N of completed frames so they barely perturb aggregate stats; the
dropped_framescounter remains the authoritative drop count. dz.frame_renderis a sync section because both begin and end happen on the render thread within a single function. Use sync (not async) when the section doesn't cross threads, doesn't overlap with others of the same name, and doesn't need per-instance identification — sync sections are slightly cheaper and don't need a cookie.- ATrace async APIs are API 29+;
minSdkis 29 to match. No weak-linking dance —frame_trace.hppincludes<android/trace.h>and callsATrace_beginAsyncSection/endAsyncSectiondirectly. IfminSdkever drops below 29 again, the helpers will need to come back as weak symbols. -a com.dz.camerafastis mandatory when invokingperfetto. Without it, app-tag atrace sections (which is where these slices land) are filtered out and the trace processor returns zero rows. Both helper scripts always pass it; if you ever invokeperfettoby hand, don't forget.- Profileable for release.
AndroidManifest.xmldeclares<profileable android:shell="true"/>inside<application>so Perfetto can attach to a release build without it beingdebuggable. Always measure on release (installRelease) — debug NDK builds add ~50–100% overhead to the C++ paths, which would skew the baseline.
| What you want | Skill / Script |
|---|---|
| One-shot frame-latency measurement (single N-second capture) | scripts/measure-frame-latency.sh [seconds] |
| Establish a local baseline with dispersion (5 × 10s by default, JSON output) | scripts/baseline-frame-latency.sh — invokable via /frame-latency-baseline |
| Run the CI capture instrumentation test locally on a tethered device | ./gradlew :app:connectedReleaseAndroidTest -Pandroid.injected.build.abi=arm64-v8a -Pandroid.testInstrumentationRunnerArguments.additionalTestOutputDir=/sdcard/Android/media/com.dz.camerafast/additional_test_output |
| Aggregate perfetto traces (from FTL or local connected test) into results.json | scripts/aggregate-traces.py <traces-dir> <output.json> |
| Compare results.json against a per-GPU baseline | scripts/compare-baseline.py benchmark/baselines/baseline-<gpu>.json results.json |
| Build, install, launch, screenshot for visual verification of UI changes | /verify-on-device |
| Discover Android-platform skills (camera, performance, perfetto-sql, etc.) | vendor/android-skills/ submodule |
The bash scripts and the :app/androidTest capture (FrameLatencyCapture) both emit .pftrace files that scripts/aggregate-traces.py consumes, so there is one place for stats math. All tools assume a single ADB device locally; set ANDROID_SERIAL=<serial> if multiple are attached. trace_processor is auto-downloaded to .cache/frame-latency/ (gitignored, ~25 MB) on first use.
These have bitten before; capture them so they don't bite again.
- Use
installReleasefor any latency measurement — debug builds add significant NDK overhead. The release build is signed with the debug keystore (signingConfig signingConfigs.debuginapp/build.gradle) so it installs locally without provisioning a real keystore. assembleDebug/assembleReleaseboth fail forarmeabi-v7abecause the repo shipslibs/shaderc/c++_static/{arm64-v8a,x86_64}/libshaderc.abut not the v7a copy. Always limit to a single ABI:./gradlew :app:installRelease -Pandroid.injected.build.abi=$(adb shell getprop ro.product.cpu.abi | tr -d '\r')- Multi-display devices (foldables) corrupt
adb exec-out screencapby prefixing the PNG with a stdout warning. Pass-d <display-id>after discovering viaadb shell dumpsys SurfaceFlinger --display-id. Trace capture is unaffected. - Camera permission is gated by activity finish-on-deny. Pre-grant it (
adb shell pm grant com.dz.camerafast android.permission.CAMERA) when scripting; the helper scripts already do. - Renderer state survives
am startwithoutforce-stop. The helper scripts alwaysforce-stopbefore launch, andbaseline-frame-latency.shalso force-stops between runs so each iteration sees a cold app.
These are the numbers a future PR should be diffed against. Headline metric is dz.frame_e2e.<gl|vk> — full camera-arrival → on-screen latency. JSON form at .cache/frame-latency/baseline.json.
| Metric | mean | CV% (across 5 runs) |
|---|---|---|
frame_e2e.gl.avg |
13.25 ms | 1.8% |
frame_e2e.gl.p90 |
20.72 ms | 1.4% |
frame_e2e.gl.p99 |
23.45 ms | 1.3% |
frame_e2e.vk.avg |
14.91 ms | 1.3% |
frame_e2e.vk.p90 |
22.48 ms | 1.0% |
frame_e2e.vk.p99 |
25.50 ms | 2.6% |
frame_to_screen.gl.p90 |
18.42 ms | 0.8% |
frame_to_screen.vk.p90 |
19.02 ms | 1.0% |
frame_render.gl.avg |
0.57 ms | 6.4% |
frame_render.vk.avg |
1.76 ms | 5.0% |
frame_native_proc.gl.avg |
0.73 ms | 6.2% |
frame_native_proc.vk.avg |
1.66 ms | 2.4% |
OpenGL is ~1.7 ms faster end-to-end on average (frame_e2e avg 13.25 vs 14.91), and ~1.8 ms faster at p90 (20.7 vs 22.5). The gap is split between frame_render (GL 0.57 vs VK 1.76) and frame_native_proc (GL 0.73 vs VK 1.66) — Vulkan's hwBuffer → vkImage path and vkAcquireNextImageKHR + submit + fence wait both cost more than the OpenGL equivalents.
Most of frame_to_screen is vsync wait. frame_to_screen.gl.avg ≈ 10.9 ms but only ~0.57 ms of that is actual GL command submission (frame_render.gl.avg); the remaining ~10.3 ms is Choreographer/vsync wait. Same shape for Vulkan: ~11.7 ms total vs ~1.76 ms of work. Optimizations that shave µs off GL/VK commands won't move the e2e needle until the vsync wait is what we're trying to displace (e.g. higher refresh rate, lower-latency presentation extensions).
Which metrics to gate PRs on:
- Tight (±5% AND ±1.5 ms):
frame_e2e.{gl,vk}.p90,frame_to_screen.{gl,vk}.p90. All sub-3% CV locally; the 1.5 ms floor is calibrated for FTL Pixel 6 / Galaxy A52s, which drift ~1 ms run-to-run on identical commits (vs ~0.25 ms on local SM-F936B). - Looser (±10% AND ±0.5 ms, or ±5 frames for counters):
frame_e2e.{gl,vk}.p99,dropped_frames.{gl,vk}. CV 2–7%. p99 is the worst 1% of frames per iteration — inherently outlier-sensitive and observed to be the noisiest tight-tier metric on FTL, so it lives in loose despite frame_e2e.p90 staying tight. - Watch only, no gate: every
avg(means are skewed by one slow frame and not representative — p90 captures steady state, p99 the tail), plusframe_native_proc.{gl,vk}.{p90, p99}andframe_render.{gl,vk}.{p90, p99}(5–25% CV — single-tail-sample noise). - Skip entirely: every
max(single-outlier sensitive, 15–40% CV), andp50on screen-facing stages (bimodal — submit-to-vsync alignment).
Dual gate (relative + absolute floor). Each gated tier has both a percentage tolerance and an absolute floor. A metric passes when either threshold is satisfied — |Δ%| ≤ tolerance_pct OR |Δabs| ≤ abs_floor. The absolute floor exists because sub-ms metrics like frame_native_proc.avg (~0.7 ms baseline) blow up to +14% on a 0.1 ms shift that is below any frame-budget significance. Real regressions exceed both thresholds; pure relative noise on tiny absolutes is filtered out.
Slice counts are deterministic to within ±1 per 10 s window: ~298 frames per renderer (~30 fps from camera). A meaningful deviation in count is itself a regression signal.
Three required GitHub Actions checks gate every PR:
| Check | File | What it does |
|---|---|---|
build |
.github/workflows/build.yml |
assembleRelease + assembleReleaseAndroidTest (arm64-v8a), uploads APK artifacts |
benchmark-adreno |
.github/workflows/benchmark.yml |
Runs com.dz.camerafast.perf.FrameLatencyCapture (an :app/androidTest instrumentation test that drives N×Ds Perfetto captures) on FTL Galaxy A52s 5G (Adreno 642L, API 34), compares against benchmark/baselines/baseline-adreno.json |
benchmark-mali |
.github/workflows/benchmark.yml |
Same on FTL Pixel 6 (Mali-G78, API 33), compares against benchmark/baselines/baseline-mali.json |
The compare step uses two-sided tolerance gates from benchmark/gates.yaml (tight ±5%/±1.5 ms, loose ±10%/±0.5 ms — pass if EITHER bound holds):
- Exit 1 (regression) — blocks merge; fix the performance issue.
- Exit 2 (improvement) — also blocks merge; copy the proposed JSON from the step summary into
benchmark/baselines/baseline-<gpu>.jsonand commit. - Exit 0 — all gated metrics within tolerance; green.
Device source: Firebase Test Lab Spark free tier (5 physical runs/day, $0). See docs/ci-setup.md for one-time GCP setup (~15 min) and the swap path to BrowserStack Open Source Program (unlimited, apply separately).
Per-GPU baseline files live under benchmark/baselines/. They are placeholders until the first FTL CI run seeds them — see benchmark/baselines/README.md.
vendor/android-skills/— Google's official AI-optimized SKILL.md files for Android dev (Perfetto SQL, edge-to-edge, AGP upgrades, R8 analyzer, Macrobenchmark testing-setup, etc.). Browsevendor/android-skills/<topic>/<skill>/SKILL.mdrather than guessing — every skill there has a precise scope statement at the top.vendor/jni.hpp/— submodule for the Mapboxjni.hppC++/JNI wrapper. Used bycore_engine.{hpp,cpp}(native peer) andframe_trace.{hpp,cpp}(static-only viaSTATIC_METHODfromutil.hpp).local.propertiesis gitignored; thesdk.dirline is required for Gradle to find your SDK.