Skip to content

fix(🍏): set sRGB color space on WebGPUMetalView CAMetalLayer#3767

Open
kbrandwijk wants to merge 5 commits intoShopify:mainfrom
kbrandwijk:fix/webgpu-metal-srgb-colorspace
Open

fix(🍏): set sRGB color space on WebGPUMetalView CAMetalLayer#3767
kbrandwijk wants to merge 5 commits intoShopify:mainfrom
kbrandwijk:fix/webgpu-metal-srgb-colorspace

Conversation

@kbrandwijk
Copy link
Copy Markdown
Contributor

Summary

  • Sets CGColorSpaceSRGB on the CAMetalLayer backing the WebGPUMetalView
  • Without this, RGBA16Float surface values are interpreted in linear/EDR color space, making colors appear too bright
  • This ensures correct tone mapping for standard content rendered through WebGPUCanvas

Test plan

  • Render content through WebGPUCanvas with RGBA16Float surface format
  • Verify colors match expected sRGB output (no blown-out highlights)
  • Verify no regression on standard BGRA8Unorm surfaces

kbrandwijk and others added 2 commits March 23, 2026 14:00
Without this, RGBA16Float surface values are interpreted in linear/EDR
color space, making colors appear too bright. Setting sRGB ensures
correct tone mapping for standard content.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@wcandillon wcandillon self-requested a review March 25, 2026 09:38
Copy link
Copy Markdown
Contributor

@wcandillon wcandillon left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any chance you could add a small example that uses RGBA16Float? I think it would be fun, we could also display it next to a Skia Canvas/Native views to make some comparison. Now is that something that works on Android already? A small example would allow to test these things quickly.

Replace the hardcoded sRGB colorspace with a configurable colorSpace
prop that supports "srgb", "display-p3", "bt2020-hlg", and "bt2020-pq".

The colorSpace determines how rendered pixel values are interpreted for
display — independent of the texture format. This is needed when
rendering camera frames in different color spaces (e.g. P3, Apple Log)
via RGBA16Float textures.

iOS: Sets CAMetalLayer.colorspace based on the prop value.
Android: Calls ANativeWindow_setBuffersDataSpace (API 28+).

The prop is reactive — changing it at runtime (e.g. when switching
camera formats) updates the display colorspace without recreating
the surface.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@kbrandwijk
Copy link
Copy Markdown
Contributor Author

kbrandwijk commented Mar 25, 2026

Rethought this — hardcoding to sRGB wasn't the right approach. The colorspace on the display surface should be configurable because it depends on the content being rendered, not just the texture format.

What changed: Replaced the hardcoded CAMetalLayer.colorspace = sRGB with a colorSpace prop on WebGPUCanvas:

<WebGPUCanvas colorSpace="display-p3" />
<WebGPUCanvas colorSpace="srgb" />
<WebGPUCanvas colorSpace="bt2020-hlg" />

Both platforms:

  • iOS: Sets CAMetalLayer.colorspace based on prop value (sRGB, Display P3, ITU-R 2100 HLG/PQ)
  • Android: Calls ANativeWindow_setBuffersDataSpace (API 28+) with the corresponding data space

The prop is reactive — changing camera format at runtime (e.g., switching from P3 to sRGB) updates the display colorspace without recreating the surface.

Re: example — This makes a side-by-side comparison straightforward: two WebGPUCanvas instances with different colorSpace props rendering the same frame. Happy to put a quick example together.

Note: The existing (non-WebGPU) Canvas already has a colorSpace prop ("p3" | "srgb") that works on iOS but is a no-op stub on Android. Same gap, separate code path. Didn't want to bloat this PR by addressing existing tech-debt there, but happy to follow up if you think it's worth it!

@wcandillon
Copy link
Copy Markdown
Contributor

wcandillon commented Mar 25, 2026 via email

@kbrandwijk
Copy link
Copy Markdown
Contributor Author

kbrandwijk commented Mar 25, 2026

You're right — Dawn has SurfaceColorManagement as a chained struct on SurfaceDescriptor with PredefinedColorSpace (sRGB, Display P3) and ToneMappingMode (Standard, Extended). So for sRGB and Display P3, we can use that at the WebGPU spec level.

However... the WebGPU spec only defines two color spaces — our camera pipeline also outputs BT.2020 (HLG/PQ) when using Apple Log or HDR formats, which aren't covered by PredefinedColorSpace. For those we need the native layer (CAMetalLayer.colorspace on iOS, ANativeWindow_setBuffersDataSpace on Android) — similar to how alphaMode has platform-specific handling.

So the approach could be: use SurfaceColorManagement for spec-compliant color spaces, and fall back to native APIs for the wider gamut formats. Or keep it consistent and always set it at the native layer since it's a superset. What do you think?

There's a larger conversation going on around proper support through the WebGPU spec (gpuweb/gpuweb#4919 and https://github.com/ccameron-chromium/webgpu-hdr/blob/main/EXPLAINER.md) so for now I personally think it's fine to go through native directly, but if you prefer the fallback option then let me know.

@kbrandwijk
Copy link
Copy Markdown
Contributor Author

One thing worth noting — the Chrome team's HDR proposal uses "rec2100-hlg", "rec2100-pq", and "rec2100-display-linear" as the color space names. We currently use "bt2020-hlg" and "bt2020-pq". We should probably align our prop values with the proposed spec naming ("rec2100-hlg" / "rec2100-pq") so we're forward-compatible if/when these land in PredefinedColorSpace. Happy to update.

When colorSpace is bt2020-hlg or bt2020-pq:
- Enable wantsExtendedDynamicRangeContent on CAMetalLayer (iOS)
- Override surface format to RGBA16Float via SurfaceInfo so the
  full HDR range is preserved through to display

The format override is applied both as a stored preference (for when
the native prop is set before JS configure) and checked during
GPUCanvasContext::configure() to handle race conditions between
native prop delivery and JS-side surface configuration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@kbrandwijk
Copy link
Copy Markdown
Contributor Author

Follow-up: added EDR support and surface format override for HDR color spaces.

When testing with HLG BT.2020 camera output, we discovered that setting the colorspace alone isn't enough for HDR — the surface also needs:

wantsExtendedDynamicRangeContent = YES on the CAMetalLayer, so the display compositor preserves values above SDR white
RGBA16Float surface format instead of BGRA8Unorm, so the full HDR range survives from compute output through to presentation
Without both, HLG content either clips to SDR range or gets tone-mapped incorrectly (looks "overly bright" because 8-bit HLG values get re-interpreted through the HLG EOTF).

The format override is implemented via SurfaceInfo::setFormatOverride() which stores the desired format and applies it when GPUCanvasContext::configure() runs — handling the race between native prop delivery and JS-side surface configuration.

This confirms the link between texture format and colorspace that your raised @wcandillon; for SDR (sRGB, Display P3) the existing BGRA8Unorm surface works fine, but HDR color spaces require the format and colorspace to change together. A future configure()-level API could couple these, but for now the native prop approach gives us the flexibility to support BT.2020 variants that aren't in the WebGPU spec yet.

@kbrandwijk
Copy link
Copy Markdown
Contributor Author

kbrandwijk commented Mar 25, 2026

Here's a video showing the working relation between selected camera formats (sRGB, P3, 2020) and the selected surface format, for accurate display and underlying texture format usage (BGRA8unorm vs RGBA16float). You clearly see that sRGB canvas is washed out for P3 camera format, while P3 is accurate and the same for 2020 (it looks off, but that's HDR rendering)

ScreenRecording_03-25-2026.12-13-09.PM_1.MP4

@wcandillon wcandillon self-requested a review March 26, 2026 06:26
Copy link
Copy Markdown
Contributor

@wcandillon wcandillon left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks very compelling, can you add a small example in https://github.com/Shopify/react-native-skia/tree/main/apps/example/src/Examples/WebGPU so we can play with it.
Btw I will clean up the existing ones and add some other more relevant ones, this was just some dirty testing.

@wcandillon
Copy link
Copy Markdown
Contributor

it looks like there is a conflict, it shouldn't be something big

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants