Skip to content

Commit bc83857

Browse files
authored
fix(🐛): ImageBitmap alpha and options (#434)
Two snapshot-based suites covering createImageBitmap and copyExternalImageToTexture, with baselines validated against Chrome (yarn test:ref) and the dawn.node client (yarn test:node):
1 parent 36c1196 commit bc83857

28 files changed

Lines changed: 973 additions & 70 deletions

apps/example/ios/Podfile.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3074,7 +3074,7 @@ SPEC CHECKSUMS:
30743074
React-microtasksnativemodule: 75b6604b667d297292345302cc5bfb6b6aeccc1b
30753075
react-native-safe-area-context: c00143b4823773bba23f2f19f85663ae89ceb460
30763076
react-native-skia: fc73e9bdc46ebb420a98c9c2be29fee80f565e79
3077-
react-native-webgpu: 3eb051bebe030f328725fc318a147599c4628e4e
3077+
react-native-webgpu: cc416064f9c8a68c6fde764a6c36305676f5fd65
30783078
React-NativeModulesApple: 879fbdc5dcff7136abceb7880fe8a2022a1bd7c3
30793079
React-oscompat: 93b5535ea7f7dff46aaee4f78309a70979bdde9d
30803080
React-perflogger: 5536d2df3d18fe0920263466f7b46a56351c0510

apps/example/src/useClient.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import { useEffect, useState } from "react";
22
import { Platform } from "react-native";
33

4-
const ANDROID_WS_HOST = "10.0.2.2";
5-
const IOS_WS_HOST = "localhost";
6-
const HOST = Platform.OS === "android" ? ANDROID_WS_HOST : IOS_WS_HOST;
4+
// Both platforms reach the test server on localhost: the iOS Simulator shares
5+
// the host network, and on Android the jest globalSetup runs
6+
// "adb reverse tcp:4242 tcp:4242" (as the RN CLI does for Metro on 8081), which
7+
// covers the emulator and a physical device alike. Only a device that cannot be
8+
// reached over adb needs this machine's LAN IP here instead.
9+
const HOST = "localhost";
710
const PORT = 4242;
811

912
type UseClient = [client: WebSocket | null, hostname: string];
@@ -27,7 +30,9 @@ export const useClient = (): UseClient => {
2730
};
2831
ws.onopen = () => {
2932
setClient(ws);
30-
ws.send(JSON.stringify({ OS: Platform.OS, arch: "paper" }));
33+
// The host is reported back so the test server can hand out fixture URLs
34+
// this device can actually reach (see fixtureUrl in setup.ts).
35+
ws.send(JSON.stringify({ OS: Platform.OS, arch: "paper", host: HOST }));
3136
};
3237
// Reconnect on every close, not only on error: the test server closes the
3338
// socket cleanly at the end of each jest run, and without a retry here the

packages/webgpu/android/cpp/AndroidPlatformContext.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,9 @@ class AndroidPlatformContext : public PlatformContext {
179179
result.height = static_cast<int>(bitmapInfo.height);
180180
result.data.resize(bitmapInfo.height * bitmapInfo.stride);
181181
memcpy(result.data.data(), bitmapPixels, result.data.size());
182+
// BitmapFactory hands back premultiplied ARGB_8888 pixels; createImageBitmap
183+
// converts to the representation requested by premultiplyAlpha.
184+
result.premultiplied = true;
182185

183186
AndroidBitmap_unlockPixels(env, bitmap);
184187

packages/webgpu/apple/ApplePlatformContext.mm

Lines changed: 23 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
#import <AVFoundation/AVFoundation.h>
66
#import <CoreVideo/CoreVideo.h>
7+
#import <ImageIO/ImageIO.h>
78
#import <React/RCTBlobManager.h>
89
#import <React/RCTBridge+Private.h>
910
#import <ReactCommon/RCTTurboModule.h>
@@ -88,36 +89,32 @@ void checkIfUsingSimulatorWithAPIValidation() {
8889

8990
ImageData
9091
ApplePlatformContext::createImageBitmapFromData(std::span<const uint8_t> data) {
91-
// This avoids a copy by assuming the UIImage/NSImage constructors
92-
// decode `nsData` eagerly before the memory for the wrapped `data`
93-
// is freed.
94-
//
95-
// Since we get the `CGImageRef` from `image` and then throw
96-
// it away, that's a fairly safe assumption.
92+
// All formats are decoded through ImageIO. Apple's imaging stack always
93+
// premultiplies alpha at decode, so the result is flagged premultiplied and
94+
// createImageBitmap / copyExternalImageToTexture convert from there. This
95+
// means premultiplyAlpha "none" is a lossy round trip for low-alpha pixels
96+
// (as it is on Android); the snapshot suites compare with pixelmatch
97+
// tolerance to absorb it.
9798
NSData *nsData =
9899
[NSData dataWithBytesNoCopy:const_cast<uint8_t *>(data.data())
99100
length:data.size()
100101
freeWhenDone:NO];
101102

102-
#if !TARGET_OS_OSX
103-
UIImage *image = [UIImage imageWithData:nsData];
104-
#else
105-
NSImage *image = [[NSImage alloc] initWithData:nsData];
106-
#endif
107-
if (!image) {
103+
CGImageSourceRef imageSource =
104+
CGImageSourceCreateWithData((__bridge CFDataRef)nsData, NULL);
105+
if (imageSource == NULL) {
106+
throw std::runtime_error("Couldn't create image source");
107+
}
108+
NSDictionary *decodeOptions = @{(id)kCGImageSourceShouldCache : @NO};
109+
CGImageRef cgImage = CGImageSourceCreateImageAtIndex(
110+
imageSource, 0, (__bridge CFDictionaryRef)decodeOptions);
111+
CFRelease(imageSource);
112+
if (cgImage == NULL) {
108113
throw std::runtime_error("Couldn't decode image");
109114
}
110115

111-
#if !TARGET_OS_OSX
112-
CGImageRef cgImage = image.CGImage;
113-
#else
114-
CGImageRef cgImage = [image CGImageForProposedRect:NULL
115-
context:NULL
116-
hints:NULL];
117-
#endif
118116
size_t width = CGImageGetWidth(cgImage);
119117
size_t height = CGImageGetHeight(cgImage);
120-
size_t bitsPerComponent = 8;
121118
size_t bytesPerRow = width * 4;
122119

123120
ImageData result;
@@ -126,16 +123,18 @@ void checkIfUsingSimulatorWithAPIValidation() {
126123
result.data.resize(height * bytesPerRow);
127124
result.format = wgpu::TextureFormat::RGBA8Unorm;
128125

126+
// The draw premultiplies alpha; flag the result premultiplied so
127+
// createImageBitmap and copyExternalImageToTexture convert consistently.
129128
CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
130129
CGContextRef context = CGBitmapContextCreate(
131-
result.data.data(), width, height, bitsPerComponent, bytesPerRow,
132-
colorSpace, kCGImageAlphaPremultipliedLast | kCGBitmapByteOrder32Big);
133-
130+
result.data.data(), width, height, 8, bytesPerRow, colorSpace,
131+
kCGImageAlphaPremultipliedLast | kCGBitmapByteOrder32Big);
134132
CGContextDrawImage(context, CGRectMake(0, 0, width, height), cgImage);
135-
136133
CGContextRelease(context);
137134
CGColorSpaceRelease(colorSpace);
135+
CGImageRelease(cgImage);
138136

137+
result.premultiplied = true;
139138
return result;
140139
}
141140

packages/webgpu/cpp/rnwgpu/PlatformContext.h

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ struct ImageData {
1616
size_t width;
1717
size_t height;
1818
wgpu::TextureFormat format;
19+
// Whether the RGB channels are premultiplied by alpha. Each platform decoder
20+
// records the representation it produced; createImageBitmap then converts to
21+
// the representation requested through premultiplyAlpha, and
22+
// copyExternalImageToTexture converts again to match the destination's
23+
// premultipliedAlpha. Defaults to true because most native decoders
24+
// (CoreGraphics, Android Bitmap) hand back premultiplied pixels.
25+
bool premultiplied = true;
1926
};
2027

2128
// Pixel layout of a VideoFrame. Determines whether the underlying surface is

packages/webgpu/cpp/rnwgpu/api/GPUQueue.cpp

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -128,26 +128,40 @@ void GPUQueue::copyExternalImageToTexture(
128128
throw std::runtime_error("Invalid input for GPUQueue::writeTexture()");
129129
}
130130

131-
if (source->flipY.value_or(false)) {
132-
// Calculate the row size and total size
131+
const bool flipY = source->flipY.value_or(false);
132+
// premultipliedAlpha defaults to false per the WebGPU spec: an untagged
133+
// destination expects straight alpha. Convert only when the ImageBitmap's
134+
// representation differs, using the same rounding as the reference client.
135+
const bool sourcePremultiplied = source->source->getPremultiplied();
136+
const bool destinationPremultiplied =
137+
destination->premultipliedAlpha.value_or(false);
138+
const bool needsAlphaConversion =
139+
bytesPerPixel == 4 && sourcePremultiplied != destinationPremultiplied;
140+
141+
if (flipY || needsAlphaConversion) {
133142
uint32_t rowSize = bytesPerPixel * source->source->getWidth();
134143
uint32_t totalSize = source->source->getSize();
135144

136-
// Create a new buffer for the flipped data
137-
std::vector<uint8_t> flippedData(totalSize);
138-
139-
// Flip the data vertically
140-
for (uint32_t row = 0; row < source->source->getHeight(); ++row) {
141-
std::memcpy(flippedData.data() +
142-
(source->source->getHeight() - 1 - row) * rowSize,
143-
static_cast<const uint8_t *>(source->source->getData()) +
144-
row * rowSize,
145-
rowSize);
145+
// Stage a mutable copy so flipping and/or alpha conversion never touch the
146+
// ImageBitmap's backing store.
147+
std::vector<uint8_t> staged(totalSize);
148+
const uint8_t *src =
149+
static_cast<const uint8_t *>(source->source->getData());
150+
if (flipY) {
151+
for (uint32_t row = 0; row < source->source->getHeight(); ++row) {
152+
std::memcpy(staged.data() +
153+
(source->source->getHeight() - 1 - row) * rowSize,
154+
src + row * rowSize, rowSize);
155+
}
156+
} else {
157+
std::memcpy(staged.data(), src, totalSize);
158+
}
159+
if (needsAlphaConversion) {
160+
convertAlpha(staged.data(), totalSize, sourcePremultiplied,
161+
destinationPremultiplied);
146162
}
147-
// Use the flipped data for writing to texture
148-
_instance.WriteTexture(&dst, flippedData.data(), totalSize, &layout, &sz);
163+
_instance.WriteTexture(&dst, staged.data(), totalSize, &layout, &sz);
149164
} else {
150-
151165
_instance.WriteTexture(&dst, source->source->getData(),
152166
source->source->getSize(), &layout, &sz);
153167
}

packages/webgpu/cpp/rnwgpu/api/ImageBitmap.h

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,33 @@ namespace rnwgpu {
1111

1212
namespace jsi = facebook::jsi;
1313

14+
// Convert RGBA8 pixel data in place between straight and premultiplied alpha.
15+
// Uses the same integer rounding as the web reference polyfill in the test
16+
// harness (setup.ts, convertAlpha) so the native result is bit-exact with the
17+
// dawn.node client. A no-op when the source and destination representations
18+
// already match.
19+
inline void convertAlpha(uint8_t *data, size_t byteLength,
20+
bool sourcePremultiplied,
21+
bool destinationPremultiplied) {
22+
if (sourcePremultiplied == destinationPremultiplied) {
23+
return;
24+
}
25+
for (size_t i = 0; i + 3 < byteLength; i += 4) {
26+
const uint32_t alpha = data[i + 3];
27+
for (size_t channel = 0; channel < 3; channel++) {
28+
const uint32_t value = data[i + channel];
29+
if (destinationPremultiplied) {
30+
data[i + channel] = static_cast<uint8_t>((value * alpha + 127) / 255);
31+
} else if (alpha == 0) {
32+
data[i + channel] = 0;
33+
} else {
34+
const uint32_t straight = (value * 255 + (alpha >> 1)) / alpha;
35+
data[i + channel] = static_cast<uint8_t>(straight > 255 ? 255 : straight);
36+
}
37+
}
38+
}
39+
}
40+
1441
class ImageBitmap : public NativeObject<ImageBitmap> {
1542
public:
1643
static constexpr const char *CLASS_NAME = "ImageBitmap";
@@ -26,6 +53,11 @@ class ImageBitmap : public NativeObject<ImageBitmap> {
2653

2754
size_t getSize() { return _imageData.data.size(); }
2855

56+
// Whether the stored pixels are premultiplied by alpha. Used by
57+
// copyExternalImageToTexture to decide whether a conversion to the
58+
// destination's premultipliedAlpha representation is needed.
59+
bool getPremultiplied() { return _imageData.premultiplied; }
60+
2961
void close() {
3062
_imageData.data.clear();
3163
_imageData.data.shrink_to_fit();

packages/webgpu/cpp/rnwgpu/api/RNWebGPU.h

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,41 @@ class RNWebGPU : public NativeObject<RNWebGPU> {
9090
auto platformContext = _platformContext;
9191
auto callInvoker = _callInvoker;
9292

93+
// Resolve the requested alpha representation from the ImageBitmapOptions.
94+
// The options bag is the second argument for createImageBitmap(source,
95+
// options) and the sixth for the crop-rect overload createImageBitmap(
96+
// source, sx, sy, sw, sh, options). premultiplyAlpha defaults to
97+
// "default", which (like "premultiply") stores premultiplied pixels; only
98+
// "none" keeps straight alpha. The other options (crop rect, resize,
99+
// imageOrientation, colorSpaceConversion) are not yet implemented natively.
100+
bool wantPremultiplied = true;
101+
const jsi::Value *optionsArg = nullptr;
102+
if (count >= 2 && args[1].isObject()) {
103+
optionsArg = &args[1];
104+
} else if (count >= 6 && args[5].isObject()) {
105+
optionsArg = &args[5];
106+
}
107+
if (optionsArg != nullptr) {
108+
auto options = optionsArg->getObject(runtime);
109+
if (options.hasProperty(runtime, "premultiplyAlpha")) {
110+
auto value = options.getProperty(runtime, "premultiplyAlpha");
111+
if (value.isString() &&
112+
value.getString(runtime).utf8(runtime) == "none") {
113+
wantPremultiplied = false;
114+
}
115+
}
116+
}
117+
118+
// Bring the decoded pixels into the representation requested via
119+
// premultiplyAlpha before wrapping them in an ImageBitmap.
120+
auto toRequestedAlpha = [wantPremultiplied](ImageData &imageData) {
121+
if (imageData.premultiplied != wantPremultiplied) {
122+
convertAlpha(imageData.data.data(), imageData.data.size(),
123+
imageData.premultiplied, wantPremultiplied);
124+
imageData.premultiplied = wantPremultiplied;
125+
}
126+
};
127+
93128
// Check if the argument is an ArrayBuffer or ArrayBufferView
94129
// (TypedArray / DataView). Only a real buffer source is run through the
95130
// ArrayBuffer converter, which validates byteOffset/byteLength against the
@@ -119,12 +154,14 @@ class RNWebGPU : public NativeObject<RNWebGPU> {
119154

120155
return Promise::createPromise(
121156
runtime,
122-
[platformContext, callInvoker, dataCopy = std::move(dataCopy)](
157+
[platformContext, callInvoker, toRequestedAlpha,
158+
dataCopy = std::move(dataCopy)](
123159
jsi::Runtime & /*runtime*/,
124160
std::shared_ptr<Promise> promise) mutable {
125161
platformContext->createImageBitmapFromDataAsync(
126162
dataCopy,
127-
[callInvoker, promise](ImageData imageData) {
163+
[callInvoker, promise, toRequestedAlpha](ImageData imageData) {
164+
toRequestedAlpha(imageData);
128165
auto imageBitmap = std::make_shared<ImageBitmap>(imageData);
129166
callInvoker->invokeAsync([promise, imageBitmap]() {
130167
promise->resolve(
@@ -149,11 +186,12 @@ class RNWebGPU : public NativeObject<RNWebGPU> {
149186

150187
return Promise::createPromise(
151188
runtime,
152-
[platformContext, callInvoker, blobId, offset,
189+
[platformContext, callInvoker, toRequestedAlpha, blobId, offset,
153190
size](jsi::Runtime & /*runtime*/, std::shared_ptr<Promise> promise) {
154191
platformContext->createImageBitmapAsync(
155192
blobId, offset, size,
156-
[callInvoker, promise](ImageData imageData) {
193+
[callInvoker, promise, toRequestedAlpha](ImageData imageData) {
194+
toRequestedAlpha(imageData);
157195
auto imageBitmap = std::make_shared<ImageBitmap>(imageData);
158196
callInvoker->invokeAsync([promise, imageBitmap]() {
159197
promise->resolve(

packages/webgpu/react-native-webgpu.podspec

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ Pod::Spec.new do |s|
2323

2424
# The VideoPlayer API uses AVFoundation / CoreMedia, and shared-texture
2525
# surfaces use CoreVideo (CVPixelBuffer). Link them so their symbols resolve.
26-
s.frameworks = "AVFoundation", "CoreMedia", "CoreVideo"
26+
# ImageIO provides CGImageSource, the image decoder behind createImageBitmap.
27+
s.frameworks = "AVFoundation", "CoreMedia", "CoreVideo", "ImageIO"
2728

2829
s.pod_target_xcconfig = {
2930
'HEADER_SEARCH_PATHS' => '$(PODS_TARGET_SRCROOT)/cpp',

0 commit comments

Comments
 (0)