diff --git a/src/generate-mipmap.ts b/src/generate-mipmap.ts index 4d1a056..565bccc 100644 --- a/src/generate-mipmap.ts +++ b/src/generate-mipmap.ts @@ -2,7 +2,7 @@ import { isTypedArray, } from './typed-arrays.js'; -function getViewDimensionForTexture(texture: GPUTexture): GPUTextureViewDimension { +function guessTextureBindingViewDimensionForTexture(texture: GPUTexture): GPUTextureViewDimension { switch (texture.dimension) { case '1d': return '1d'; @@ -10,7 +10,7 @@ function getViewDimensionForTexture(texture: GPUTexture): GPUTextureViewDimensio return '3d'; default: // to shut up TS case '2d': - return texture.depthOrArrayLayers > 1 ? '2d-array' : '2d'; + return texture.depthOrArrayLayers > 1 ? '2d-array' : '2d'; } } @@ -48,40 +48,51 @@ export function numMipLevels(size: GPUExtent3D, dimension?: GPUTextureDimension) return 1 + Math.log2(maxSize) | 0; } -// Use a WeakMap so the device can be destroyed and/or lost -const byDevice = new WeakMap(); +function getMipmapGenerationWGSL(textureBindingViewDimension: GPUTextureViewDimension) { + let textureSnippet; + let sampleSnippet; + switch (textureBindingViewDimension) { + case '2d': + textureSnippet = 'texture_2d'; + sampleSnippet = 'textureSample(ourTexture, ourSampler, fsInput.texcoord)'; + break; + case '2d-array': + textureSnippet = 'texture_2d_array'; + sampleSnippet = ` + textureSample( + ourTexture, + ourSampler, + fsInput.texcoord, + uni.layer)`; + break; + case 'cube': + textureSnippet = 'texture_cube'; + sampleSnippet = ` + textureSample( + ourTexture, + ourSampler, + faceMat[uni.layer] * vec3f(fract(fsInput.texcoord), 1))`; + break; + case 'cube-array': + textureSnippet = 'texture_cube_array'; + sampleSnippet = ` + textureSample( + ourTexture, + ourSampler, + faceMat[uni.layer] * vec3f(fract(fsInput.texcoord), 1), uni.layer)`; + break; + default: + throw new Error(`unsupported view: ${textureBindingViewDimension}`); + } + return ` + const faceMat = array( + mat3x3f( 0, 0, -2, 0, -2, 0, 1, 1, 1), // pos-x + mat3x3f( 0, 0, 2, 0, -2, 0, -1, 1, -1), // neg-x + mat3x3f( 2, 0, 0, 0, 0, 2, -1, 1, -1), // pos-y + mat3x3f( 2, 0, 0, 0, 0, -2, -1, -1, 1), // neg-y + mat3x3f( 2, 0, 0, 0, -2, 0, -1, 1, 1), // pos-z + mat3x3f(-2, 0, 0, 0, -2, 0, 1, 1, -1)); // neg-z -/** - * Generates mip levels from level 0 to the last mip for an existing texture - * - * The texture must have been created with TEXTURE_BINDING and - * RENDER_ATTACHMENT and been created with mip levels - * - * @param device - * @param texture - */ -export function generateMipmap(device: GPUDevice, texture: GPUTexture) { - let perDeviceInfo = byDevice.get(device); - if (!perDeviceInfo) { - perDeviceInfo = { - pipelineByFormat: {}, - moduleByView: {}, - }; - byDevice.set(device, perDeviceInfo); - } - let { - sampler, - } = perDeviceInfo; - const { - pipelineByFormat, - moduleByView, - } = perDeviceInfo; - const view = getViewDimensionForTexture(texture); - let module = moduleByView[view]; - if (!module) { - module = device.createShaderModule({ - label: `mip level generation for ${view}`, - code: ` struct VSOutput { @builtin(position) position: vec4f, @location(0) texcoord: vec2f, @@ -103,29 +114,85 @@ export function generateMipmap(device: GPUDevice, texture: GPUTexture) { return vsOutput; } + struct Uniforms { + layer: u32, + }; + @group(0) @binding(0) var ourSampler: sampler; - @group(0) @binding(1) var ourTexture: texture_2d; + @group(0) @binding(1) var ourTexture: ${textureSnippet}; + @group(0) @binding(2) var uni: Uniforms; @fragment fn fs(fsInput: VSOutput) -> @location(0) vec4f { - return textureSample(ourTexture, ourSampler, fsInput.texcoord); + _ = uni.layer; // make sure this is used so all pipelines have the same bindings + return ${sampleSnippet}; } - `, + `; +} + +// Use a WeakMap so the device can be destroyed and/or lost +const byDevice = new WeakMap(); + +/** + * Generates mip levels from level 0 to the last mip for an existing texture + * + * The texture must have been created with TEXTURE_BINDING and RENDER_ATTACHMENT + * and been created with mip levels + * + * @param device A GPUDevice + * @param texture The texture to create mips for + * @param textureBindingViewDimension This is only needed in compatibility mode + * and it is only needed when the texture is going to be used as a cube map. + */ +export function generateMipmap( + device: GPUDevice, + texture: GPUTexture, + textureBindingViewDimension?: GPUTextureViewDimension) { + let perDeviceInfo = byDevice.get(device); + if (!perDeviceInfo) { + perDeviceInfo = { + pipelineByFormatAndView: {}, + moduleByViewType: {}, + }; + byDevice.set(device, perDeviceInfo); + } + let { + sampler, + uniformBuffer, + uniformValues, + } = perDeviceInfo; + const { + pipelineByFormatAndView, + moduleByViewType, + } = perDeviceInfo; + textureBindingViewDimension = textureBindingViewDimension || guessTextureBindingViewDimensionForTexture(texture); + let module = moduleByViewType[textureBindingViewDimension]; + if (!module) { + const code = getMipmapGenerationWGSL(textureBindingViewDimension); + module = device.createShaderModule({ + label: `mip level generation for ${textureBindingViewDimension}`, + code, }); - moduleByView[view] = module; + moduleByViewType[textureBindingViewDimension] = module; } if (!sampler) { sampler = device.createSampler({ minFilter: 'linear', + magFilter: 'linear', + }); + uniformBuffer = device.createBuffer({ + size: 16, + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, }); - perDeviceInfo.sampler = sampler; + uniformValues = new Uint32Array(1); + Object.assign(perDeviceInfo, { sampler, uniformBuffer, uniformValues }); } - const id = `${texture.format}`; + const id = `${texture.format}.${textureBindingViewDimension}`; - if (!pipelineByFormat[id]) { - pipelineByFormat[id] = device.createRenderPipeline({ - label: `mip level generator pipeline for ${view}`, + if (!pipelineByFormatAndView[id]) { + pipelineByFormatAndView[id] = device.createRenderPipeline({ + label: `mip level generator pipeline for ${textureBindingViewDimension}`, layout: 'auto', vertex: { module, @@ -138,14 +205,13 @@ export function generateMipmap(device: GPUDevice, texture: GPUTexture) { }, }); } - const pipeline = pipelineByFormat[id]; - - const encoder = device.createCommandEncoder({ - label: 'mip gen encoder', - }); + const pipeline = pipelineByFormatAndView[id]; for (let baseMipLevel = 1; baseMipLevel < texture.mipLevelCount; ++baseMipLevel) { for (let baseArrayLayer = 0; baseArrayLayer < texture.depthOrArrayLayers; ++baseArrayLayer) { + uniformValues[0] = baseArrayLayer; + device.queue.writeBuffer(uniformBuffer, 0, uniformValues); + const bindGroup = device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: [ @@ -153,13 +219,12 @@ export function generateMipmap(device: GPUDevice, texture: GPUTexture) { { binding: 1, resource: texture.createView({ - dimension: '2d', + dimension: textureBindingViewDimension, baseMipLevel: baseMipLevel - 1, mipLevelCount: 1, - baseArrayLayer, - arrayLayerCount: 1, }), }, + { binding: 2, resource: { buffer: uniformBuffer }}, ], }); @@ -168,6 +233,7 @@ export function generateMipmap(device: GPUDevice, texture: GPUTexture) { colorAttachments: [ { view: texture.createView({ + dimension: '2d', baseMipLevel, mipLevelCount: 1, baseArrayLayer, @@ -179,14 +245,18 @@ export function generateMipmap(device: GPUDevice, texture: GPUTexture) { ], }; + const encoder = device.createCommandEncoder({ + label: 'mip gen encoder', + }); + const pass = encoder.beginRenderPass(renderPassDescriptor); pass.setPipeline(pipeline); pass.setBindGroup(0, bindGroup); pass.draw(3); pass.end(); + + const commandBuffer = encoder.finish(); + device.queue.submit([commandBuffer]); } } - - const commandBuffer = encoder.finish(); - device.queue.submit([commandBuffer]); } \ No newline at end of file diff --git a/test/tests/generate-mipmap-test.js b/test/tests/generate-mipmap-test.js index 529e5f7..4e2e40c 100644 --- a/test/tests/generate-mipmap-test.js +++ b/test/tests/generate-mipmap-test.js @@ -4,7 +4,7 @@ import { numMipLevels, } from '../../dist/1.x/webgpu-utils.module.js'; import { assertArrayEqualApproximately, assertEqual } from '../assert.js'; -import { readTextureUnpadded, testWithDevice } from '../webgpu.js'; +import { readTextureUnpadded, testWithDeviceWithOptions } from '../webgpu.js'; // prevent global document // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -14,102 +14,72 @@ const document = undefined; describe('generate-mipmap tests', () => { - it('returns correct number of mip levels', () => { - assertEqual(numMipLevels([1]), 1); - assertEqual(numMipLevels([2]), 2); - assertEqual(numMipLevels([3]), 2); - assertEqual(numMipLevels([4]), 3); - assertEqual(numMipLevels([4]), 3); - - assertEqual(numMipLevels([1, 1]), 1); - assertEqual(numMipLevels([1, 2]), 2); - assertEqual(numMipLevels([1, 3]), 2); - assertEqual(numMipLevels([1, 4]), 3); - assertEqual(numMipLevels([1, 4]), 3); - - assertEqual(numMipLevels([1, 1, 1]), 1); - assertEqual(numMipLevels([1, 1, 2]), 1); - assertEqual(numMipLevels([1, 1, 3]), 1); - assertEqual(numMipLevels([1, 1, 4]), 1); - assertEqual(numMipLevels([1, 1, 4]), 1); - - assertEqual(numMipLevels([1, 1, 1], '3d'), 1); - assertEqual(numMipLevels([1, 1, 2], '3d'), 2); - assertEqual(numMipLevels([1, 1, 3], '3d'), 2); - assertEqual(numMipLevels([1, 1, 4], '3d'), 3); - assertEqual(numMipLevels([1, 1, 4], '3d'), 3); - - assertEqual(numMipLevels({width: 1}), 1); - assertEqual(numMipLevels({width: 2}), 2); - assertEqual(numMipLevels({width: 3}), 2); - assertEqual(numMipLevels({width: 4}), 3); - assertEqual(numMipLevels({width: 4}), 3); - - assertEqual(numMipLevels({width: 1, height: 1}), 1); - assertEqual(numMipLevels({width: 1, height: 2}), 2); - assertEqual(numMipLevels({width: 1, height: 3}), 2); - assertEqual(numMipLevels({width: 1, height: 4}), 3); - assertEqual(numMipLevels({width: 1, height: 4}), 3); - - assertEqual(numMipLevels({width: 1, depthOrArrayLayers: 1}, '3d'), 1); - assertEqual(numMipLevels({width: 1, depthOrArrayLayers: 2}, '3d'), 2); - assertEqual(numMipLevels({width: 1, depthOrArrayLayers: 3}, '3d'), 2); - assertEqual(numMipLevels({width: 1, depthOrArrayLayers: 4}, '3d'), 3); - assertEqual(numMipLevels({width: 1, depthOrArrayLayers: 4}, '3d'), 3); - - assertEqual(numMipLevels({width: 1, depthOrArrayLayers: 1}), 1); - assertEqual(numMipLevels({width: 1, depthOrArrayLayers: 2}), 1); - assertEqual(numMipLevels({width: 1, depthOrArrayLayers: 3}), 1); - assertEqual(numMipLevels({width: 1, depthOrArrayLayers: 4}), 1); - assertEqual(numMipLevels({width: 1, depthOrArrayLayers: 4}), 1); + it('returns correct number of mip levels', () => { + assertEqual(numMipLevels([1]), 1); + assertEqual(numMipLevels([2]), 2); + assertEqual(numMipLevels([3]), 2); + assertEqual(numMipLevels([4]), 3); + assertEqual(numMipLevels([4]), 3); + + assertEqual(numMipLevels([1, 1]), 1); + assertEqual(numMipLevels([1, 2]), 2); + assertEqual(numMipLevels([1, 3]), 2); + assertEqual(numMipLevels([1, 4]), 3); + assertEqual(numMipLevels([1, 4]), 3); + + assertEqual(numMipLevels([1, 1, 1]), 1); + assertEqual(numMipLevels([1, 1, 2]), 1); + assertEqual(numMipLevels([1, 1, 3]), 1); + assertEqual(numMipLevels([1, 1, 4]), 1); + assertEqual(numMipLevels([1, 1, 4]), 1); + + assertEqual(numMipLevels([1, 1, 1], '3d'), 1); + assertEqual(numMipLevels([1, 1, 2], '3d'), 2); + assertEqual(numMipLevels([1, 1, 3], '3d'), 2); + assertEqual(numMipLevels([1, 1, 4], '3d'), 3); + assertEqual(numMipLevels([1, 1, 4], '3d'), 3); + + assertEqual(numMipLevels({width: 1}), 1); + assertEqual(numMipLevels({width: 2}), 2); + assertEqual(numMipLevels({width: 3}), 2); + assertEqual(numMipLevels({width: 4}), 3); + assertEqual(numMipLevels({width: 4}), 3); + + assertEqual(numMipLevels({width: 1, height: 1}), 1); + assertEqual(numMipLevels({width: 1, height: 2}), 2); + assertEqual(numMipLevels({width: 1, height: 3}), 2); + assertEqual(numMipLevels({width: 1, height: 4}), 3); + assertEqual(numMipLevels({width: 1, height: 4}), 3); + + assertEqual(numMipLevels({width: 1, depthOrArrayLayers: 1}, '3d'), 1); + assertEqual(numMipLevels({width: 1, depthOrArrayLayers: 2}, '3d'), 2); + assertEqual(numMipLevels({width: 1, depthOrArrayLayers: 3}, '3d'), 2); + assertEqual(numMipLevels({width: 1, depthOrArrayLayers: 4}, '3d'), 3); + assertEqual(numMipLevels({width: 1, depthOrArrayLayers: 4}, '3d'), 3); + + assertEqual(numMipLevels({width: 1, depthOrArrayLayers: 1}), 1); + assertEqual(numMipLevels({width: 1, depthOrArrayLayers: 2}), 1); + assertEqual(numMipLevels({width: 1, depthOrArrayLayers: 3}), 1); + assertEqual(numMipLevels({width: 1, depthOrArrayLayers: 4}), 1); + assertEqual(numMipLevels({width: 1, depthOrArrayLayers: 4}), 1); + + }); + + function test(compatibilityMode) { + const options = { + compatibilityMode, + }; + + describe(compatibilityMode ? 'test compatibility mode' : 'test normal WebGPU', () => { - }); - - it('generates mipmaps 1 layer', testWithDevice(async device => { - const kTextureWidth = 4; - const kTextureHeight = 4; - const r = [255, 0, 0, 255]; - const b = [0, 0, 255, 255]; - const textureData = new Uint8Array([ - r, r, b, b, - r, r, b, b, - b, b, r, r, - b, b, r, r, - ].flat()); - - const size = [kTextureWidth, kTextureHeight]; - const texture = device.createTexture({ - size, - mipLevelCount: numMipLevels(size), - format: 'rgba8unorm', - usage: GPUTextureUsage.TEXTURE_BINDING | - GPUTextureUsage.RENDER_ATTACHMENT | - GPUTextureUsage.COPY_DST | - GPUTextureUsage.COPY_SRC, - }); - - device.queue.writeTexture( - { texture }, - textureData, - { bytesPerRow: kTextureWidth * 4 }, - { width: kTextureWidth, height: kTextureHeight }, - ); - generateMipmap(device, texture); - - const result = await readTextureUnpadded(device, texture, 2); - assertArrayEqualApproximately(result, [128, 0, 128, 255], 1); - })); - - it('generates mipmaps 3 layers', testWithDevice(async device => { - const kTextureWidth = 4; - const kTextureHeight = 4; const r = [255, 0, 0, 255]; const g = [0, 255, 0, 255]; const b = [0, 0, 255, 255]; const y = [255, 255, 0, 255]; - // const c = [0, 255, 255, 255]; + const c = [0, 255, 255, 255]; const m = [255, 0, 255, 255]; - const textureData = [ + + const layerData = [ { src: new Uint8Array([ r, r, b, b, @@ -137,34 +107,103 @@ describe('generate-mipmap tests', () => { ].flat()), expected: [255, 128, 128, 255], }, + { + src: new Uint8Array([ + c, c, m, m, + c, c, m, m, + m, m, c, c, + m, m, c, c, + ].flat()), + expected: [128, 128, 255, 255], + }, + { + src: new Uint8Array([ + b, b, y, y, + b, b, y, y, + y, y, b, b, + y, y, b, b, + ].flat()), + expected: [128, 128, 128, 255], + }, + { + src: new Uint8Array([ + g, g, r, r, + g, g, r, r, + r, r, g, g, + r, r, g, g, + ].flat()), + expected: [128, 128, 0, 255], + }, ]; - const size = [kTextureWidth, kTextureHeight, textureData.length]; - const texture = device.createTexture({ - size, - mipLevelCount: numMipLevels(size), - format: 'rgba8unorm', - usage: GPUTextureUsage.TEXTURE_BINDING | - GPUTextureUsage.RENDER_ATTACHMENT | - GPUTextureUsage.COPY_DST | - GPUTextureUsage.COPY_SRC, - }); - - textureData.forEach(({src}, layer) => { - device.queue.writeTexture( - { texture, origin: [0, 0, layer] }, - src, - { bytesPerRow: kTextureWidth * 4 }, - { width: kTextureWidth, height: kTextureHeight }, + async function testGenerateMipmap(device, textureData, textureOptions = {}) { + const kTextureWidth = 4; + const kTextureHeight = 4; + const size = [kTextureWidth, kTextureHeight, textureData.length]; + const texture = device.createTexture({ + ...textureOptions, + size, + mipLevelCount: numMipLevels(size), + format: 'rgba8unorm', + usage: GPUTextureUsage.TEXTURE_BINDING | + GPUTextureUsage.RENDER_ATTACHMENT | + GPUTextureUsage.COPY_DST | + GPUTextureUsage.COPY_SRC, + }); + + textureData.forEach(({src}, layer) => { + device.queue.writeTexture( + { texture, origin: [0, 0, layer] }, + src, + { bytesPerRow: kTextureWidth * 4 }, + { width: kTextureWidth, height: kTextureHeight }, + ); + }); + generateMipmap(device, texture, textureOptions.textureBindingViewDimension === 'cube' ? 'cube' : undefined); + + const results = await Promise.all(textureData.map((_, layer) => readTextureUnpadded(device, texture, 2, layer))); + + textureData.forEach(({expected}, layer) => { + assertArrayEqualApproximately(results[layer], expected, 1, `for layer: ${layer}`); + }); + + } + + it('generates mipmaps 1 layer', testWithDeviceWithOptions(options, async device => { + await testGenerateMipmap(device, layerData.slice(0, 1)); + })); + + it('generates mipmaps 3 layers', testWithDeviceWithOptions(options, async device => { + await testGenerateMipmap(device, layerData.slice(0, 3)); + })); + + it('generates mipmaps 6 layers (cube)', testWithDeviceWithOptions(options, async device => { + await testGenerateMipmap(device, layerData.slice(0, 6), { textureBindingViewDimension: 'cube' }); + })); + + it('generates mipmaps 6 layers (2d-array)', testWithDeviceWithOptions(options, async device => { + await testGenerateMipmap(device, layerData.slice(0, 6), { textureBindingViewDimension: '2d-array' }); + })); + + it('generates mipmaps 12 layers (cube-array)', testWithDeviceWithOptions(options, async device => { + if (options.compatibilityMode) { + // no cube-array in compat + return; + } + await testGenerateMipmap.call(this, + device, + [ + ...layerData.slice(0, 6), + ...layerData.slice(0, 6).reverse(), + ], ); - }); - generateMipmap(device, texture); + })); + + }); + } - const results = await Promise.all(textureData.map((_, layer) => readTextureUnpadded(device, texture, 2, layer))); + test(false); + test(true); - textureData.forEach(({expected}, layer) => { - assertArrayEqualApproximately(results[layer], expected, 1, `for layer: ${layer}`); - }); - })); }); diff --git a/test/webgpu.js b/test/webgpu.js index 0bb6040..0295ab1 100644 --- a/test/webgpu.js +++ b/test/webgpu.js @@ -30,9 +30,9 @@ GPUDevice.prototype.createBuffer = (function (origFn) { }; })(GPUDevice.prototype.createBuffer); -export function testWithDevice(fn, ...args) { +export function testWithDeviceWithOptions(options, fn, ...args) { return async function () { - const adapter = await globalThis?.navigator?.gpu?.requestAdapter(); + const adapter = await globalThis?.navigator?.gpu?.requestAdapter(options); const device = await adapter?.requestDevice(); if (!device) { this.skip(); @@ -42,7 +42,7 @@ export function testWithDevice(fn, ...args) { let caughtErr; try { - await fn(device, ...args); + await fn.call(this, device, ...args); } catch (e) { caughtErr = e; } finally { @@ -57,6 +57,12 @@ export function testWithDevice(fn, ...args) { }; } +export function testWithDevice(fn, ...args) { + return async function () { + await testWithDeviceWithOptions({}, fn, ...args).call(this); + }; +} + export function testWithDeviceAndDocument(fn) { return async function () { if (typeof document === 'undefined') {