diff --git a/public/assets/img/webgpu.png b/public/assets/img/webgpu.png index a44b73ea..7edddf27 100644 Binary files a/public/assets/img/webgpu.png and b/public/assets/img/webgpu.png differ diff --git a/sample/particles/main.ts b/sample/particles/main.ts index 89237ff6..87382c4a 100644 --- a/sample/particles/main.ts +++ b/sample/particles/main.ts @@ -30,13 +30,23 @@ canvas.width = canvas.clientWidth * devicePixelRatio; canvas.height = canvas.clientHeight * devicePixelRatio; const presentationFormat = 'rgba16float'; +const displaySettings = { + colorSpace: 'srgb' as string, + // rgba8unorm clamps colors outside the color space when the image is copied, + // while rgba16float preserves them (and avoids banding in linear spaces). + textureFormat: 'rgba8unorm' as 'rgba8unorm' | 'rgba16float', + toneMappingMode: 'standard' as GPUCanvasToneMappingMode, +}; + function configureContext() { context.configure({ device, + colorSpace: displaySettings.colorSpace as PredefinedColorSpace, format: presentationFormat, - toneMapping: { mode: simulationParams.toneMappingMode }, + toneMapping: { mode: displaySettings.toneMappingMode }, }); hdrFolder.name = getHdrFolderName(); + updateColorSpaceName(); } const particlesBuffer = device.createBuffer({ @@ -186,43 +196,75 @@ 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({ - size: [imageBitmap.width, imageBitmap.height, 1], - mipLevelCount, - format: 'rgba8unorm', - usage: - GPUTextureUsage.TEXTURE_BINDING | - GPUTextureUsage.STORAGE_BINDING | - GPUTextureUsage.COPY_DST | - GPUTextureUsage.RENDER_ATTACHMENT, -}); -device.queue.copyExternalImageToTexture( - { source: imageBitmap }, - { texture: texture }, - [imageBitmap.width, imageBitmap.height] -); +let logoTexture: GPUTexture; +// Creates the logo texture in the current texture format, copies the image +// into it, and generates the probability map. +function createLogoTexture() { + logoTexture?.destroy(); + logoTexture = device.createTexture({ + size: [imageBitmap.width, imageBitmap.height, 1], + mipLevelCount, + format: displaySettings.textureFormat, + usage: + GPUTextureUsage.TEXTURE_BINDING | + GPUTextureUsage.STORAGE_BINDING | + GPUTextureUsage.COPY_DST | + GPUTextureUsage.RENDER_ATTACHMENT, + }); + reinitLogoTexture(); + generateProbabilityMap(); +} +// 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 reinitLogoTexture() { + device.queue.copyExternalImageToTexture( + { source: imageBitmap }, + { + texture: logoTexture, + colorSpace: displaySettings.colorSpace as PredefinedColorSpace, + }, + [imageBitmap.width, imageBitmap.height] + ); +} ////////////////////////////////////////////////////////////////////////////// // 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. ////////////////////////////////////////////////////////////////////////////// -{ - const probabilityMapImportLevelPipeline = device.createComputePipeline({ - layout: 'auto', - compute: { - module: device.createShaderModule({ code: probabilityMapWGSL }), - entryPoint: 'import_level', - }, - }); - const probabilityMapExportLevelPipeline = device.createComputePipeline({ - layout: 'auto', - compute: { - module: device.createShaderModule({ code: probabilityMapWGSL }), - entryPoint: 'export_level', - }, - }); +// The storage texture format is part of the shader, so create a pair of +// pipelines for each texture format. +const probabilityMapPipelines = new Map< + GPUTextureFormat, + { importLevel: GPUComputePipeline; exportLevel: GPUComputePipeline } +>(); +function getProbabilityMapPipelines(format: GPUTextureFormat) { + let pipelines = probabilityMapPipelines.get(format); + if (!pipelines) { + const module = device.createShaderModule({ + code: probabilityMapWGSL.replace('rgba8unorm', format), + }); + pipelines = { + importLevel: device.createComputePipeline({ + layout: 'auto', + compute: { module, entryPoint: 'import_level' }, + }), + exportLevel: device.createComputePipeline({ + layout: 'auto', + compute: { module, entryPoint: 'export_level' }, + }), + }; + probabilityMapPipelines.set(format, pipelines); + } + return pipelines; +} +function generateProbabilityMap() { + const { + importLevel: probabilityMapImportLevelPipeline, + exportLevel: probabilityMapExportLevelPipeline, + } = getProbabilityMapPipelines(logoTexture.format); const probabilityMapUBOBufferSize = 1 * 4 + // stride @@ -233,7 +275,7 @@ device.queue.copyExternalImageToTexture( 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({ @@ -243,12 +285,12 @@ device.queue.copyExternalImageToTexture( 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) @@ -274,8 +316,8 @@ device.queue.copyExternalImageToTexture( { // tex_in / tex_out binding: 3, - resource: texture.createView({ - format: 'rgba8unorm', + resource: logoTexture.createView({ + format: logoTexture.format, dimension: '2d', baseMipLevel: level, mipLevelCount: 1, @@ -298,6 +340,9 @@ device.queue.copyExternalImageToTexture( } } device.queue.submit([commandEncoder.finish()]); + probabilityMapUBOBuffer.destroy(); + buffer_a.destroy(); + buffer_b.destroy(); } ////////////////////////////////////////////////////////////////////////////// @@ -306,7 +351,6 @@ device.queue.copyExternalImageToTexture( const simulationParams = { simulate: true, deltaTime: 0.04, - toneMappingMode: 'standard' as GPUCanvasToneMappingMode, brightnessFactor: 1.0, }; @@ -325,9 +369,40 @@ 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'); +const colorSpaceController = colorFolder + .add(displaySettings, 'colorSpace', [ + 'srgb', + 'srgb-linear', + 'display-p3', + 'display-p3-linear', + ]) + .onChange(() => { + configureContext(); + reinitLogoTexture(); + }); +// Disable color spaces the browser doesn't support. +for (const option of colorSpaceController.domElement.querySelectorAll( + 'option' +)) { + option.disabled = !isColorSpaceSupported(option.value); +} +colorFolder + .add(displaySettings, 'textureFormat', ['rgba8unorm', 'rgba16float']) + .onChange(initLogoTextureAndBindGroup); +colorFolder.open(); +const p3MediaQuery = window.matchMedia('(color-gamut: p3)'); +function updateColorSpaceName() { + const wantsP3 = displaySettings.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']) + .add(displaySettings, 'toneMappingMode', ['standard', 'extended']) .onChange(configureContext); hdrFolder.add(simulationParams, 'brightnessFactor', 0, 4, 0.1); hdrFolder.open(); @@ -340,7 +415,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"; @@ -360,14 +435,20 @@ const computePipeline = device.createComputePipeline({ entryPoint: 'simulate', }, }); -const computeBindGroup = device.createBindGroup({ - layout: computePipeline.getBindGroupLayout(0), - entries: [ - { binding: 0, resource: simulationUBOBuffer }, - { binding: 1, resource: particlesBuffer }, - { binding: 2, resource: texture.createView() }, - ], -}); +let computeBindGroup: GPUBindGroup; +// (Re)creates the logo texture and the compute bind group that samples it. +function initLogoTextureAndBindGroup() { + createLogoTexture(); + computeBindGroup = device.createBindGroup({ + layout: computePipeline.getBindGroupLayout(0), + entries: [ + { binding: 0, resource: simulationUBOBuffer }, + { binding: 1, resource: particlesBuffer }, + { binding: 2, resource: logoTexture.createView() }, + ], + }); +} +initLogoTextureAndBindGroup(); const aspect = canvas.width / canvas.height; const projection = mat4.perspective((2 * Math.PI) / 5, aspect, 1, 100.0); @@ -446,3 +527,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' }, diff --git a/sample/particles/probabilityMap.wgsl b/sample/particles/probabilityMap.wgsl index f5e08f5b..83db8421 100644 --- a/sample/particles/probabilityMap.wgsl +++ b/sample/particles/probabilityMap.wgsl @@ -6,6 +6,8 @@ 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; +// The storage format is replaced at runtime in main.ts to match the +// selected texture format. @binding(3) @group(0) var tex_out : texture_storage_2d; ////////////////////////////////////////////////////////////////////////////////