Skip to content

Commit a9b00fd

Browse files
committed
🔧
1 parent d188e19 commit a9b00fd

15 files changed

Lines changed: 405 additions & 9 deletions

File tree

apps/example/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { Reanimated } from "./Reanimated";
3636
import { AsyncStarvation } from "./Diagnostics/AsyncStarvation";
3737
import { DeviceLostHang } from "./Diagnostics/DeviceLostHang";
3838
import { StorageBufferVertices } from "./StorageBufferVertices";
39+
import { HDR } from "./HDR";
3940

4041
// The two lines below are needed by three.js
4142
import "fast-text-encoding";
@@ -97,6 +98,7 @@ function App() {
9798
name="StorageBufferVertices"
9899
component={StorageBufferVertices}
99100
/>
101+
<Stack.Screen name="HDR" component={HDR} />
100102
</Stack.Navigator>
101103
</NavigationContainer>
102104
</GestureHandlerRootView>

apps/example/src/HDR/HDR.tsx

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import React, { useEffect, useRef, useState } from "react";
2+
import {
3+
Platform,
4+
PixelRatio,
5+
StyleSheet,
6+
Switch,
7+
Text,
8+
View,
9+
} from "react-native";
10+
import type { CanvasRef } from "react-native-wgpu";
11+
import { Canvas } from "react-native-wgpu";
12+
13+
import { fullscreenTriangleVertWGSL, hdrBandFragWGSL } from "./shaders";
14+
15+
type ToneMapping = "standard" | "extended";
16+
17+
const HDR_FORMAT: GPUTextureFormat = "rgba16float";
18+
const PEAK_MULTIPLIER = 8.0; // 8x SDR reference white.
19+
20+
function HDRCanvas({
21+
toneMapping,
22+
peak,
23+
}: {
24+
toneMapping: ToneMapping;
25+
peak: number;
26+
}) {
27+
const ref = useRef<CanvasRef>(null);
28+
29+
useEffect(() => {
30+
let cancelled = false;
31+
(async () => {
32+
const adapter = await navigator.gpu.requestAdapter();
33+
if (!adapter) {
34+
throw new Error("No adapter");
35+
}
36+
const device = await adapter.requestDevice();
37+
if (cancelled) {
38+
return;
39+
}
40+
41+
const context = ref.current!.getContext("webgpu")!;
42+
const canvas = context.canvas as HTMLCanvasElement;
43+
canvas.width = canvas.clientWidth * PixelRatio.get();
44+
canvas.height = canvas.clientHeight * PixelRatio.get();
45+
46+
context.configure({
47+
device,
48+
format: HDR_FORMAT,
49+
alphaMode: "opaque",
50+
toneMapping: { mode: toneMapping },
51+
});
52+
53+
const pipeline = device.createRenderPipeline({
54+
layout: "auto",
55+
vertex: {
56+
module: device.createShaderModule({
57+
code: fullscreenTriangleVertWGSL,
58+
}),
59+
entryPoint: "main",
60+
},
61+
fragment: {
62+
module: device.createShaderModule({ code: hdrBandFragWGSL }),
63+
entryPoint: "main",
64+
targets: [{ format: HDR_FORMAT }],
65+
},
66+
primitive: { topology: "triangle-list" },
67+
});
68+
69+
const paramsBuffer = device.createBuffer({
70+
size: 16,
71+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
72+
});
73+
device.queue.writeBuffer(
74+
paramsBuffer,
75+
0,
76+
new Float32Array([peak, 0, 0, 0]),
77+
);
78+
79+
const bindGroup = device.createBindGroup({
80+
layout: pipeline.getBindGroupLayout(0),
81+
entries: [{ binding: 0, resource: { buffer: paramsBuffer } }],
82+
});
83+
84+
const encoder = device.createCommandEncoder();
85+
const pass = encoder.beginRenderPass({
86+
colorAttachments: [
87+
{
88+
view: context.getCurrentTexture().createView(),
89+
clearValue: [0, 0, 0, 1],
90+
loadOp: "clear",
91+
storeOp: "store",
92+
},
93+
],
94+
});
95+
pass.setPipeline(pipeline);
96+
pass.setBindGroup(0, bindGroup);
97+
pass.draw(3);
98+
pass.end();
99+
device.queue.submit([encoder.finish()]);
100+
context.present();
101+
})();
102+
return () => {
103+
cancelled = true;
104+
};
105+
}, [toneMapping, peak]);
106+
107+
return <Canvas ref={ref} style={StyleSheet.absoluteFill} />;
108+
}
109+
110+
export function HDR() {
111+
const [extended, setExtended] = useState(true);
112+
const mode: ToneMapping = extended ? "extended" : "standard";
113+
return (
114+
<View style={styles.container}>
115+
<View style={styles.toolbar}>
116+
<Text style={styles.title}>HDR — {mode}</Text>
117+
<View style={styles.switchRow}>
118+
<Text style={styles.switchLabel}>Extended</Text>
119+
<Switch value={extended} onValueChange={setExtended} />
120+
</View>
121+
</View>
122+
123+
<Text style={styles.hint}>
124+
Left band: SDR white (1.0). Right band: {PEAK_MULTIPLIER}x. Toggle the
125+
switch. With "extended" on an EDR display (iPhone Pro / iPad Pro XDR /
126+
MBP XDR), the right band glows visibly brighter than the left. In
127+
"standard" both bands match.
128+
</Text>
129+
<Text style={styles.hint}>
130+
Tip: dim the display brightness; the OS allocates more EDR headroom at
131+
lower SDR brightness, so the boost is more obvious. iOS Settings,
132+
Display & Brightness, Auto-Brightness can also affect headroom.
133+
</Text>
134+
135+
<View style={styles.canvasContainer}>
136+
{/* Force a fresh CAMetalLayer per mode: iOS won't downgrade a layer
137+
out of EDR composition once it's been promoted, so we remount the
138+
Canvas (and therefore the underlying MetalView) when the toggle
139+
flips. */}
140+
<HDRCanvas key={mode} toneMapping={mode} peak={PEAK_MULTIPLIER} />
141+
</View>
142+
143+
{Platform.OS !== "ios" && Platform.OS !== "macos" ? (
144+
<Text style={styles.warning}>
145+
Note: HDR display is currently only wired for Apple platforms.
146+
</Text>
147+
) : null}
148+
</View>
149+
);
150+
}
151+
152+
const styles = StyleSheet.create({
153+
container: {
154+
flex: 1,
155+
backgroundColor: "black",
156+
},
157+
toolbar: {
158+
flexDirection: "row",
159+
alignItems: "center",
160+
justifyContent: "space-between",
161+
padding: 12,
162+
},
163+
title: {
164+
color: "white",
165+
fontSize: 14,
166+
fontWeight: "600",
167+
flex: 1,
168+
},
169+
switchRow: {
170+
flexDirection: "row",
171+
alignItems: "center",
172+
},
173+
switchLabel: {
174+
color: "white",
175+
marginRight: 8,
176+
},
177+
hint: {
178+
color: "#bbb",
179+
fontSize: 12,
180+
paddingHorizontal: 12,
181+
paddingBottom: 8,
182+
},
183+
canvasContainer: {
184+
flex: 1,
185+
margin: 12,
186+
backgroundColor: "black",
187+
},
188+
warning: {
189+
color: "#ffb347",
190+
fontSize: 12,
191+
padding: 12,
192+
},
193+
});

apps/example/src/HDR/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { HDR } from "./HDR";

apps/example/src/HDR/shaders.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
export const fullscreenTriangleVertWGSL = /* wgsl */ `
2+
struct VSOut {
3+
@builtin(position) position : vec4<f32>,
4+
@location(0) uv : vec2<f32>,
5+
};
6+
7+
@vertex
8+
fn main(@builtin(vertex_index) idx : u32) -> VSOut {
9+
var pos = array<vec2<f32>, 3>(
10+
vec2<f32>(-1.0, -1.0),
11+
vec2<f32>( 3.0, -1.0),
12+
vec2<f32>(-1.0, 3.0),
13+
);
14+
let p = pos[idx];
15+
var out : VSOut;
16+
out.position = vec4<f32>(p, 0.0, 1.0);
17+
out.uv = (p + vec2<f32>(1.0, 1.0)) * 0.5;
18+
out.uv.y = 1.0 - out.uv.y;
19+
return out;
20+
}
21+
`;
22+
23+
// Renders three vertical bands:
24+
// left third: solid value 1.0 (SDR reference white)
25+
// middle third: black gap
26+
// right third: solid value = peak (HDR if peak > 1)
27+
//
28+
// With "extended" tone mapping on an EDR-capable display the right band
29+
// glows visibly brighter than the left. With "standard" tone mapping
30+
// the right band is clamped to 1.0 and matches the left.
31+
export const hdrBandFragWGSL = /* wgsl */ `
32+
struct Params {
33+
peak : f32,
34+
_p0 : f32,
35+
_p1 : f32,
36+
_p2 : f32,
37+
};
38+
39+
@group(0) @binding(0) var<uniform> params : Params;
40+
41+
@fragment
42+
fn main(@location(0) uv : vec2<f32>) -> @location(0) vec4<f32> {
43+
let x = uv.x;
44+
if (x < 0.4) {
45+
// SDR reference white.
46+
return vec4<f32>(1.0, 1.0, 1.0, 1.0);
47+
} else if (x < 0.6) {
48+
// Black gap so the two whites are not adjacent.
49+
return vec4<f32>(0.0, 0.0, 0.0, 1.0);
50+
} else {
51+
// Bright (potentially HDR) white.
52+
return vec4<f32>(params.peak, params.peak, params.peak, 1.0);
53+
}
54+
}
55+
`;

apps/example/src/Home.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,10 @@ export const examples = [
127127
screen: "StorageBufferVertices",
128128
title: "💾 Storage Buffer Vertices",
129129
},
130+
{
131+
screen: "HDR",
132+
title: "🌞 HDR Canvas",
133+
},
130134
];
131135

132136
const styles = StyleSheet.create({

apps/example/src/Route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,5 @@ export type Routes = {
2929
AsyncStarvation: undefined;
3030
DeviceLostHang: undefined;
3131
StorageBufferVertices: undefined;
32+
HDR: undefined;
3233
};

packages/webgpu/apple/ApplePlatformContext.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ class ApplePlatformContext : public PlatformContext {
1313
wgpu::Surface makeSurface(wgpu::Instance instance, void *surface, int width,
1414
int height) override;
1515

16+
void configureSurfaceColor(void *nativeSurface,
17+
const SurfaceColorConfig &config) override;
18+
1619
ImageData createImageBitmap(std::string blobId, double offset,
1720
double size) override;
1821

packages/webgpu/apple/ApplePlatformContext.mm

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
#include <TargetConditionals.h>
44

5+
#import <QuartzCore/CAMetalLayer.h>
56
#import <React/RCTBlobManager.h>
67
#import <React/RCTBridge+Private.h>
78
#import <ReactCommon/RCTTurboModule.h>
@@ -39,6 +40,70 @@ void checkIfUsingSimulatorWithAPIValidation() {
3940
return instance.CreateSurface(&surfaceDescriptor);
4041
}
4142

43+
void ApplePlatformContext::configureSurfaceColor(
44+
void *nativeSurface, const SurfaceColorConfig &config) {
45+
if (!nativeSurface) {
46+
NSLog(@"[RNWebGPU] configureSurfaceColor: nativeSurface is null");
47+
return;
48+
}
49+
CAMetalLayer *layer = (__bridge CAMetalLayer *)nativeSurface;
50+
51+
// CAMetalLayer property setters are documented as thread-safe, and we need
52+
// these changes to land before the caller renders / presents the next
53+
// frame, so apply synchronously here instead of bouncing to the main queue.
54+
[CATransaction begin];
55+
[CATransaction setDisableActions:YES];
56+
57+
CGColorSpaceRef colorSpace = nullptr;
58+
if (config.extendedDynamicRange) {
59+
#if !TARGET_OS_OSX
60+
if (@available(iOS 16.0, *)) {
61+
layer.wantsExtendedDynamicRangeContent = YES;
62+
}
63+
#else
64+
layer.wantsExtendedDynamicRangeContent = YES;
65+
#endif
66+
// Extended linear sRGB allows shader values >1 to map into the display's
67+
// EDR headroom on supporting hardware.
68+
colorSpace = CGColorSpaceCreateWithName(kCGColorSpaceExtendedLinearSRGB);
69+
} else {
70+
#if !TARGET_OS_OSX
71+
if (@available(iOS 16.0, *)) {
72+
layer.wantsExtendedDynamicRangeContent = NO;
73+
}
74+
#else
75+
layer.wantsExtendedDynamicRangeContent = NO;
76+
#endif
77+
// Non-extended sRGB clamps float values >1 to SDR. Linear variants
78+
// ("kCGColorSpaceLinearSRGB") still pass values >1 through on iOS, so we
79+
// use gamma-encoded sRGB for the standard tone-mapping mode.
80+
colorSpace = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
81+
}
82+
if (colorSpace) {
83+
layer.colorspace = colorSpace;
84+
CGColorSpaceRelease(colorSpace);
85+
}
86+
87+
[CATransaction commit];
88+
89+
#if !TARGET_OS_OSX
90+
CGFloat headroom = 1.0;
91+
if (@available(iOS 16.0, *)) {
92+
headroom = UIScreen.mainScreen.currentEDRHeadroom;
93+
}
94+
NSLog(@"[RNWebGPU] HDR configure: extended=%d format=%u "
95+
@"layer.wantsEDR=%d layer.colorspace=%@ EDRHeadroom=%.2f",
96+
(int)config.extendedDynamicRange, (unsigned)config.format,
97+
(int)layer.wantsExtendedDynamicRangeContent, layer.colorspace,
98+
(double)headroom);
99+
#else
100+
NSLog(@"[RNWebGPU] HDR configure: extended=%d format=%u "
101+
@"layer.wantsEDR=%d layer.colorspace=%@",
102+
(int)config.extendedDynamicRange, (unsigned)config.format,
103+
(int)layer.wantsExtendedDynamicRangeContent, layer.colorspace);
104+
#endif
105+
}
106+
42107
static std::span<const uint8_t> nsDataToSpan(NSData *data) {
43108
return {static_cast<const uint8_t *>(data.bytes), data.length};
44109
}

packages/webgpu/apple/MetalView.mm

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,17 @@ - (void)configure {
2828
auto gpu = manager->_gpu;
2929
auto surface = manager->_platformContext->makeSurface(
3030
gpu, nativeSurface, size.width, size.height);
31-
registry
32-
.getSurfaceInfoOrCreate([_contextId intValue], gpu, size.width,
33-
size.height)
34-
->switchToOnscreen(nativeSurface, surface);
31+
auto info = registry.getSurfaceInfoOrCreate([_contextId intValue], gpu,
32+
size.width, size.height);
33+
info->switchToOnscreen(nativeSurface, surface);
34+
// If a previous configure() call from JS already requested an HDR / color
35+
// config for this surface, replay it onto the freshly attached layer. This
36+
// covers the race where JS calls context.configure() before this MetalView
37+
// mounts (e.g., on a key-driven Canvas remount).
38+
if (auto colorConfig = info->getColorConfig()) {
39+
manager->_platformContext->configureSurfaceColor(nativeSurface,
40+
*colorConfig);
41+
}
3542
}
3643

3744
- (void)update {

0 commit comments

Comments
 (0)