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(nullptr, width, height); auto ®istry = rnwgpu::SurfaceRegistry::getInstance(); _surfaceInfo = @@ -61,6 +64,7 @@ class GPUCanvasContext : public NativeObject { std::shared_ptr _canvas; std::shared_ptr _surfaceInfo; std::shared_ptr _gpu; + std::shared_ptr _platformContext; }; } // namespace rnwgpu diff --git a/packages/webgpu/cpp/rnwgpu/api/RNWebGPU.h b/packages/webgpu/cpp/rnwgpu/api/RNWebGPU.h index 78ef5ec7b..e25fb0a20 100644 --- a/packages/webgpu/cpp/rnwgpu/api/RNWebGPU.h +++ b/packages/webgpu/cpp/rnwgpu/api/RNWebGPU.h @@ -70,8 +70,8 @@ class RNWebGPU : public NativeObject { std::shared_ptr MakeWebGPUCanvasContext(int contextId, float width, float height) { - auto ctx = - std::make_shared(_gpu, contextId, width, height); + auto ctx = std::make_shared( + _gpu, _platformContext, contextId, width, height); return ctx; } diff --git a/packages/webgpu/cpp/rnwgpu/api/descriptors/GPUCanvasConfiguration.h b/packages/webgpu/cpp/rnwgpu/api/descriptors/GPUCanvasConfiguration.h index c27b4a7fb..6136407e7 100644 --- a/packages/webgpu/cpp/rnwgpu/api/descriptors/GPUCanvasConfiguration.h +++ b/packages/webgpu/cpp/rnwgpu/api/descriptors/GPUCanvasConfiguration.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include "webgpu/webgpu_cpp.h" @@ -13,6 +14,11 @@ namespace jsi = facebook::jsi; namespace rnwgpu { +enum class GPUCanvasToneMappingMode { + Standard, + Extended, +}; + struct GPUCanvasConfiguration { std::shared_ptr device; // GPUDevice wgpu::TextureFormat format; // GPUTextureFormat @@ -20,6 +26,7 @@ struct GPUCanvasConfiguration { std::optional> viewFormats; // Iterable wgpu::CompositeAlphaMode alphaMode = wgpu::CompositeAlphaMode::Opaque; + GPUCanvasToneMappingMode toneMappingMode = GPUCanvasToneMappingMode::Standard; }; } // namespace rnwgpu @@ -63,6 +70,20 @@ struct JSIConverter> { result->alphaMode = wgpu::CompositeAlphaMode::Premultiplied; } } + if (value.hasProperty(runtime, "toneMapping")) { + auto toneMapping = value.getProperty(runtime, "toneMapping"); + if (toneMapping.isObject()) { + auto tmObj = toneMapping.getObject(runtime); + if (tmObj.hasProperty(runtime, "mode")) { + auto mode = tmObj.getProperty(runtime, "mode") + .asString(runtime) + .utf8(runtime); + if (mode == "extended") { + result->toneMappingMode = GPUCanvasToneMappingMode::Extended; + } + } + } + } } return result;