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/cpp/rnwgpu/SurfaceRegistry.h b/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h index 110a45d44..1ff5c7c4c 100644 --- a/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h +++ b/packages/webgpu/cpp/rnwgpu/SurfaceRegistry.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -40,6 +41,7 @@ class SurfaceInfo { config.width = width; config.height = height; config.presentMode = wgpu::PresentMode::Fifo; + _rebindColorChain(); _configure(); } @@ -151,7 +153,21 @@ class SurfaceInfo { return config.device; } + void setColorManagement(std::optional mgmt) { + std::unique_lock lock(_mutex); + _colorManagement = std::move(mgmt); + _rebindColorChain(); + if (surface) { + surface.Configure(&config); + } + } + private: + void _rebindColorChain() { + config.nextInChain = + _colorManagement ? &_colorManagement.value() : nullptr; + } + void _configure() { if (surface) { surface.Configure(&config); @@ -175,6 +191,7 @@ class SurfaceInfo { wgpu::SurfaceConfiguration config; int width; int height; + std::optional _colorManagement; }; class SurfaceRegistry { diff --git a/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.cpp b/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.cpp index d75eb7b0f..abd57d7ca 100644 --- a/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.cpp +++ b/packages/webgpu/cpp/rnwgpu/api/GPUCanvasContext.cpp @@ -35,6 +35,14 @@ void GPUCanvasContext::configure( #endif surfaceConfiguration.presentMode = wgpu::PresentMode::Fifo; _surfaceInfo->configure(surfaceConfiguration); + + wgpu::SurfaceColorManagement colorManagement; + colorManagement.colorSpace = wgpu::PredefinedColorSpace::SRGB; + colorManagement.toneMappingMode = + configuration->toneMappingMode == GPUCanvasToneMappingMode::Extended + ? wgpu::ToneMappingMode::Extended + : wgpu::ToneMappingMode::Standard; + _surfaceInfo->setColorManagement(colorManagement); } void GPUCanvasContext::unconfigure() {} 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;