Skip to content

Commit 62e76f5

Browse files
authored
feat(🤖): correct per-buffer YUV conversion for Android camera frames (#387)
Three improvements to the Android camera-frame path, found while reviewing the VisionCamera integration: 1. Expose GPUExternalTexture.yuvToRgbMatrix (non-spec extension). Dawn samples Android external-format (opaque YCbCr) buffers through a Vulkan conversion hard-coded to RGB_IDENTITY (SamplerVk.cpp, see crbug.com/497675620), so shaders receive raw [Y, Cb, Cr] and previously had to guess the conversion. The example hard-coded BT.709 narrow-range, but Android camera streams are usually BT.601, and range varies by device, which skews colors. The driver reports the correct model and range per buffer (suggestedYcbcrModel/Range); Dawn captures them at import time and we now read them back via the shared memory's AHardwareBuffer properties and derive the exact 3x4 conversion matrix. On iOS and for RGBA surfaces the matrix is the identity passthrough, so shaders can apply it unconditionally. The VisionCamera example now uploads the matrix as uniforms instead of hard-coding coefficients. 2. Fail fast on CPU-only AHardwareBuffers. Dawn only grants TextureBinding when the AHB was allocated with AHARDWAREBUFFER_USAGE_GPU_SAMPLED_IMAGE; without it, import fails deep inside Dawn with an opaque validation error. wrapNativeBuffer now checks the usage bits and throws an actionable error (pointing at vision-camera's pixelFormat: 'native'). 3. Remove the unreachable "defined biplanar format" branch on Android. Dawn maps every YUV AHB format (Y8Cb8Cr8_420, P010, vendor formats) to OpaqueYCbCrAndroid (AHBFunctions.cpp), so the R8BG8Biplanar420Unorm plane-splitting path and its BT.709 matrix could never run. Also folds the Android both-axes flip in the example into the rotation passed to importExternalTexture (a double mirror is a 180° rotation), removing the per-fragment flip branches from the WGSL prelude.
1 parent cb845d4 commit 62e76f5

7 files changed

Lines changed: 235 additions & 152 deletions

File tree

‎apps/example/src/VisionCamera/VisionCamera.tsx‎

Lines changed: 44 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,8 @@ const CameraView = () => {
167167
// This is what lets us call the interop factory off RNWebGPU, where the native
168168
// platform context already lives, instead of off `device`.
169169
const rnwgpu = RNWebGPU;
170+
// Captured as a plain bool so the worklet doesn't reach for Platform.
171+
const isAndroid = Platform.OS === "android";
170172
const devices = useCameraDevices();
171173
// Pick back camera if available, otherwise front, otherwise anything. The
172174
// iOS simulator returns an empty list since there are no cameras, in which
@@ -254,7 +256,8 @@ const CameraView = () => {
254256
minFilter: "linear",
255257
});
256258
const uniformBuffer = device.createBuffer({
257-
size: 32,
259+
// vec4f params + vec4u modes + 3x vec4f yuvToRgbMatrix rows.
260+
size: 80,
258261
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
259262
});
260263

@@ -282,7 +285,8 @@ const CameraView = () => {
282285
primitive: { topology: "triangle-list" },
283286
});
284287
const prepassUniformBuffer = device.createBuffer({
285-
size: 16,
288+
// 2x vec2f sizes + 3x vec4f yuvToRgbMatrix rows.
289+
size: 64,
286290
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
287291
});
288292

@@ -469,8 +473,11 @@ const CameraView = () => {
469473
// Orientation. The sensor buffer is landscape; frame.orientation is
470474
// the rotation needed to bring it upright. We hand that to Dawn via
471475
// importExternalTexture's `rotation`, which de-rotates the frame into
472-
// the portrait canvas. The residual vertical flip (Android buffer
473-
// Y-origin) and YUV->RGB are corrected in the shader (CAMERA_PRELUDE).
476+
// the portrait canvas. On Android the buffer additionally arrives
477+
// mirrored on both axes relative to the canvas (buffer Y-origin),
478+
// which is the same as an extra 180° rotation, folded in below.
479+
// YUV->RGB is applied in the shader via the
480+
// GPUExternalTexture.yuvToRgbMatrix uniform (see CAMERA_PRELUDE).
474481
let rotationDeg: 0 | 90 | 180 | 270 = 0;
475482
if (frame.orientation === "right") {
476483
rotationDeg = 90;
@@ -479,6 +486,9 @@ const CameraView = () => {
479486
} else if (frame.orientation === "left") {
480487
rotationDeg = 270;
481488
}
489+
if (isAndroid) {
490+
rotationDeg = ((rotationDeg + 180) % 360) as 0 | 90 | 180 | 270;
491+
}
482492
// A 90/270 rotation swaps the displayed width & height, so cover-fit
483493
// uses the post-rotation dimensions.
484494
const rotated = rotationDeg === 90 || rotationDeg === 270;
@@ -496,21 +506,6 @@ const CameraView = () => {
496506
} else {
497507
sy = frameAR / canvasAR;
498508
}
499-
// 32-byte uniform: vec4f params + vec4u modes. Built on a single
500-
// ArrayBuffer so the f32/u32 halves go up in one writeBuffer call.
501-
const uniformData = new ArrayBuffer(32);
502-
const uniformF32 = new Float32Array(uniformData);
503-
const uniformU32 = new Uint32Array(uniformData);
504-
uniformF32[0] = sx;
505-
uniformF32[1] = sy;
506-
uniformF32[2] = ABERRATION_STRENGTHS[modes.aberration] ?? 0;
507-
uniformF32[3] = PIXELATE_BLOCKS[modes.pixelate] ?? 0;
508-
uniformU32[4] = modes.effect;
509-
uniformU32[5] = modes.tint;
510-
uniformU32[6] = modes.vignette;
511-
uniformU32[7] = blurMode;
512-
device.queue.writeBuffer(uniformBuffer, 0, uniformData);
513-
514509
let externalTex;
515510
try {
516511
externalTex = device.importExternalTexture({
@@ -525,6 +520,29 @@ const CameraView = () => {
525520
);
526521
throw e;
527522
}
523+
// Per-buffer YUV->RGB conversion for the shader: the driver-derived
524+
// matrix on the Android opaque-YCbCr path, an identity passthrough
525+
// on iOS / RGBA surfaces (see CAMERA_PRELUDE).
526+
const yuvMatrix = externalTex.yuvToRgbMatrix;
527+
528+
// 80-byte uniform: vec4f params + vec4u modes + the three vec4f
529+
// yuvToRgbMatrix rows. Built on a single ArrayBuffer so the f32/u32
530+
// halves go up in one writeBuffer call.
531+
const uniformData = new ArrayBuffer(80);
532+
const uniformF32 = new Float32Array(uniformData);
533+
const uniformU32 = new Uint32Array(uniformData);
534+
uniformF32[0] = sx;
535+
uniformF32[1] = sy;
536+
uniformF32[2] = ABERRATION_STRENGTHS[modes.aberration] ?? 0;
537+
uniformF32[3] = PIXELATE_BLOCKS[modes.pixelate] ?? 0;
538+
uniformU32[4] = modes.effect;
539+
uniformU32[5] = modes.tint;
540+
uniformU32[6] = modes.vignette;
541+
uniformU32[7] = blurMode;
542+
for (let i = 0; i < 12; i++) {
543+
uniformF32[8 + i] = yuvMatrix[i] ?? 0;
544+
}
545+
device.queue.writeBuffer(uniformBuffer, 0, uniformData);
528546
const bindGroup = device.createBindGroup({
529547
layout: pipeline.getBindGroupLayout(0),
530548
entries: [
@@ -542,7 +560,13 @@ const CameraView = () => {
542560
device.queue.writeBuffer(
543561
prepassUniformBuffer,
544562
0,
545-
new Float32Array([dispW, dispH, canvasWidth, canvasHeight]),
563+
new Float32Array([
564+
dispW,
565+
dispH,
566+
canvasWidth,
567+
canvasHeight,
568+
...yuvMatrix,
569+
]),
546570
);
547571
const prepassBindGroup = device.createBindGroup({
548572
layout: prepassPipeline.getBindGroupLayout(0),

‎apps/example/src/VisionCamera/blurShaders.ts‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ export const PREPASS_SHADER = /* wgsl */ `
2121
struct PrepassUniforms {
2222
texSize: vec2f,
2323
canvasSize: vec2f,
24+
// The three rows of GPUExternalTexture.yuvToRgbMatrix (see CAMERA_PRELUDE).
25+
yuv0: vec4f,
26+
yuv1: vec4f,
27+
yuv2: vec4f,
2428
};
2529
2630
@group(0) @binding(0) var srcTex: texture_external;
@@ -63,14 +67,14 @@ fn fs_main(in: VsOut) -> @location(0) vec4f {
6367
scale = vec2f(1.0, texAR / canvasAR);
6468
}
6569
let uv = vec2f(0.5) + (in.uv - vec2f(0.5)) * scale;
66-
// cameraCoord (vertical flip) + cameraDecode (YUV->RGB) come from
67-
// CAMERA_PRELUDE, prepended when this module is compiled. They are no-ops on
68-
// iOS and handle the Android opaque-YUV case.
70+
// cameraDecode (from CAMERA_PRELUDE, prepended when this module is
71+
// compiled) applies GPUExternalTexture.yuvToRgbMatrix: the real YUV->RGB
72+
// conversion on the Android opaque-YUV path, an identity passthrough on iOS.
6973
let c = cameraDecode(textureSampleBaseClampToEdge(
7074
srcTex,
7175
srcSampler,
72-
cameraCoord(clamp(uv, vec2f(0.0), vec2f(1.0))),
73-
));
76+
clamp(uv, vec2f(0.0), vec2f(1.0)),
77+
), u.yuv0, u.yuv1, u.yuv2);
7478
return vec4f(c.rgb, 1.0);
7579
}
7680
`;

‎apps/example/src/VisionCamera/shaders.ts‎

Lines changed: 23 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
1-
import { Platform } from "react-native";
2-
31
// Main camera-effects shader: samples the imported external texture (camera
42
// frame) with cover-fit + optional chromatic aberration / pixelate, optionally
53
// mixes in the pre-blurred backdrop, then applies effect / tint / vignette.
6-
// `cameraCoord` / `cameraDecode` come from CAMERA_PRELUDE, which is prepended
7-
// at shader-module creation time.
4+
// `cameraDecode` comes from CAMERA_PRELUDE, which is prepended at
5+
// shader-module creation time.
86
export const SHADER = /* wgsl */ `
97
struct VsOut {
108
@builtin(position) position: vec4f,
@@ -22,6 +20,10 @@ struct Uniforms {
2220
// w: blurMode (0 off, 1 strong - blurred everywhere (prepass bakes
2321
// cover-fit), 2 overlay - blurred backdrop + sharp card)
2422
modes: vec4u,
23+
// The three rows of GPUExternalTexture.yuvToRgbMatrix (see CAMERA_PRELUDE).
24+
yuv0: vec4f,
25+
yuv1: vec4f,
26+
yuv2: vec4f,
2527
};
2628
2729
@group(0) @binding(0) var srcTex: texture_external;
@@ -56,7 +58,8 @@ fn snap(uv: vec2f, block: f32) -> vec2f {
5658
5759
fn sampleExternal(uv: vec2f, block: f32) -> vec4f {
5860
return cameraDecode(
59-
textureSampleBaseClampToEdge(srcTex, srcSampler, cameraCoord(snap(uv, block))),
61+
textureSampleBaseClampToEdge(srcTex, srcSampler, snap(uv, block)),
62+
u.yuv0, u.yuv1, u.yuv2,
6063
);
6164
}
6265
@@ -177,43 +180,21 @@ fn fs_main(in: VsOut) -> @location(0) vec4f {
177180
// YCbCr conversion that is hard-coded to RGB_IDENTITY (Dawn's
178181
// SamplerVk.cpp::GetYCbCrForTextureView; see crbug.com/497675620), so the
179182
// external sample comes back as raw [Y, Cb, Cr] on *every* device — this is by
180-
// design, not a driver quirk — and we do the BT.709 YUV->RGB ourselves below.
181-
// (Dawn's own SharedTextureMemoryOpaqueYCbCrAndroidForExternalTexture
182-
// .NoopSampleY8Cb8Cr8AHB test asserts the same raw passthrough.) The frame also
183-
// comes out mirrored on both axes relative to the canvas (Android buffer
184-
// origin), so we flip X and Y. iOS goes through the native two-plane path,
185-
// which already converts and orients, so this correction is Android-only. The
186-
// prelude is prepended to every shader module that samples the camera (main
187-
// pass + blur prepass).
183+
// design, not a driver quirk. The correct conversion depends on the buffer:
184+
// react-native-webgpu derives it from the driver's suggested YCbCr model and
185+
// range (BT.601/709/2020, full/narrow) and exposes it as
186+
// GPUExternalTexture.yuvToRgbMatrix; the worklet uploads its three rows as
187+
// vec4f uniforms and cameraDecode applies them. On iOS (native two-plane path,
188+
// already converted by Dawn) and for RGBA surfaces the matrix is the identity
189+
// passthrough, so the decode is safe to apply unconditionally. The prelude is
190+
// prepended to every shader module that samples the camera (main pass + blur
191+
// prepass).
188192
export const CAMERA_PRELUDE = /* wgsl */ `
189-
const CAMERA_IS_YUV: bool = ${Platform.OS === "android"};
190-
const CAMERA_FLIP_X: bool = ${Platform.OS === "android"};
191-
const CAMERA_FLIP_Y: bool = ${Platform.OS === "android"};
192-
193-
fn cameraCoord(uv: vec2f) -> vec2f {
194-
var c = uv;
195-
if (CAMERA_FLIP_X) {
196-
c.x = 1.0 - c.x;
197-
}
198-
if (CAMERA_FLIP_Y) {
199-
c.y = 1.0 - c.y;
200-
}
201-
return c;
202-
}
203-
204-
// BT.709 limited-range YUV -> RGB. On the Android opaque path the sampled
205-
// channels are always raw [Y, Cb, Cr] (Dawn forces an RGB_IDENTITY Vulkan
206-
// conversion); a no-op passthrough on every other platform.
207-
fn cameraDecode(c: vec4f) -> vec4f {
208-
if (!CAMERA_IS_YUV) {
209-
return c;
210-
}
211-
let y = c.r - 0.0627451;
212-
let cb = c.g - 0.5;
213-
let cr = c.b - 0.5;
214-
let r = 1.164384 * y + 1.792741 * cr;
215-
let g = 1.164384 * y - 0.213249 * cb - 0.532909 * cr;
216-
let b = 1.164384 * y + 2.112402 * cb;
217-
return vec4f(clamp(vec3f(r, g, b), vec3f(0.0), vec3f(1.0)), 1.0);
193+
// Apply a 3x4 row-major [c.r, c.g, c.b, 1] -> R'G'B' matrix
194+
// (GPUExternalTexture.yuvToRgbMatrix, see above).
195+
fn cameraDecode(c: vec4f, m0: vec4f, m1: vec4f, m2: vec4f) -> vec4f {
196+
let v = vec4f(c.rgb, 1.0);
197+
let rgb = vec3f(dot(m0, v), dot(m1, v), dot(m2, v));
198+
return vec4f(clamp(rgb, vec3f(0.0), vec3f(1.0)), 1.0);
218199
}
219200
`;

‎packages/webgpu/android/cpp/AndroidPlatformContext.h‎

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -241,8 +241,8 @@ class AndroidPlatformContext : public PlatformContext {
241241
const uint32_t stridePixels = actualDesc.stride;
242242

243243
void *vaddr = nullptr;
244-
int rc = AHardwareBuffer_lock(buffer, AHARDWAREBUFFER_USAGE_CPU_WRITE_RARELY,
245-
-1, nullptr, &vaddr);
244+
int rc = AHardwareBuffer_lock(
245+
buffer, AHARDWAREBUFFER_USAGE_CPU_WRITE_RARELY, -1, nullptr, &vaddr);
246246
if (rc != 0 || !vaddr) {
247247
AHardwareBuffer_release(buffer);
248248
throw std::runtime_error(
@@ -302,6 +302,21 @@ class AndroidPlatformContext : public PlatformContext {
302302
AHardwareBuffer_Desc desc = {};
303303
AHardwareBuffer_describe(buffer, &desc);
304304

305+
// Dawn derives the importable WebGPU usages from the AHB's usage bits;
306+
// without GPU_SAMPLED_IMAGE the imported texture never gets
307+
// TextureBinding and import fails deep inside Dawn with an opaque
308+
// validation error. Surface the real cause here instead. (CameraX's
309+
// default ImageReaders allocate CPU-only buffers; with
310+
// react-native-vision-camera, use pixelFormat: 'native' which allocates
311+
// GPU-sampleable buffers.)
312+
if ((desc.usage & AHARDWAREBUFFER_USAGE_GPU_SAMPLED_IMAGE) == 0) {
313+
throw std::runtime_error(
314+
"wrapNativeBuffer: this AHardwareBuffer was allocated without "
315+
"AHARDWAREBUFFER_USAGE_GPU_SAMPLED_IMAGE, so the GPU cannot sample "
316+
"it. Camera/CPU pipelines must allocate frames with GPU usage (for "
317+
"react-native-vision-camera, use pixelFormat: 'native').");
318+
}
319+
305320
AHardwareBuffer_acquire(buffer);
306321

307322
VideoFrameHandle handle;
@@ -312,13 +327,13 @@ class AndroidPlatformContext : public PlatformContext {
312327
// Dawn's OpaqueYCbCrAndroidForExternalTexture path. Single-plane RGBA AHBs
313328
// take the plain BGRA8 path (sampled as a regular 2D texture).
314329
switch (desc.format) {
315-
case AHARDWAREBUFFER_FORMAT_Y8Cb8Cr8_420:
316-
case AHARDWAREBUFFER_FORMAT_YCbCr_P010:
317-
handle.pixelFormat = VideoPixelFormat::NV12;
318-
break;
319-
default:
320-
handle.pixelFormat = VideoPixelFormat::BGRA8;
321-
break;
330+
case AHARDWAREBUFFER_FORMAT_Y8Cb8Cr8_420:
331+
case AHARDWAREBUFFER_FORMAT_YCbCr_P010:
332+
handle.pixelFormat = VideoPixelFormat::NV12;
333+
break;
334+
default:
335+
handle.pixelFormat = VideoPixelFormat::BGRA8;
336+
break;
322337
}
323338
handle.deleter = [buffer]() { AHardwareBuffer_release(buffer); };
324339
return handle;

0 commit comments

Comments
 (0)