From c40436a6e9f1234e424fbbb2978fbea39decb990 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Beaufort?= Date: Wed, 30 Sep 2026 11:19:30 +0200 Subject: [PATCH 1/5] Add color space selection to particles sample Let users pick the canvas colorSpace (srgb, srgb-linear, display-p3, display-p3-linear) in a new "Color settings" folder. Only color spaces supported by the browser are listed, and a warning is shown when a display-p3 space is selected on a non wide gamut display. --- sample/particles/main.ts | 43 +++++++++++++++++++++++++++++++++++++++- sample/particles/meta.ts | 2 +- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/sample/particles/main.ts b/sample/particles/main.ts index 89237ff6..4364e868 100644 --- a/sample/particles/main.ts +++ b/sample/particles/main.ts @@ -33,10 +33,12 @@ const presentationFormat = 'rgba16float'; function configureContext() { context.configure({ device, + colorSpace: simulationParams.colorSpace as PredefinedColorSpace, format: presentationFormat, toneMapping: { mode: simulationParams.toneMappingMode }, }); hdrFolder.name = getHdrFolderName(); + updateColorSpaceName(); } const particlesBuffer = device.createBuffer({ @@ -306,6 +308,7 @@ device.queue.copyExternalImageToTexture( const simulationParams = { simulate: true, deltaTime: 0.04, + colorSpace: 'srgb' as string, toneMappingMode: 'standard' as GPUCanvasToneMappingMode, brightnessFactor: 1.0, }; @@ -325,7 +328,27 @@ const gui = new GUI(); gui.width = 325; gui.add(simulationParams, 'simulate'); gui.add(simulationParams, 'deltaTime'); -const hdrFolder = gui.addFolder(''); +const colorFolder = gui.addFolder('Color settings'); +colorFolder + .add( + simulationParams, + 'colorSpace', + ['srgb', 'srgb-linear', 'display-p3', 'display-p3-linear'].filter( + isColorSpaceSupported + ) + ) + .onChange(configureContext); +colorFolder.open(); +const p3MediaQuery = window.matchMedia('(color-gamut: p3)'); +function updateColorSpaceName() { + const wantsP3 = simulationParams.colorSpace.startsWith('display-p3'); + colorFolder.name = + wantsP3 && !p3MediaQuery.matches + ? "Color settings ⚠️ Display isn't wide gamut" + : 'Color settings'; +} +p3MediaQuery.onchange = updateColorSpaceName; +const hdrFolder = gui.addFolder('HDR settings'); hdrFolder .add(simulationParams, 'toneMappingMode', ['standard', 'extended']) .onChange(configureContext); @@ -446,3 +469,21 @@ function assert(cond: boolean, msg = '') { throw new Error(msg); } } + +// Configuring a canvas context with an unsupported color space throws a +// TypeError, so probe each one on a throwaway OffscreenCanvas context. +function isColorSpaceSupported(colorSpace: string) { + const ctx = new OffscreenCanvas(1, 1).getContext('webgpu'); + try { + ctx.configure({ + device, + format: presentationFormat, + colorSpace: colorSpace as PredefinedColorSpace, + }); + return true; + } catch { + return false; + } finally { + ctx.unconfigure(); + } +} diff --git a/sample/particles/meta.ts b/sample/particles/meta.ts index a3416d84..465bb856 100644 --- a/sample/particles/meta.ts +++ b/sample/particles/meta.ts @@ -2,7 +2,7 @@ export default { name: 'Particles (HDR)', tocName: 'particles (HDR)', description: - 'This example demonstrates rendering of particles (using HDR capabilities when possible) simulated with compute shaders.', + 'This example demonstrates rendering of particles simulated with compute shaders, using HDR and wide color gamut canvas capabilities when possible.', filename: __DIRNAME__, sources: [ { path: 'main.ts' }, From d7e0043f80cb07bfd85d16aaacd526caab709a39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Beaufort?= Date: Thu, 1 Oct 2026 07:22:42 +0200 Subject: [PATCH 2/5] Use color space when copying image to particles texture Pass the selected color space to copyExternalImageToTexture and re-copy the image when it changes. Switch the texture to rgba16float to avoid banding with linear color spaces. --- sample/particles/main.ts | 44 ++++++++++++++++++---------- sample/particles/probabilityMap.wgsl | 2 +- 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/sample/particles/main.ts b/sample/particles/main.ts index 4364e868..0fedd621 100644 --- a/sample/particles/main.ts +++ b/sample/particles/main.ts @@ -30,6 +30,14 @@ canvas.width = canvas.clientWidth * devicePixelRatio; canvas.height = canvas.clientHeight * devicePixelRatio; const presentationFormat = 'rgba16float'; +const simulationParams = { + simulate: true, + deltaTime: 0.04, + colorSpace: 'srgb' as string, + toneMappingMode: 'standard' as GPUCanvasToneMappingMode, + brightnessFactor: 1.0, +}; + function configureContext() { context.configure({ device, @@ -191,18 +199,27 @@ const mipLevelCount = const texture = device.createTexture({ size: [imageBitmap.width, imageBitmap.height, 1], mipLevelCount, - format: 'rgba8unorm', + format: 'rgba16float', usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.STORAGE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT, }); -device.queue.copyExternalImageToTexture( - { source: imageBitmap }, - { texture: texture }, - [imageBitmap.width, imageBitmap.height] -); +// Copies the image into mip level 0, converting its colors to the current +// color space. The alpha channel (used for the probability map) is unaffected +// by the color space, so the probability map doesn't need regenerating. +function copyImageToTexture() { + device.queue.copyExternalImageToTexture( + { source: imageBitmap }, + { + texture: texture, + colorSpace: simulationParams.colorSpace as PredefinedColorSpace, + }, + [imageBitmap.width, imageBitmap.height] + ); +} +copyImageToTexture(); ////////////////////////////////////////////////////////////////////////////// // Probability map generation @@ -277,7 +294,7 @@ device.queue.copyExternalImageToTexture( // tex_in / tex_out binding: 3, resource: texture.createView({ - format: 'rgba8unorm', + format: 'rgba16float', dimension: '2d', baseMipLevel: level, mipLevelCount: 1, @@ -305,14 +322,6 @@ device.queue.copyExternalImageToTexture( ////////////////////////////////////////////////////////////////////////////// // Simulation compute pipeline ////////////////////////////////////////////////////////////////////////////// -const simulationParams = { - simulate: true, - deltaTime: 0.04, - colorSpace: 'srgb' as string, - toneMappingMode: 'standard' as GPUCanvasToneMappingMode, - brightnessFactor: 1.0, -}; - const simulationUBOBufferSize = 1 * 4 + // deltaTime 1 * 4 + // brightnessFactor @@ -337,7 +346,10 @@ colorFolder isColorSpaceSupported ) ) - .onChange(configureContext); + .onChange(() => { + configureContext(); + copyImageToTexture(); + }); colorFolder.open(); const p3MediaQuery = window.matchMedia('(color-gamut: p3)'); function updateColorSpaceName() { diff --git a/sample/particles/probabilityMap.wgsl b/sample/particles/probabilityMap.wgsl index f5e08f5b..0016a539 100644 --- a/sample/particles/probabilityMap.wgsl +++ b/sample/particles/probabilityMap.wgsl @@ -6,7 +6,7 @@ struct UBO { @binding(1) @group(0) var buf_in : array; @binding(2) @group(0) var buf_out : array; @binding(3) @group(0) var tex_in : texture_2d; -@binding(3) @group(0) var tex_out : texture_storage_2d; +@binding(3) @group(0) var tex_out : texture_storage_2d; //////////////////////////////////////////////////////////////////////////////// // import_level From 68e237c106842466d9f3577ddd8c6cb4cbcd3694 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Beaufort?= Date: Fri, 2 Oct 2026 08:08:22 +0200 Subject: [PATCH 3/5] Split color space and tone mapping into displaySettings in particles sample --- sample/particles/main.ts | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/sample/particles/main.ts b/sample/particles/main.ts index 0fedd621..ed6c7558 100644 --- a/sample/particles/main.ts +++ b/sample/particles/main.ts @@ -30,20 +30,17 @@ canvas.width = canvas.clientWidth * devicePixelRatio; canvas.height = canvas.clientHeight * devicePixelRatio; const presentationFormat = 'rgba16float'; -const simulationParams = { - simulate: true, - deltaTime: 0.04, +const displaySettings = { colorSpace: 'srgb' as string, toneMappingMode: 'standard' as GPUCanvasToneMappingMode, - brightnessFactor: 1.0, }; function configureContext() { context.configure({ device, - colorSpace: simulationParams.colorSpace as PredefinedColorSpace, + colorSpace: displaySettings.colorSpace as PredefinedColorSpace, format: presentationFormat, - toneMapping: { mode: simulationParams.toneMappingMode }, + toneMapping: { mode: displaySettings.toneMappingMode }, }); hdrFolder.name = getHdrFolderName(); updateColorSpaceName(); @@ -214,7 +211,7 @@ function copyImageToTexture() { { source: imageBitmap }, { texture: texture, - colorSpace: simulationParams.colorSpace as PredefinedColorSpace, + colorSpace: displaySettings.colorSpace as PredefinedColorSpace, }, [imageBitmap.width, imageBitmap.height] ); @@ -322,6 +319,12 @@ copyImageToTexture(); ////////////////////////////////////////////////////////////////////////////// // Simulation compute pipeline ////////////////////////////////////////////////////////////////////////////// +const simulationParams = { + simulate: true, + deltaTime: 0.04, + brightnessFactor: 1.0, +}; + const simulationUBOBufferSize = 1 * 4 + // deltaTime 1 * 4 + // brightnessFactor @@ -340,7 +343,7 @@ gui.add(simulationParams, 'deltaTime'); const colorFolder = gui.addFolder('Color settings'); colorFolder .add( - simulationParams, + displaySettings, 'colorSpace', ['srgb', 'srgb-linear', 'display-p3', 'display-p3-linear'].filter( isColorSpaceSupported @@ -353,7 +356,7 @@ colorFolder colorFolder.open(); const p3MediaQuery = window.matchMedia('(color-gamut: p3)'); function updateColorSpaceName() { - const wantsP3 = simulationParams.colorSpace.startsWith('display-p3'); + const wantsP3 = displaySettings.colorSpace.startsWith('display-p3'); colorFolder.name = wantsP3 && !p3MediaQuery.matches ? "Color settings ⚠️ Display isn't wide gamut" @@ -362,7 +365,7 @@ function updateColorSpaceName() { p3MediaQuery.onchange = updateColorSpaceName; const hdrFolder = gui.addFolder('HDR settings'); hdrFolder - .add(simulationParams, 'toneMappingMode', ['standard', 'extended']) + .add(displaySettings, 'toneMappingMode', ['standard', 'extended']) .onChange(configureContext); hdrFolder.add(simulationParams, 'brightnessFactor', 0, 4, 0.1); hdrFolder.open(); @@ -375,7 +378,7 @@ function getHdrFolderName() { return 'HDR settings'; } if ( - simulationParams.toneMappingMode === 'extended' && + displaySettings.toneMappingMode === 'extended' && context.getConfiguration().toneMapping?.mode !== 'extended' ) { return "HDR settings ⚠️ Browser doesn't support HDR canvas"; From a3a0108560ebca65482abf9db023213a413ef28c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Beaufort?= Date: Fri, 2 Oct 2026 08:17:33 +0200 Subject: [PATCH 4/5] Show unsupported color spaces as disabled options in particles sample --- sample/particles/main.ts | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/sample/particles/main.ts b/sample/particles/main.ts index ed6c7558..dc8330bc 100644 --- a/sample/particles/main.ts +++ b/sample/particles/main.ts @@ -341,18 +341,23 @@ gui.width = 325; gui.add(simulationParams, 'simulate'); gui.add(simulationParams, 'deltaTime'); const colorFolder = gui.addFolder('Color settings'); -colorFolder - .add( - displaySettings, - 'colorSpace', - ['srgb', 'srgb-linear', 'display-p3', 'display-p3-linear'].filter( - isColorSpaceSupported - ) - ) +const colorSpaceController = colorFolder + .add(displaySettings, 'colorSpace', [ + 'srgb', + 'srgb-linear', + 'display-p3', + 'display-p3-linear', + ]) .onChange(() => { configureContext(); copyImageToTexture(); }); +// Disable color spaces the browser doesn't support. +for (const option of colorSpaceController.domElement.querySelectorAll( + 'option' +)) { + option.disabled = !isColorSpaceSupported(option.value); +} colorFolder.open(); const p3MediaQuery = window.matchMedia('(color-gamut: p3)'); function updateColorSpaceName() { From 5420fbd4cad733bfbd0e76018b3cd19d5e7ca93a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Beaufort?= Date: Fri, 2 Oct 2026 08:20:02 +0200 Subject: [PATCH 5/5] Rename texture to logoTexture and copyImageToTexture to reinitLogoTexture in particles sample --- sample/particles/main.ts | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/sample/particles/main.ts b/sample/particles/main.ts index dc8330bc..345ca938 100644 --- a/sample/particles/main.ts +++ b/sample/particles/main.ts @@ -193,7 +193,7 @@ assert(isPowerOf2(imageBitmap.width), 'image must be a power of 2'); // Calculate number of mip levels required to generate the probability map const mipLevelCount = (Math.log2(Math.max(imageBitmap.width, imageBitmap.height)) + 1) | 0; -const texture = device.createTexture({ +const logoTexture = device.createTexture({ size: [imageBitmap.width, imageBitmap.height, 1], mipLevelCount, format: 'rgba16float', @@ -206,22 +206,22 @@ const texture = device.createTexture({ // Copies the image into mip level 0, converting its colors to the current // color space. The alpha channel (used for the probability map) is unaffected // by the color space, so the probability map doesn't need regenerating. -function copyImageToTexture() { +function reinitLogoTexture() { device.queue.copyExternalImageToTexture( { source: imageBitmap }, { - texture: texture, + texture: logoTexture, colorSpace: displaySettings.colorSpace as PredefinedColorSpace, }, [imageBitmap.width, imageBitmap.height] ); } -copyImageToTexture(); +reinitLogoTexture(); ////////////////////////////////////////////////////////////////////////////// // Probability map generation -// The 0'th mip level of texture holds the color data and spawn-probability in -// the alpha channel. The mip levels 1..N are generated to hold spawn +// The 0'th mip level of logoTexture holds the color data and spawn-probability +// in the alpha channel. The mip levels 1..N are generated to hold spawn // probabilities up to the top 1x1 mip level. ////////////////////////////////////////////////////////////////////////////// { @@ -249,7 +249,7 @@ copyImageToTexture(); usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, }); const buffer_a = device.createBuffer({ - size: texture.width * texture.height * 4, + size: logoTexture.width * logoTexture.height * 4, usage: GPUBufferUsage.STORAGE, }); const buffer_b = device.createBuffer({ @@ -259,12 +259,12 @@ copyImageToTexture(); device.queue.writeBuffer( probabilityMapUBOBuffer, 0, - new Uint32Array([texture.width]) + new Uint32Array([logoTexture.width]) ); const commandEncoder = device.createCommandEncoder(); - for (let level = 0; level < texture.mipLevelCount; level++) { - const levelWidth = Math.max(1, texture.width >> level); - const levelHeight = Math.max(1, texture.height >> level); + for (let level = 0; level < logoTexture.mipLevelCount; level++) { + const levelWidth = Math.max(1, logoTexture.width >> level); + const levelHeight = Math.max(1, logoTexture.height >> level); const pipeline = level == 0 ? probabilityMapImportLevelPipeline.getBindGroupLayout(0) @@ -290,7 +290,7 @@ copyImageToTexture(); { // tex_in / tex_out binding: 3, - resource: texture.createView({ + resource: logoTexture.createView({ format: 'rgba16float', dimension: '2d', baseMipLevel: level, @@ -350,7 +350,7 @@ const colorSpaceController = colorFolder ]) .onChange(() => { configureContext(); - copyImageToTexture(); + reinitLogoTexture(); }); // Disable color spaces the browser doesn't support. for (const option of colorSpaceController.domElement.querySelectorAll( @@ -408,7 +408,7 @@ const computeBindGroup = device.createBindGroup({ entries: [ { binding: 0, resource: simulationUBOBuffer }, { binding: 1, resource: particlesBuffer }, - { binding: 2, resource: texture.createView() }, + { binding: 2, resource: logoTexture.createView() }, ], });