diff --git a/apps/example/src/App.tsx b/apps/example/src/App.tsx
index 667f87164..909f8f7de 100644
--- a/apps/example/src/App.tsx
+++ b/apps/example/src/App.tsx
@@ -36,6 +36,7 @@ import { Reanimated } from "./Reanimated";
import { AsyncStarvation } from "./Diagnostics/AsyncStarvation";
import { DeviceLostHang } from "./Diagnostics/DeviceLostHang";
import { StorageBufferVertices } from "./StorageBufferVertices";
+import { HDR } from "./HDR";
// The two lines below are needed by three.js
import "fast-text-encoding";
@@ -97,6 +98,7 @@ function App() {
name="StorageBufferVertices"
component={StorageBufferVertices}
/>
+
diff --git a/apps/example/src/HDR/HDR.tsx b/apps/example/src/HDR/HDR.tsx
new file mode 100644
index 000000000..b37e11e05
--- /dev/null
+++ b/apps/example/src/HDR/HDR.tsx
@@ -0,0 +1,193 @@
+import React, { useEffect, useRef, useState } from "react";
+import {
+ Platform,
+ PixelRatio,
+ StyleSheet,
+ Switch,
+ Text,
+ View,
+} from "react-native";
+import type { CanvasRef } from "react-native-wgpu";
+import { Canvas } from "react-native-wgpu";
+
+import { fullscreenTriangleVertWGSL, hdrBandFragWGSL } from "./shaders";
+
+type ToneMapping = "standard" | "extended";
+
+const HDR_FORMAT: GPUTextureFormat = "rgba16float";
+const PEAK_MULTIPLIER = 8.0; // 8x SDR reference white.
+
+function HDRCanvas({
+ toneMapping,
+ peak,
+}: {
+ toneMapping: ToneMapping;
+ peak: number;
+}) {
+ const ref = useRef(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ (async () => {
+ const adapter = await navigator.gpu.requestAdapter();
+ if (!adapter) {
+ throw new Error("No adapter");
+ }
+ const device = await adapter.requestDevice();
+ if (cancelled) {
+ return;
+ }
+
+ const context = ref.current!.getContext("webgpu")!;
+ const canvas = context.canvas as HTMLCanvasElement;
+ canvas.width = canvas.clientWidth * PixelRatio.get();
+ canvas.height = canvas.clientHeight * PixelRatio.get();
+
+ context.configure({
+ device,
+ format: HDR_FORMAT,
+ alphaMode: "opaque",
+ toneMapping: { mode: toneMapping },
+ });
+
+ const pipeline = device.createRenderPipeline({
+ layout: "auto",
+ vertex: {
+ module: device.createShaderModule({
+ code: fullscreenTriangleVertWGSL,
+ }),
+ entryPoint: "main",
+ },
+ fragment: {
+ module: device.createShaderModule({ code: hdrBandFragWGSL }),
+ entryPoint: "main",
+ targets: [{ format: HDR_FORMAT }],
+ },
+ primitive: { topology: "triangle-list" },
+ });
+
+ const paramsBuffer = device.createBuffer({
+ size: 16,
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
+ });
+ device.queue.writeBuffer(
+ paramsBuffer,
+ 0,
+ new Float32Array([peak, 0, 0, 0]),
+ );
+
+ const bindGroup = device.createBindGroup({
+ layout: pipeline.getBindGroupLayout(0),
+ entries: [{ binding: 0, resource: { buffer: paramsBuffer } }],
+ });
+
+ const encoder = device.createCommandEncoder();
+ const pass = encoder.beginRenderPass({
+ colorAttachments: [
+ {
+ view: context.getCurrentTexture().createView(),
+ clearValue: [0, 0, 0, 1],
+ loadOp: "clear",
+ storeOp: "store",
+ },
+ ],
+ });
+ pass.setPipeline(pipeline);
+ pass.setBindGroup(0, bindGroup);
+ pass.draw(3);
+ pass.end();
+ device.queue.submit([encoder.finish()]);
+ context.present();
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [toneMapping, peak]);
+
+ return ;
+}
+
+export function HDR() {
+ const [extended, setExtended] = useState(true);
+ const mode: ToneMapping = extended ? "extended" : "standard";
+ return (
+
+
+ HDR — {mode}
+
+ Extended
+
+
+
+
+
+ Left band: SDR white (1.0). Right band: {PEAK_MULTIPLIER}x. Toggle the
+ switch. With "extended" on an EDR display (iPhone Pro / iPad Pro XDR /
+ MBP XDR), the right band glows visibly brighter than the left. In
+ "standard" both bands match.
+
+
+ Tip: dim the display brightness; the OS allocates more EDR headroom at
+ lower SDR brightness, so the boost is more obvious. iOS Settings,
+ Display & Brightness, Auto-Brightness can also affect headroom.
+
+
+
+ {/* Force a fresh CAMetalLayer per mode: iOS won't downgrade a layer
+ out of EDR composition once it's been promoted, so we remount the
+ Canvas (and therefore the underlying MetalView) when the toggle
+ flips. */}
+
+
+
+ {Platform.OS !== "ios" && Platform.OS !== "macos" ? (
+
+ Note: HDR display is currently only wired for Apple platforms.
+
+ ) : null}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: "black",
+ },
+ toolbar: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ padding: 12,
+ },
+ title: {
+ color: "white",
+ fontSize: 14,
+ fontWeight: "600",
+ flex: 1,
+ },
+ switchRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ },
+ switchLabel: {
+ color: "white",
+ marginRight: 8,
+ },
+ hint: {
+ color: "#bbb",
+ fontSize: 12,
+ paddingHorizontal: 12,
+ paddingBottom: 8,
+ },
+ canvasContainer: {
+ flex: 1,
+ margin: 12,
+ backgroundColor: "black",
+ },
+ warning: {
+ color: "#ffb347",
+ fontSize: 12,
+ padding: 12,
+ },
+});
diff --git a/apps/example/src/HDR/index.ts b/apps/example/src/HDR/index.ts
new file mode 100644
index 000000000..7086cc0ba
--- /dev/null
+++ b/apps/example/src/HDR/index.ts
@@ -0,0 +1 @@
+export { HDR } from "./HDR";
diff --git a/apps/example/src/HDR/shaders.ts b/apps/example/src/HDR/shaders.ts
new file mode 100644
index 000000000..fc5e6cf2d
--- /dev/null
+++ b/apps/example/src/HDR/shaders.ts
@@ -0,0 +1,55 @@
+export const fullscreenTriangleVertWGSL = /* wgsl */ `
+struct VSOut {
+ @builtin(position) position : vec4,
+ @location(0) uv : vec2,
+};
+
+@vertex
+fn main(@builtin(vertex_index) idx : u32) -> VSOut {
+ var pos = array, 3>(
+ vec2(-1.0, -1.0),
+ vec2( 3.0, -1.0),
+ vec2(-1.0, 3.0),
+ );
+ let p = pos[idx];
+ var out : VSOut;
+ out.position = vec4(p, 0.0, 1.0);
+ out.uv = (p + vec2(1.0, 1.0)) * 0.5;
+ out.uv.y = 1.0 - out.uv.y;
+ return out;
+}
+`;
+
+// Renders three vertical bands:
+// left third: solid value 1.0 (SDR reference white)
+// middle third: black gap
+// right third: solid value = peak (HDR if peak > 1)
+//
+// With "extended" tone mapping on an EDR-capable display the right band
+// glows visibly brighter than the left. With "standard" tone mapping
+// the right band is clamped to 1.0 and matches the left.
+export const hdrBandFragWGSL = /* wgsl */ `
+struct Params {
+ peak : f32,
+ _p0 : f32,
+ _p1 : f32,
+ _p2 : f32,
+};
+
+@group(0) @binding(0) var params : Params;
+
+@fragment
+fn main(@location(0) uv : vec2) -> @location(0) vec4 {
+ let x = uv.x;
+ if (x < 0.4) {
+ // SDR reference white.
+ return vec4(1.0, 1.0, 1.0, 1.0);
+ } else if (x < 0.6) {
+ // Black gap so the two whites are not adjacent.
+ return vec4(0.0, 0.0, 0.0, 1.0);
+ } else {
+ // Bright (potentially HDR) white.
+ return vec4(params.peak, params.peak, params.peak, 1.0);
+ }
+}
+`;
diff --git a/apps/example/src/Home.tsx b/apps/example/src/Home.tsx
index 9272dfec9..47ffe40bf 100644
--- a/apps/example/src/Home.tsx
+++ b/apps/example/src/Home.tsx
@@ -127,6 +127,10 @@ export const examples = [
screen: "StorageBufferVertices",
title: "💾 Storage Buffer Vertices",
},
+ {
+ screen: "HDR",
+ title: "🌞 HDR Canvas",
+ },
];
const styles = StyleSheet.create({
diff --git a/apps/example/src/Route.ts b/apps/example/src/Route.ts
index 152923e1e..4d91d0db0 100644
--- a/apps/example/src/Route.ts
+++ b/apps/example/src/Route.ts
@@ -29,4 +29,5 @@ export type Routes = {
AsyncStarvation: undefined;
DeviceLostHang: undefined;
StorageBufferVertices: undefined;
+ HDR: undefined;
};
diff --git a/packages/webgpu/apple/ApplePlatformContext.h b/packages/webgpu/apple/ApplePlatformContext.h
index 86e80b807..5027b13b1 100644
--- a/packages/webgpu/apple/ApplePlatformContext.h
+++ b/packages/webgpu/apple/ApplePlatformContext.h
@@ -13,6 +13,9 @@ class ApplePlatformContext : public PlatformContext {
wgpu::Surface makeSurface(wgpu::Instance instance, void *surface, int width,
int height) override;
+ void configureSurfaceColor(void *nativeSurface,
+ const SurfaceColorConfig &config) override;
+
ImageData createImageBitmap(std::string blobId, double offset,
double size) override;
diff --git a/packages/webgpu/apple/ApplePlatformContext.mm b/packages/webgpu/apple/ApplePlatformContext.mm
index 8907511a0..e5ec10165 100644
--- a/packages/webgpu/apple/ApplePlatformContext.mm
+++ b/packages/webgpu/apple/ApplePlatformContext.mm
@@ -2,6 +2,7 @@
#include
+#import
#import
#import
#import
@@ -39,6 +40,70 @@ void checkIfUsingSimulatorWithAPIValidation() {
return instance.CreateSurface(&surfaceDescriptor);
}
+void ApplePlatformContext::configureSurfaceColor(
+ void *nativeSurface, const SurfaceColorConfig &config) {
+ if (!nativeSurface) {
+ NSLog(@"[RNWebGPU] configureSurfaceColor: nativeSurface is null");
+ return;
+ }
+ CAMetalLayer *layer = (__bridge CAMetalLayer *)nativeSurface;
+
+ // CAMetalLayer property setters are documented as thread-safe, and we need
+ // these changes to land before the caller renders / presents the next
+ // frame, so apply synchronously here instead of bouncing to the main queue.
+ [CATransaction begin];
+ [CATransaction setDisableActions:YES];
+
+ CGColorSpaceRef colorSpace = nullptr;
+ if (config.extendedDynamicRange) {
+#if !TARGET_OS_OSX
+ if (@available(iOS 16.0, *)) {
+ layer.wantsExtendedDynamicRangeContent = YES;
+ }
+#else
+ layer.wantsExtendedDynamicRangeContent = YES;
+#endif
+ // Extended linear sRGB allows shader values >1 to map into the display's
+ // EDR headroom on supporting hardware.
+ colorSpace = CGColorSpaceCreateWithName(kCGColorSpaceExtendedLinearSRGB);
+ } else {
+#if !TARGET_OS_OSX
+ if (@available(iOS 16.0, *)) {
+ layer.wantsExtendedDynamicRangeContent = NO;
+ }
+#else
+ layer.wantsExtendedDynamicRangeContent = NO;
+#endif
+ // Non-extended sRGB clamps float values >1 to SDR. Linear variants
+ // ("kCGColorSpaceLinearSRGB") still pass values >1 through on iOS, so we
+ // use gamma-encoded sRGB for the standard tone-mapping mode.
+ colorSpace = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
+ }
+ if (colorSpace) {
+ layer.colorspace = colorSpace;
+ CGColorSpaceRelease(colorSpace);
+ }
+
+ [CATransaction commit];
+
+#if !TARGET_OS_OSX
+ CGFloat headroom = 1.0;
+ if (@available(iOS 16.0, *)) {
+ headroom = UIScreen.mainScreen.currentEDRHeadroom;
+ }
+ NSLog(@"[RNWebGPU] HDR configure: extended=%d format=%u "
+ @"layer.wantsEDR=%d layer.colorspace=%@ EDRHeadroom=%.2f",
+ (int)config.extendedDynamicRange, (unsigned)config.format,
+ (int)layer.wantsExtendedDynamicRangeContent, layer.colorspace,
+ (double)headroom);
+#else
+ NSLog(@"[RNWebGPU] HDR configure: extended=%d format=%u "
+ @"layer.wantsEDR=%d layer.colorspace=%@",
+ (int)config.extendedDynamicRange, (unsigned)config.format,
+ (int)layer.wantsExtendedDynamicRangeContent, layer.colorspace);
+#endif
+}
+
static std::span nsDataToSpan(NSData *data) {
return {static_cast(data.bytes), data.length};
}
diff --git a/packages/webgpu/apple/MetalView.mm b/packages/webgpu/apple/MetalView.mm
index ccff1245c..6f219a9e4 100644
--- a/packages/webgpu/apple/MetalView.mm
+++ b/packages/webgpu/apple/MetalView.mm
@@ -28,10 +28,17 @@ - (void)configure {
auto gpu = manager->_gpu;
auto surface = manager->_platformContext->makeSurface(
gpu, nativeSurface, size.width, size.height);
- registry
- .getSurfaceInfoOrCreate([_contextId intValue], gpu, size.width,
- size.height)
- ->switchToOnscreen(nativeSurface, surface);
+ auto info = registry.getSurfaceInfoOrCreate([_contextId intValue], gpu,
+ size.width, size.height);
+ info->switchToOnscreen(nativeSurface, surface);
+ // If a previous configure() call from JS already requested an HDR / color
+ // config for this surface, replay it onto the freshly attached layer. This
+ // covers the race where JS calls context.configure() before this MetalView
+ // mounts (e.g., on a key-driven Canvas remount).
+ if (auto colorConfig = info->getColorConfig()) {
+ manager->_platformContext->configureSurfaceColor(nativeSurface,
+ *colorConfig);
+ }
}
- (void)update {
diff --git a/packages/webgpu/cpp/rnwgpu/PlatformContext.h b/packages/webgpu/cpp/rnwgpu/PlatformContext.h
index bca6a2608..3b7bf7a94 100644
--- a/packages/webgpu/cpp/rnwgpu/PlatformContext.h
+++ b/packages/webgpu/cpp/rnwgpu/PlatformContext.h
@@ -17,6 +17,11 @@ struct ImageData {
wgpu::TextureFormat format;
};
+struct SurfaceColorConfig {
+ wgpu::TextureFormat format = wgpu::TextureFormat::Undefined;
+ bool extendedDynamicRange = false;
+};
+
class PlatformContext {
public:
PlatformContext() = default;
@@ -24,6 +29,12 @@ class PlatformContext {
virtual wgpu::Surface makeSurface(wgpu::Instance instance, void *surface,
int width, int height) = 0;
+
+ // Apply extra platform-specific color configuration on the native surface
+ // (e.g. CAMetalLayer wantsExtendedDynamicRangeContent / colorspace on
+ // Apple). Default is a no-op for platforms without HDR support.
+ virtual void configureSurfaceColor(void * /*nativeSurface*/,
+ const SurfaceColorConfig & /*config*/) {}
virtual ImageData createImageBitmap(std::string blobId, double offset,
double size) = 0;
diff --git a/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h b/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h
index 110a45d44..6707ed9eb 100644
--- a/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h
+++ b/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h
@@ -1,10 +1,12 @@
#pragma once
#include
+#include
#include
#include
#include
+#include "PlatformContext.h"
#include "webgpu/webgpu_cpp.h"
namespace rnwgpu {
@@ -151,6 +153,16 @@ class SurfaceInfo {
return config.device;
}
+ void setColorConfig(const SurfaceColorConfig &cfg) {
+ std::unique_lock lock(_mutex);
+ _colorConfig = cfg;
+ }
+
+ std::optional getColorConfig() {
+ std::shared_lock lock(_mutex);
+ return _colorConfig;
+ }
+
private:
void _configure() {
if (surface) {
@@ -175,6 +187,7 @@ class SurfaceInfo {
wgpu::SurfaceConfiguration config;
int width;
int height;
+ std::optional _colorConfig;
};
class SurfaceRegistry {
diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.cpp b/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.cpp
index d75eb7b0f..e5be5828e 100644
--- a/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.cpp
+++ b/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.cpp
@@ -35,6 +35,22 @@ void GPUCanvasContext::configure(
#endif
surfaceConfiguration.presentMode = wgpu::PresentMode::Fifo;
_surfaceInfo->configure(surfaceConfiguration);
+
+ if (_platformContext) {
+ SurfaceColorConfig colorConfig;
+ colorConfig.format = surfaceConfiguration.format;
+ colorConfig.extendedDynamicRange =
+ configuration->toneMappingMode == GPUCanvasToneMappingMode::Extended;
+ // Stash so a (re)attaching native layer can replay this config — see
+ // MetalView::configure on Apple. This avoids a race where configure()
+ // fires from JS before the MetalView has called switchToOnscreen.
+ _surfaceInfo->setColorConfig(colorConfig);
+ auto nativeInfo = _surfaceInfo->getNativeInfo();
+ if (nativeInfo.nativeSurface) {
+ _platformContext->configureSurfaceColor(nativeInfo.nativeSurface,
+ colorConfig);
+ }
+ }
}
void GPUCanvasContext::unconfigure() {}
diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.h b/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.h
index 4b97a7887..6fa77dcc7 100644
--- a/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.h
+++ b/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.h
@@ -14,6 +14,7 @@
#include "GPU.h"
#include "GPUCanvasConfiguration.h"
#include "GPUTexture.h"
+#include "PlatformContext.h"
#include "SurfaceRegistry.h"
namespace rnwgpu {
@@ -24,9 +25,11 @@ class GPUCanvasContext : public NativeObject {
public:
static constexpr const char *CLASS_NAME = "GPUCanvasContext";
- GPUCanvasContext(std::shared_ptr gpu, int contextId, int width,
- int height)
- : NativeObject(CLASS_NAME), _gpu(std::move(gpu)) {
+ GPUCanvasContext(std::shared_ptr gpu,
+ std::shared_ptr platformContext,
+ int contextId, int width, int height)
+ : NativeObject(CLASS_NAME), _gpu(std::move(gpu)),
+ _platformContext(std::move(platformContext)) {
_canvas = std::make_shared