diff --git a/apps/typegpu-docs/src/content/docs/apis/textures.mdx b/apps/typegpu-docs/src/content/docs/apis/textures.mdx index a14907fc26..6f7faf586b 100644 --- a/apps/typegpu-docs/src/content/docs/apis/textures.mdx +++ b/apps/typegpu-docs/src/content/docs/apis/textures.mdx @@ -10,28 +10,27 @@ We assume that you are familiar with the following concepts: - Storage Textures ::: -In a similar fashion to buffers, textures provide a way to store and manage data on the GPU. They allow for both read and write access from WGSL shaders, and can also be sampled in the case of sampled textures. The main advantage of using textures over buffers is their optimized memory layout for spatial data, which can lead to better performance in certain scenarios as well as additional functionality such as filtering and mipmapping. +Textures store spatial data on the GPU. They can be sampled, used as render targets, exposed as storage textures, and mipmapped. -TypeGPU textures serve as a wrapper that provides type safety and higher level utilities (such as automatic mipmap generation). They also allow - in a similar way to buffers - for fixed resource creation that can be used directly in shaders without the need for manual bind group management. +TypeGPU wraps WebGPU textures with typed views, usage checks, and helpers for common writes. Let's look at an example of creating and using a typed texture. ```ts twoslash -import tgpu, { d } from 'typegpu'; +import tgpu from 'typegpu'; const root = await tgpu.init(); -const texture = root.createTexture({ - size: [256, 256], - format: 'rgba8unorm' as const, -}).$usage('sampled'); - const response = await fetch('path/to/image.png'); const blob = await response.blob(); -const image = await createImageBitmap(blob); +const imageBitmap = await createImageBitmap(blob); -// Uploading image data to the texture (will be resampled if sizes differ) -texture.write(image); +const texture = root.createTexture({ + size: [imageBitmap.width, imageBitmap.height], + format: 'rgba8unorm' as const, +}).$usage('sampled', 'render'); + +texture.write(imageBitmap); // Creating a view to use in shader const sampledView = texture.createView(); @@ -80,7 +79,7 @@ const texture = root.createTexture({ }) .$usage('sampled') // Can be sampled in shaders .$usage('storage') // Can be written or read to as storage texture - .$usage('render'); // Can be used as a render target + .$usage('render'); // Can be used as a render target or image upload target ``` You can also add multiple flags at once: @@ -97,77 +96,113 @@ const texture = root.createTexture({ ## Writing to a texture -The `.write()` method provides multiple overloads for different data sources: +Most texture writes are one of: decoded images, blobs, image arrays, raw bytes, regions, or channel writes. + +| Source | Use when | +| --- | --- | +| `texture.write(imageBitmap)` | You already have a decoded image with the right size. | +| `texture.write(imageBitmap, { resize: true })` | The decoded image should be resized into the texture. | +| `texture.writeAsync({ source: blob, ... })` | You have a fetched `Blob` and do not need to keep the decoded bitmap. | +| `texture.write([layer0, layer1])` | You want to fill a texture array or 3D texture one layer at a time. | +| `texture.write(bytes)` | You already have raw texel bytes. | +| `texture.write({ source, ... })` | You need a crop, destination region, or channel write. | + +Image writes require `.$usage('render')`. Source and destination sizes must match unless you pass `resize: true`. + +### Exact-size image upload + +```ts twoslash +import tgpu from 'typegpu'; +const root = await tgpu.init(); +const response = await fetch('path/to/image.png'); +const blob = await response.blob(); +const imageBitmap = await createImageBitmap(blob); +// ---cut--- +const texture = root.createTexture({ + size: [imageBitmap.width, imageBitmap.height], + format: 'rgba8unorm', +}).$usage('sampled', 'render'); + +texture.write(imageBitmap); +``` + +:::caution[Browser support] +`GPUCopyExternalImageSource` also includes canvas, video, `ImageData`, `HTMLImageElement`, and `VideoFrame`, but browser support varies. Decode fetched images to `ImageBitmap` for portable code. +::: + +### Resizing decoded images ```ts -// Image sources (single or array) -write(source: ExternalImageSource | ExternalImageSource[]): void +const texture = root.createTexture({ + size: [512, 512], + format: 'rgba8unorm', +}).$usage('sampled', 'render'); + +texture.write(imageBitmap, { resize: true }); -// Raw binary data with optional mip level -write(source: ArrayBuffer | TypedArray | DataView, mipLevel?: number): void +texture.write({ + source: imageBitmap, + sourceOrigin: [16, 16], + sourceSize: [128, 128], + size: [256, 256], + resize: true, +}); ``` -### Writing image data +:::note +Resizing decoded images uses TypeGPU's render-pass blit path, so the destination must be a 2D texture with a renderable, float-sampleable format. +::: + +### Blob helper -You can write various image sources to textures. `ExternalImageSource` includes: -- `HTMLCanvasElement` -- `HTMLImageElement` -- `HTMLVideoElement` -- `ImageBitmap` -- `ImageData` -- `OffscreenCanvas` -- `VideoFrame` +Use `writeAsync` when you have a `Blob` and do not need to keep the decoded `ImageBitmap`. With `resize: true`, TypeGPU passes resize options to `createImageBitmap` before uploading. ```ts twoslash import tgpu from 'typegpu'; const root = await tgpu.init(); // ---cut--- const texture = root.createTexture({ - size: [256, 256], + size: [512, 512], format: 'rgba8unorm', -}).$usage('sampled'); +}).$usage('sampled', 'render'); -// From an ImageBitmap -const response = await fetch('path/to/image.png'); -const blob = await response.blob(); -const imageBitmap = await createImageBitmap(blob); -texture.write(imageBitmap); +const blob = await (await fetch('path/to/image.png')).blob(); -// From an HTMLCanvasElement -const canvas = document.createElement('canvas'); -const ctx = canvas.getContext('2d'); -// ... draw on canvas -texture.write(canvas); +await texture.writeAsync({ + source: blob, + size: [512, 512], + resize: true, +}); ``` -:::tip -If image dimensions don't match the texture size, the image will be automatically resampled to fit (requires 'render' usage). -::: +The resulting bitmap is still written as an image source, so the texture needs `.$usage('render')`. -### Writing arrays of images +### Texture arrays and 3D textures -For 3D textures or texture arrays, you can write multiple images: +Pass an array of images to write one image per layer. ```ts twoslash import tgpu from 'typegpu'; const root = await tgpu.init(); -declare const imageBitmap1: ImageBitmap; -declare const imageBitmap2: ImageBitmap; -declare const imageBitmap3: ImageBitmap; +declare const layer0: ImageBitmap; +declare const layer1: ImageBitmap; +declare const layer2: ImageBitmap; // ---cut--- -const texture3d = root.createTexture({ +const texture = root.createTexture({ size: [256, 256, 3], format: 'rgba8unorm', - dimension: '3d', -}).$usage('sampled'); +}).$usage('sampled', 'render'); -// Write array of images for each layer -texture3d.write([imageBitmap1, imageBitmap2, imageBitmap3]); +texture.write([layer0, layer1, layer2]); ``` +Each image must match the layer size unless you pass `{ resize: true }`. + +For 3D textures, add `dimension: '3d'`; the same `write([layer0, layer1, ...])` form writes depth slices. + ### Writing raw binary data -You can write raw binary data directly to textures using `ArrayBuffer`, typed arrays, or `DataView`: +Use raw bytes when you already have texel data in CPU memory. ```ts twoslash import tgpu from 'typegpu'; @@ -176,6 +211,7 @@ const root = await tgpu.init(); const texture = root.createTexture({ size: [2, 2], format: 'rgba8unorm', + mipLevelCount: 2, }).$usage('sampled'); // Using Uint8Array for RGBA data (4 pixels, 4 bytes each) @@ -188,29 +224,48 @@ const data = new Uint8Array([ texture.write(data); // Write to a specific mip level -const mipData = new Uint8Array(4 * 128 * 128); // Data for 128x128 +const mipData = new Uint8Array(4); // Data for 1 pixel texture.write(mipData, 1); // Write to mip level 1 ``` -You can also copy from another texture: +Raw writes do not need `.$usage('render')`, but the byte length must exactly match the texture format, size, and mip level. + +### Regions and channel writes + +Use an object descriptor for source crops, destination regions, or selected channels. ```ts twoslash import tgpu from 'typegpu'; const root = await tgpu.init(); +declare const imageBitmap: ImageBitmap; +declare const roughnessMap: ImageBitmap; +declare const metalnessMap: ImageBitmap; +declare const maskMap: ImageBitmap; // ---cut--- -const sourceTexture = root.createTexture({ - size: [256, 256], +const texture = root.createTexture({ + size: [512, 512], format: 'rgba8unorm', -}).$usage('sampled'); +}).$usage('sampled', 'render'); -const targetTexture = root.createTexture({ - size: [256, 256], - format: 'rgba8unorm', -}).$usage('sampled'); +texture.write({ + source: imageBitmap, + sourceOrigin: [16, 16], + sourceSize: [128, 128], + origin: [128, 64], + size: [128, 128], +}); -targetTexture.copyFrom(sourceTexture); +texture.write({ + channels: { + r: roughnessMap, + g: metalnessMap, + a: { source: maskMap, from: 'r' }, + }, +}); ``` +Region and channel writes follow the same render usage and resize rules as other image writes. + ### Mipmaps TypeGPU provides automatic mipmap generation for textures: @@ -234,6 +289,8 @@ texture.generateMipmaps(); // Generate all mip levels automatically The `generateMipmaps()` method requires both `'sampled'` and `'render'` usage flags, as TypeGPU runs a downsampling pipeline behind the scenes to generate the mip levels. ::: +You can also copy between equal-sized, same-format textures with `targetTexture.copyFrom(sourceTexture)`. + ## Texture views To create a view - which will also serve as fixed texture usage - you can use one of the available [texture schemas](/TypeGPU/apis/data-schemas/#textures). You can pass it to the `.createView` method of the texture. @@ -254,7 +311,7 @@ const sampledView = texture.createView(d.texture2d(d.f32)); ``` :::tip -If type information is available the view schema will be staticly checked against the texture properties. +If type information is available the view schema will be statically checked against the texture properties. ```ts twoslash import tgpu, { d } from 'typegpu'; diff --git a/apps/typegpu-docs/src/examples/algorithms/genetic-racing/index.ts b/apps/typegpu-docs/src/examples/algorithms/genetic-racing/index.ts index ee42d2f645..ea6819263c 100644 --- a/apps/typegpu-docs/src/examples/algorithms/genetic-racing/index.ts +++ b/apps/typegpu-docs/src/examples/algorithms/genetic-racing/index.ts @@ -66,7 +66,7 @@ const carSpriteTexture = root format: 'rgba8unorm', }) .$usage('sampled', 'render'); -carSpriteTexture.write(carBitmap); +carSpriteTexture.write(carBitmap, { resize: true }); const carSpriteView = carSpriteTexture.createView(); const linearSampler = root.createSampler({ diff --git a/apps/typegpu-docs/src/examples/simulation/stable-fluid/index.ts b/apps/typegpu-docs/src/examples/simulation/stable-fluid/index.ts index 420c81d294..afacecbd05 100644 --- a/apps/typegpu-docs/src/examples/simulation/stable-fluid/index.ts +++ b/apps/typegpu-docs/src/examples/simulation/stable-fluid/index.ts @@ -13,7 +13,6 @@ import { defineControls } from '../../common/defineControls.ts'; // Initialize const root = await tgpu.init(); -const device = root.device; // Setup canvas const canvas = document.querySelector('canvas') as HTMLCanvasElement; @@ -86,20 +85,12 @@ let brushState: BrushState = { // Load and create background texture const response = await fetch('/TypeGPU/plums.jpg'); -const plums = await createImageBitmap(await response.blob(), { - resizeWidth: p.N, - resizeHeight: p.N, - resizeQuality: 'high', -}); +const plums = await response.blob(); const backgroundTexture = root .createTexture({ size: [p.N, p.N], format: 'rgba8unorm' }) .$usage('sampled', 'render'); -device.queue.copyExternalImageToTexture( - { source: plums }, - { texture: root.unwrap(backgroundTexture) }, - { width: p.N, height: p.N, depthOrArrayLayers: 1 }, -); +await backgroundTexture.writeAsync({ source: plums, size: [p.N, p.N], resize: true }); // Create simulation textures const velTex = [createField('velocity0'), createField('velocity1')]; diff --git a/apps/typegpu-docs/src/examples/tests/texture-test/index.ts b/apps/typegpu-docs/src/examples/tests/texture-test/index.ts index 9b0f7efccf..df900dea65 100644 --- a/apps/typegpu-docs/src/examples/tests/texture-test/index.ts +++ b/apps/typegpu-docs/src/examples/tests/texture-test/index.ts @@ -1,4 +1,4 @@ -import tgpu, { common, d } from 'typegpu'; +import tgpu, { common, d, std } from 'typegpu'; import { defineControls } from '../../common/defineControls.ts'; const root = await tgpu.init(); @@ -6,7 +6,8 @@ const canvas = document.querySelector('canvas') as HTMLCanvasElement; const context = root.configureContext({ canvas }); const presentationFormat = navigator.gpu.getPreferredCanvasFormat(); -const imageBitmap = await createImageBitmap(await (await fetch('/TypeGPU/plums.jpg')).blob()); +const imageBlob = await (await fetch('/TypeGPU/plums.jpg')).blob(); +const imageBitmap = await createImageBitmap(imageBlob); const testFormats = [ 'rgba8unorm', @@ -20,14 +21,7 @@ const testFormats = [ ] as const; type TestFormat = (typeof testFormats)[number]; - -const sizePresets = { - Original: [imageBitmap.width, imageBitmap.height], - '256x256': [256, 256], - '512x512': [512, 512], - '1024x1024': [1024, 1024], - '300x500': [300, 500], -} as const; +type WriteKind = 'resize' | 'crop' | 'blob' | 'greenToRed' | 'clear'; const hasFloat32Filterable = root.device.features.has('float32-filterable'); @@ -36,7 +30,11 @@ function isFilterable(format: GPUTextureFormat): boolean { } let currentFormat: TestFormat = 'rgba16float'; -let currentSize = sizePresets.Original; +let currentSize: readonly [number, number] = [imageBitmap.width, imageBitmap.height]; +let cropX = 25; +let cropY = 25; +let cropSize = 50; +let activeWrite: WriteKind = 'resize'; function calculateMipLevels(width: number, height: number): number { return Math.floor(Math.log2(Math.min(width, height))) + 1; @@ -88,20 +86,15 @@ function createPipelineForFormat(format: TestFormat) { const fragmentFunction = tgpu.fragmentFn({ in: { uv: d.vec2f }, out: d.vec4f, - })`{ - let color = textureSampleBias(layout.$.myTexture, sampler, in.uv, bias); + })(({ uv }) => { + const color = std.textureSampleBias(layout.$.myTexture, sampler.$, uv, biasUniform.$); - if (channel == 1) { return vec4f(color.rrr, 1.0); } - if (channel == 2) { return vec4f(color.ggg, 1.0); } - if (channel == 3) { return vec4f(color.bbb, 1.0); } - if (channel == 4) { return vec4f(color.aaa, 1.0); } + if (channelUniform.$ === 1) return d.vec4f(color.rrr, 1); + if (channelUniform.$ === 2) return d.vec4f(color.ggg, 1); + if (channelUniform.$ === 3) return d.vec4f(color.bbb, 1); + if (channelUniform.$ === 4) return d.vec4f(color.aaa, 1); return color; - }`.$uses({ - layout, - sampler, - bias: biasUniform, - channel: channelUniform, }); const pipeline = root.createRenderPipeline({ @@ -114,20 +107,90 @@ function createPipelineForFormat(format: TestFormat) { } let texture = createTestTexture(currentFormat, currentSize); -texture.write(imageBitmap); -texture.generateMipmaps(); + +function cropRect() { + const sourceSize: readonly [number, number] = [ + Math.max(1, Math.floor((imageBitmap.width * cropSize) / 100)), + Math.max(1, Math.floor((imageBitmap.height * cropSize) / 100)), + ]; + const sourceOrigin: readonly [number, number] = [ + Math.floor(((imageBitmap.width - sourceSize[0]) * cropX) / 100), + Math.floor(((imageBitmap.height - sourceSize[1]) * cropY) / 100), + ]; + return { sourceOrigin, sourceSize }; +} + +function writeResizedContent() { + texture.write(imageBitmap, { resize: true }); + texture.generateMipmaps(); +} + +function writeCroppedContent() { + texture.write({ + source: imageBitmap, + ...cropRect(), + size: currentSize, + resize: true, + }); + texture.generateMipmaps(); +} + +async function writeBlobContent() { + const target = texture; + await target.writeAsync({ source: imageBlob, size: currentSize, resize: true }); + if (!target.destroyed) { + target.generateMipmaps(); + } +} + +function writeGreenToRedContent() { + texture.clear(); + texture.write({ + channels: { + r: { source: imageBitmap, from: 'g' }, + }, + size: currentSize, + resize: true, + }); + texture.generateMipmaps(); +} + +function clearContent() { + texture.clear(); +} + +async function writeActiveContent() { + if (activeWrite === 'resize') { + writeResizedContent(); + } else if (activeWrite === 'crop') { + writeCroppedContent(); + } else if (activeWrite === 'blob') { + await writeBlobContent(); + } else if (activeWrite === 'greenToRed') { + writeGreenToRedContent(); + } else { + clearContent(); + } +} + +await writeActiveContent(); let { layout, pipeline } = createPipelineForFormat(currentFormat); let bindGroup = root.createBindGroup(layout, { myTexture: texture }); function recreateTexture() { + texture.destroy(); texture = createTestTexture(currentFormat, currentSize); - texture.write(imageBitmap); - texture.generateMipmaps(); ({ layout, pipeline } = createPipelineForFormat(currentFormat)); bindGroup = root.createBindGroup(layout, { myTexture: texture }); } +function write(writeKind = activeWrite) { + activeWrite = writeKind; + recreateTexture(); + return writeActiveContent(); +} + function render() { pipeline.with(bindGroup).withColorAttachment({ view: context }).draw(3); @@ -141,24 +204,65 @@ export const controls = defineControls({ options: [...testFormats], onSelectChange: (value) => { currentFormat = value; - recreateTexture(); + void write(); }, }, - Size: { - initial: 'Original', - options: [...Object.keys(sizePresets), 'Random'], - onSelectChange: (value) => { - if (value === 'Random') { - const randomWidth = Math.floor(Math.random() * 1024) + 64; - const randomHeight = Math.floor(Math.random() * 1024) + 64; - currentSize = [randomWidth, randomHeight]; - console.log(`Random size selected: ${randomWidth}x${randomHeight}`); - } else { - currentSize = sizePresets[value as keyof typeof sizePresets] as readonly [number, number]; - } - recreateTexture(); + 'Target width': { + initial: currentSize[0], + min: 64, + max: 1024, + step: 1, + onSliderChange: (value) => { + currentSize = [Math.round(value), currentSize[1]]; + void write(); + }, + }, + 'Target height': { + initial: currentSize[1], + min: 64, + max: 1024, + step: 1, + onSliderChange: (value) => { + currentSize = [currentSize[0], Math.round(value)]; + void write(); + }, + }, + 'Crop X': { + initial: cropX, + min: 0, + max: 100, + step: 1, + onSliderChange: (value) => { + cropX = value; + void write('crop'); + }, + }, + 'Crop Y': { + initial: cropY, + min: 0, + max: 100, + step: 1, + onSliderChange: (value) => { + cropY = value; + void write('crop'); }, }, + 'Crop size': { + initial: cropSize, + min: 1, + max: 100, + step: 1, + onSliderChange: (value) => { + cropSize = value; + void write('crop'); + }, + }, + 'Write blob': { + onButtonClick: () => write('blob'), + }, + 'Write G->R': { + onButtonClick: () => write('greenToRed'), + }, Channel: { initial: 'RGBA', options: ['RGBA', 'R', 'G', 'B', 'A'], @@ -174,7 +278,7 @@ export const controls = defineControls({ onSliderChange: (value) => biasUniform.write(value), }, Clear: { - onButtonClick: () => texture.clear(), + onButtonClick: () => write('clear'), }, }); diff --git a/apps/typegpu-docs/tests/individual-example-tests/utils/baseTest.ts b/apps/typegpu-docs/tests/individual-example-tests/utils/baseTest.ts index b03bd1a100..666bd16c92 100644 --- a/apps/typegpu-docs/tests/individual-example-tests/utils/baseTest.ts +++ b/apps/typegpu-docs/tests/individual-example-tests/utils/baseTest.ts @@ -2,6 +2,7 @@ * @vitest-environment jsdom */ +import tgpu from 'typegpu'; import { setupCommonMocks } from './commonMocks.ts'; import { extractShaderCodes, @@ -10,6 +11,24 @@ import { waitForExpectedCalls, } from './testUtils.ts'; +let resizePatched = false; + +function allowTextureResize(device: GPUDevice) { + if (resizePatched) { + return; + } + resizePatched = true; + + const root = tgpu.initFromDevice({ device }); + const proto = Object.getPrototypeOf( + root.createTexture({ size: [1, 1], format: 'rgba8unorm' }), + ) as { write: (source: unknown, options?: object) => unknown }; + const originalWrite = proto.write; + proto.write = function (this: unknown, source: unknown, options?: object) { + return originalWrite.call(this, source, { ...options, resize: true }); + }; +} + export interface ExampleTestConfig { category: string; name: string; @@ -27,6 +46,8 @@ export async function runExampleTest( config.setupMocks(); } + allowTextureResize(device); + const urls = getExampleURLs(config.category, config.name); const html = await import(urls.html); document.body.innerHTML = html.default; diff --git a/apps/typegpu-docs/tests/individual-example-tests/utils/commonMocks.ts b/apps/typegpu-docs/tests/individual-example-tests/utils/commonMocks.ts index 198e8baea7..9dee271cc2 100644 --- a/apps/typegpu-docs/tests/individual-example-tests/utils/commonMocks.ts +++ b/apps/typegpu-docs/tests/individual-example-tests/utils/commonMocks.ts @@ -91,16 +91,18 @@ export function mockResizeObserver() { } export function mockCreateImageBitmap({ width = 2, height = 2 } = {}) { - vi.stubGlobal('createImageBitmap', async () => { + vi.stubGlobal('createImageBitmap', async (_source: unknown, options?: ImageBitmapOptions) => { + const w = options?.resizeWidth ?? width; + const h = options?.resizeHeight ?? height; return { - width, - height, + width: w, + height: h, close: vi.fn(), getImageData: () => { return { data: new Uint8ClampedArray([0, 0, 0, 255, 255, 255, 255, 255]), - width, - height, + width: w, + height: h, }; }, } as ImageBitmap; diff --git a/packages/typegpu-testing-utility/src/extendedIt.ts b/packages/typegpu-testing-utility/src/extendedIt.ts index 00ba17e02c..624b561218 100644 --- a/packages/typegpu-testing-utility/src/extendedIt.ts +++ b/packages/typegpu-testing-utility/src/extendedIt.ts @@ -28,7 +28,9 @@ export const it = base end: vi.fn(), setBindGroup: vi.fn(), setPipeline: vi.fn(), + setScissorRect: vi.fn(), setVertexBuffer: vi.fn(), + setViewport: vi.fn(), setIndexBuffer: vi.fn(), setStencilReference: vi.fn(), executeBundles: vi.fn(), diff --git a/packages/typegpu-testing-utility/src/webgpuGlobals.ts b/packages/typegpu-testing-utility/src/webgpuGlobals.ts index 0a8be4e35a..232226b9b0 100644 --- a/packages/typegpu-testing-utility/src/webgpuGlobals.ts +++ b/packages/typegpu-testing-utility/src/webgpuGlobals.ts @@ -30,3 +30,11 @@ globalThis.GPUShaderStage = { FRAGMENT: 2, COMPUTE: 4, }; + +globalThis.GPUColorWrite = { + RED: 1, + GREEN: 2, + BLUE: 4, + ALPHA: 8, + ALL: 15, +}; diff --git a/packages/typegpu/src/core/texture/texture.ts b/packages/typegpu/src/core/texture/texture.ts index 914f3ac4a0..98111d882c 100644 --- a/packages/typegpu/src/core/texture/texture.ts +++ b/packages/typegpu/src/core/texture/texture.ts @@ -26,7 +26,27 @@ import type { ExperimentalTgpuRoot } from '../root/rootTypes.ts'; import { valueProxyHandler } from '../valueProxyUtils.ts'; import type { TextureProps } from './textureProps.ts'; import type { AllowedUsages, LiteralToExtensionMap } from './usageExtension.ts'; -import { generateTextureMipmaps, getImageSourceDimensions, resampleImage } from './textureUtils.ts'; +import { + copyImageToTexture, + generateTextureMipmaps, + resampleImage, + writeTextureChannel, +} from './textureUtils.ts'; +import { + createBitmapForBlobWrite, + expandChannelWrites, + imageWriteForLayer, + isTextureChannelWrite, + isTextureImageWrite, + needsResize, + normalizeImageWrite, + textureLayerSize, + validateResizeAllowed, + type TextureBlobWrite, + type TextureChannelWrite, + type TextureImageWrite, + type TextureResizeOptions, +} from './textureWrite.ts'; export type TextureInternals = { unwrap(): GPUTexture; @@ -39,15 +59,13 @@ type TextureViewInternals = { // Public API export type TexelData = Vec4u | Vec4i | Vec4f; - -export type ExternalImageSource = - | HTMLCanvasElement - | HTMLImageElement - | HTMLVideoElement - | ImageBitmap - | ImageData - | OffscreenCanvas - | VideoFrame; +export type { + TextureBlobWrite, + TextureChannel, + TextureChannelWrite, + TextureImageWrite, + TextureWriteOptions, +} from './textureWrite.ts'; type TgpuTextureViewDescriptor = { /** @@ -127,6 +145,10 @@ type CopyCompatibleTexture = TgpuTexture<{ sampleCount?: T['sampleCount']; }>; +function isBlob(value: Blob | TextureBlobWrite): value is Blob { + return typeof Blob !== 'undefined' && value instanceof Blob; +} + // oxlint-disable-next-line typescript/no-explicit-any -- we can't tame the validation otherwise export interface TgpuTexture extends TgpuNamable { readonly [$internal]: TextureInternals; @@ -162,8 +184,14 @@ export interface TgpuTexture extends TgpuNama clear(mipLevel?: number | 'all'): void; generateMipmaps(baseMipLevel?: number, mipLevels?: number): void; - write(source: ExternalImageSource | ExternalImageSource[]): void; + write( + source: GPUCopyExternalImageSource | GPUCopyExternalImageSource[], + options?: TextureResizeOptions, + ): void; + write(source: TextureImageWrite): void; + write(source: TextureChannelWrite): void; write(source: ArrayBuffer | TypedArray | DataView, mipLevel?: number): void; + writeAsync(source: Blob | TextureBlobWrite): Promise; // TODO: support copies from GPUBuffers and TgpuBuffers copyFrom>(source: T): void; @@ -377,22 +405,47 @@ class TgpuTextureImpl implements TgpuTexture implements TgpuTexture { + const write = isBlob(source) ? { source, size: textureLayerSize(this.props.size) } : source; + const bitmap = await createBitmapForBlobWrite(write); + const { source: _source, resize: _resize, ...options } = write; + + try { + this.#writeImage({ ...options, source: bitmap, resize: false }); + } finally { + bitmap.close(); + } + } + #writeBufferData(source: ArrayBuffer | TypedArray | DataView, mipLevel: number) { const mipWidth = Math.max(1, (this.props.size[0] as number) >> mipLevel); const mipHeight = Math.max(1, (this.props.size[1] ?? 1) >> mipLevel); @@ -424,11 +489,10 @@ class TgpuTextureImpl implements TgpuTexture implements TgpuTexture implements TgpuTexture) { diff --git a/packages/typegpu/src/core/texture/textureUtils.ts b/packages/typegpu/src/core/texture/textureUtils.ts index 6de9ff6b75..698c6cc91f 100644 --- a/packages/typegpu/src/core/texture/textureUtils.ts +++ b/packages/typegpu/src/core/texture/textureUtils.ts @@ -1,32 +1,9 @@ import { getEffectiveSampleTypes, getTextureFormatInfo } from './textureFormats.ts'; -import type { ExternalImageSource } from './texture.ts'; - -export function getImageSourceDimensions(source: ExternalImageSource): { - width: number; - height: number; -} { - const { videoWidth, videoHeight } = source as HTMLVideoElement; - if (videoWidth && videoHeight) { - return { width: videoWidth, height: videoHeight }; - } - - const { naturalWidth, naturalHeight } = source as HTMLImageElement; - if (naturalWidth && naturalHeight) { - return { width: naturalWidth, height: naturalHeight }; - } - - const { codedWidth, codedHeight } = source as VideoFrame; - if (codedWidth && codedHeight) { - return { width: codedWidth, height: codedHeight }; - } - - const { width, height } = source as ImageBitmap; - if (width && height) { - return { width, height }; - } - - throw new Error('Cannot determine dimensions of the provided image source.'); -} +import type { + TextureChannel, + TextureChannelWriteLayout, + TextureImageWriteLayout, +} from './textureWrite.ts'; const FULLSCREEN_VERTEX_SHADER = ` struct VertexOutput { @@ -63,6 +40,16 @@ fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f { return vec4f(dot(r, vec4f(0.25)), dot(g, vec4f(0.25)), dot(b, vec4f(0.25)), dot(a, vec4f(0.25))); }`; +const CHANNEL_FRAGMENT_SHADER = (channel: TextureChannel) => ` +@group(0) @binding(0) var src: texture_2d; +@group(0) @binding(1) var samp: sampler; + +@fragment +fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f { + let value = textureSample(src, samp, uv).${channel}; + return vec4f(value); +}`; + type BlitResources = { vertexModule: GPUShaderModule; fragmentModule: GPUShaderModule; @@ -73,7 +60,9 @@ type BlitResources = { type DeviceCache = { vertexModule: GPUShaderModule; - filterableResources: Map; + filterableResources: Map; + channelModules: Map; + stagedTexture?: { key: string; texture: GPUTexture }; layoutResources: Map< string, { bindGroupLayout: GPUBindGroupLayout; pipelineLayout: GPUPipelineLayout } @@ -90,6 +79,7 @@ function getOrCreateDeviceCache(device: GPUDevice): DeviceCache { code: FULLSCREEN_VERTEX_SHADER, }), filterableResources: new Map(), + channelModules: new Map(), layoutResources: new Map(), }; blitCache.set(device, cache); @@ -97,22 +87,49 @@ function getOrCreateDeviceCache(device: GPUDevice): DeviceCache { return cache; } +function getChannelShaderModule(device: GPUDevice, channel: TextureChannel): GPUShaderModule { + const cache = getOrCreateDeviceCache(device); + let module = cache.channelModules.get(channel); + if (!module) { + module = device.createShaderModule({ + code: CHANNEL_FRAGMENT_SHADER(channel), + }); + cache.channelModules.set(channel, module); + } + return module; +} + +function channelWriteMask(channel: TextureChannel): GPUColorWriteFlags { + switch (channel) { + case 'r': + return GPUColorWrite.RED; + case 'g': + return GPUColorWrite.GREEN; + case 'b': + return GPUColorWrite.BLUE; + case 'a': + return GPUColorWrite.ALPHA; + } +} + function getBlitResources( device: GPUDevice, filterable: boolean, sampleType: GPUTextureSampleType, + filter: GPUFilterMode = 'linear', ): BlitResources { const cache = getOrCreateDeviceCache(device); - let filterableRes = cache.filterableResources.get(filterable); + const filterableKey = `${filterable}:${filter}`; + let filterableRes = cache.filterableResources.get(filterableKey); if (!filterableRes) { filterableRes = { fragmentModule: device.createShaderModule({ code: filterable ? SAMPLE_FRAGMENT_SHADER : GATHER_FRAGMENT_SHADER, }), - sampler: device.createSampler(filterable ? { magFilter: 'linear', minFilter: 'linear' } : {}), + sampler: device.createSampler(filterable ? { magFilter: filter, minFilter: filter } : {}), }; - cache.filterableResources.set(filterable, filterableRes); + cache.filterableResources.set(filterableKey, filterableRes); } const layoutKey = `${filterable}:${sampleType}`; @@ -155,12 +172,15 @@ type BlitOptions = { format: GPUTextureFormat; filterable: boolean; sampleType: GPUTextureSampleType; + filter?: GPUFilterMode; encoder?: GPUCommandEncoder; + loadOp?: GPULoadOp; + viewport?: { x: number; y: number; width: number; height: number }; }; function blit(options: BlitOptions): void { const { device, source, destination, format, filterable, sampleType } = options; - const resources = getBlitResources(device, filterable, sampleType); + const resources = getBlitResources(device, filterable, sampleType, options.filter); const pipeline = device.createRenderPipeline({ layout: resources.pipelineLayout, @@ -184,11 +204,27 @@ function blit(options: BlitOptions): void { colorAttachments: [ { view: destination, - loadOp: 'clear', + loadOp: options.loadOp ?? 'clear', storeOp: 'store', }, ], }); + if (options.viewport) { + pass.setViewport( + options.viewport.x, + options.viewport.y, + options.viewport.width, + options.viewport.height, + 0, + 1, + ); + pass.setScissorRect( + options.viewport.x, + options.viewport.y, + options.viewport.width, + options.viewport.height, + ); + } pass.setPipeline(pipeline); pass.setBindGroup(0, bindGroup); pass.draw(3); @@ -199,8 +235,80 @@ function blit(options: BlitOptions): void { } } +function imageStagingFormat(format: GPUTextureFormat): GPUTextureFormat { + if (format === 'rgba8unorm-srgb' || format === 'bgra8unorm-srgb') { + return format; + } + + return 'rgba8unorm'; +} + +function getStagedImageTexture( + device: GPUDevice, + write: TextureImageWriteLayout, + targetFormat: GPUTextureFormat, +): GPUTexture { + const cache = getOrCreateDeviceCache(device); + const format = imageStagingFormat(targetFormat); + const key = `${format}:${write.sourceSize.width}x${write.sourceSize.height}`; + let inputTexture = cache.stagedTexture?.key === key ? cache.stagedTexture.texture : undefined; + + if (!inputTexture) { + cache.stagedTexture?.texture.destroy(); + inputTexture = device.createTexture({ + size: write.sourceSize, + format, + usage: + GPUTextureUsage.TEXTURE_BINDING | + GPUTextureUsage.COPY_DST | + GPUTextureUsage.RENDER_ATTACHMENT, + }); + cache.stagedTexture = { key, texture: inputTexture }; + } + + device.queue.copyExternalImageToTexture( + { + source: write.source, + ...((write.sourceOrigin.x !== 0 || write.sourceOrigin.y !== 0) && { + origin: write.sourceOrigin, + }), + ...(write.flipY !== undefined && { flipY: write.flipY }), + }, + { texture: inputTexture }, + write.sourceSize, + ); + + return inputTexture; +} + export function clearTextureUtilsCache(device: GPUDevice): void { - blitCache.delete(device); + const cache = blitCache.get(device); + if (cache) { + cache.stagedTexture?.texture.destroy(); + blitCache.delete(device); + } +} + +export function copyImageToTexture( + device: GPUDevice, + texture: GPUTexture, + write: TextureImageWriteLayout, +): void { + device.queue.copyExternalImageToTexture( + { + source: write.source, + ...((write.sourceOrigin.x !== 0 || write.sourceOrigin.y !== 0) && { + origin: write.sourceOrigin, + }), + ...(write.flipY !== undefined && { flipY: write.flipY }), + }, + { + texture, + mipLevel: write.mipLevel, + origin: write.targetOrigin, + }, + write.targetSize, + ); } function validateBlitFormat( @@ -235,6 +343,30 @@ function validateBlitFormat( }; } +function targetViewDescriptor(write: TextureImageWriteLayout): GPUTextureViewDescriptor { + return { + dimension: '2d', + baseMipLevel: write.mipLevel, + mipLevelCount: 1, + baseArrayLayer: write.targetOrigin.z, + arrayLayerCount: 1, + }; +} + +function targetViewport(write: TextureImageWriteLayout): { + x: number; + y: number; + width: number; + height: number; +} { + return { + x: write.targetOrigin.x, + y: write.targetOrigin.y, + width: write.targetSize.width, + height: write.targetSize.height, + }; +} + export function generateTextureMipmaps( device: GPUDevice, texture: GPUTexture, @@ -273,63 +405,79 @@ export function generateTextureMipmaps( export function resampleImage( device: GPUDevice, targetTexture: GPUTexture, - image: ExternalImageSource, - layer = 0, + write: TextureImageWriteLayout, ): void { if (targetTexture.dimension !== '2d') { throw new Error('Resampling only supports 2D textures.'); } const { filterable } = validateBlitFormat(device, targetTexture.format, 'resample'); - const { width, height } = getImageSourceDimensions(image); - - const inputTexture = device.createTexture({ - size: [width, height], - format: 'rgba8unorm', - usage: - GPUTextureUsage.TEXTURE_BINDING | - GPUTextureUsage.COPY_DST | - GPUTextureUsage.RENDER_ATTACHMENT, - }); - - device.queue.copyExternalImageToTexture( - { source: image }, - { - texture: inputTexture, - }, - [width, height], - ); - - const renderTexture = device.createTexture({ - size: [targetTexture.width, targetTexture.height], - format: targetTexture.format, - usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC, - }); + const inputTexture = getStagedImageTexture(device, write, targetTexture.format); const encoder = device.createCommandEncoder(); blit({ device, source: inputTexture.createView(), - destination: renderTexture.createView(), + destination: targetTexture.createView(targetViewDescriptor(write)), format: targetTexture.format, filterable, - sampleType: 'float', // Input is always rgba8unorm which is filterable + sampleType: 'float', + ...(write.filter !== undefined && { filter: write.filter }), encoder, + loadOp: 'load', + viewport: targetViewport(write), }); - encoder.copyTextureToTexture( - { texture: renderTexture }, - { texture: targetTexture, origin: { x: 0, y: 0, z: layer } }, - { - width: targetTexture.width, - height: targetTexture.height, - depthOrArrayLayers: 1, + device.queue.submit([encoder.finish()]); +} + +export function writeTextureChannel( + device: GPUDevice, + targetTexture: GPUTexture, + write: TextureChannelWriteLayout, +): void { + if (targetTexture.dimension !== '2d') { + throw new Error('Channel writes only support 2D textures.'); + } + + const { filterable } = validateBlitFormat(device, targetTexture.format, 'write channels'); + const inputTexture = getStagedImageTexture(device, write, targetTexture.format); + const resources = getBlitResources(device, filterable, 'float', write.filter); + const pipeline = device.createRenderPipeline({ + layout: resources.pipelineLayout, + vertex: { module: resources.vertexModule }, + fragment: { + module: getChannelShaderModule(device, write.from), + targets: [{ format: targetTexture.format, writeMask: channelWriteMask(write.to) }], }, - ); + primitive: { topology: 'triangle-list' }, + }); + const bindGroup = device.createBindGroup({ + layout: resources.bindGroupLayout, + entries: [ + { binding: 0, resource: inputTexture.createView() }, + { binding: 1, resource: resources.sampler }, + ], + }); + const encoder = device.createCommandEncoder(); - device.queue.submit([encoder.finish()]); + const pass = encoder.beginRenderPass({ + colorAttachments: [ + { + view: targetTexture.createView(targetViewDescriptor(write)), + loadOp: 'load', + storeOp: 'store', + }, + ], + }); + const viewport = targetViewport(write); + pass.setViewport(viewport.x, viewport.y, viewport.width, viewport.height, 0, 1); + pass.setScissorRect(viewport.x, viewport.y, viewport.width, viewport.height); + pass.setPipeline(pipeline); + pass.setBindGroup(0, bindGroup); + pass.draw(3); + pass.end(); - inputTexture.destroy(); - renderTexture.destroy(); + device.queue.submit([encoder.finish()]); } diff --git a/packages/typegpu/src/core/texture/textureWrite.ts b/packages/typegpu/src/core/texture/textureWrite.ts new file mode 100644 index 0000000000..01a6b734f9 --- /dev/null +++ b/packages/typegpu/src/core/texture/textureWrite.ts @@ -0,0 +1,283 @@ +type TextureSourceSize = readonly [number, number] | Pick; + +export type TextureWriteOptions = { + mipLevel?: GPUIntegerCoordinate; + origin?: GPUOrigin3D; + size?: GPUExtent3D; + sourceOrigin?: GPUOrigin2D; + sourceSize?: TextureSourceSize; + resize?: boolean; + filter?: GPUFilterMode; + flipY?: boolean; +}; + +export type TextureImageWrite = TextureWriteOptions & { + source: GPUCopyExternalImageSource; +}; + +export type TextureBlobWrite = Omit & { + source: Blob; +}; + +export type TextureChannel = 'r' | 'g' | 'b' | 'a'; + +type TextureChannelWriteSource = + | GPUCopyExternalImageSource + | { source: GPUCopyExternalImageSource; from?: TextureChannel }; + +export type TextureChannelWrite = TextureWriteOptions & { + channels: Partial>; +}; + +export type TextureResizeOptions = Pick; + +export type TextureImageWriteLayout = { + source: GPUCopyExternalImageSource; + sourceOrigin: { x: number; y: number }; + sourceSize: { width: number; height: number }; + targetOrigin: { x: number; y: number; z: number }; + targetSize: { width: number; height: number; depthOrArrayLayers: number }; + mipLevel: number; + filter?: GPUFilterMode; + flipY?: boolean; +}; + +export type TextureChannelWriteLayout = TextureImageWriteLayout & { + from: TextureChannel; + to: TextureChannel; +}; + +export type TextureChannelWriteEntry = { + from: TextureChannel; + to: TextureChannel; + write: TextureImageWrite; +}; + +const TEXTURE_CHANNELS = ['r', 'g', 'b', 'a'] as const satisfies readonly TextureChannel[]; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function hasSource(value: unknown): value is { source: unknown } { + return isRecord(value) && 'source' in value; +} + +function isIterable(value: unknown): value is Iterable { + return typeof value === 'object' && value !== null && Symbol.iterator in value; +} + +function isTextureChannel(value: string): value is TextureChannel { + return (TEXTURE_CHANNELS as readonly string[]).includes(value); +} + +function isChannelWriteImage( + value: TextureChannelWriteSource, +): value is { source: GPUCopyExternalImageSource; from?: TextureChannel } { + return hasSource(value); +} + +function origin2(value: TextureWriteOptions['sourceOrigin']): { x: number; y: number } { + if (isIterable(value)) { + const [x = 0, y = 0] = value; + return { x, y }; + } + + return { x: value?.x ?? 0, y: value?.y ?? 0 }; +} + +function origin3(value: TextureWriteOptions['origin']): { x: number; y: number; z: number } { + if (isIterable(value)) { + const [x = 0, y = 0, z = 0] = value; + return { x, y, z }; + } + + return { x: value?.x ?? 0, y: value?.y ?? 0, z: value?.z ?? 0 }; +} + +function size2( + value: TextureWriteOptions['sourceSize'], + fallback: { width: number; height: number }, +): { width: number; height: number } { + if (isIterable(value)) { + const [width = fallback.width, height = fallback.height] = value; + return { width, height }; + } + + return { width: value?.width ?? fallback.width, height: value?.height ?? fallback.height }; +} + +function size3( + value: TextureWriteOptions['size'], + fallback: { width: number; height: number; depthOrArrayLayers: number }, +): { width: number; height: number; depthOrArrayLayers: number } { + if (isIterable(value)) { + const [ + width = fallback.width, + height = fallback.height, + depthOrArrayLayers = fallback.depthOrArrayLayers, + ] = value; + return { + width, + height, + depthOrArrayLayers, + }; + } + + return { + width: value?.width ?? fallback.width, + height: value?.height ?? fallback.height, + depthOrArrayLayers: value?.depthOrArrayLayers ?? fallback.depthOrArrayLayers, + }; +} + +export function isTextureImageWrite(value: unknown): value is TextureImageWrite { + return hasSource(value); +} + +export function isTextureChannelWrite(value: unknown): value is TextureChannelWrite { + return isRecord(value) && 'channels' in value; +} + +export function textureLayerSize(size: readonly number[]): readonly [number, number, number] { + return [size[0] ?? 1, size[1] ?? 1, 1]; +} + +export function imageWriteForLayer( + source: GPUCopyExternalImageSource, + textureSize: readonly number[], + layer: number | undefined, + options: TextureResizeOptions, +): TextureImageWrite { + return { + source, + size: textureLayerSize(textureSize), + ...(layer !== undefined && { origin: [0, 0, layer] as const }), + ...options, + }; +} + +export function getImageSourceDimensions(source: GPUCopyExternalImageSource): { + width: number; + height: number; +} { + const { videoWidth, videoHeight } = source as HTMLVideoElement; + if (videoWidth && videoHeight) { + return { width: videoWidth, height: videoHeight }; + } + + const { naturalWidth, naturalHeight } = source as HTMLImageElement; + if (naturalWidth && naturalHeight) { + return { width: naturalWidth, height: naturalHeight }; + } + + const { codedWidth, codedHeight } = source as VideoFrame; + if (codedWidth && codedHeight) { + return { width: codedWidth, height: codedHeight }; + } + + const { width, height } = source as ImageBitmap; + if (width && height) { + return { width, height }; + } + + throw new Error('Cannot determine dimensions of the provided image source.'); +} + +export function normalizeImageWrite(write: TextureImageWrite): TextureImageWriteLayout { + const sourceOrigin = origin2(write.sourceOrigin); + const sourceDimensions = getImageSourceDimensions(write.source); + const sourceSize = size2(write.sourceSize, { + width: sourceDimensions.width - sourceOrigin.x, + height: sourceDimensions.height - sourceOrigin.y, + }); + const targetSize = size3(write.size, { + width: sourceSize.width, + height: sourceSize.height, + depthOrArrayLayers: 1, + }); + + if (targetSize.depthOrArrayLayers !== 1) { + throw new Error('Texture image writes can only write one layer at a time.'); + } + + return { + source: write.source, + sourceOrigin, + sourceSize, + targetOrigin: origin3(write.origin), + targetSize, + mipLevel: write.mipLevel ?? 0, + ...(write.filter !== undefined && { filter: write.filter }), + ...(write.flipY !== undefined && { flipY: write.flipY }), + }; +} + +export function needsResize(write: TextureImageWriteLayout): boolean { + return ( + write.sourceSize.width !== write.targetSize.width || + write.sourceSize.height !== write.targetSize.height + ); +} + +export function validateResizeAllowed( + write: TextureImageWrite, + normalized: TextureImageWriteLayout, +): void { + if (needsResize(normalized) && !write.resize) { + throw new Error( + `Texture write source size ${normalized.sourceSize.width}x${normalized.sourceSize.height} does not match target size ${normalized.targetSize.width}x${normalized.targetSize.height}. Pass resize: true to resize explicitly.`, + ); + } +} + +function channelImageWrite( + write: TextureChannelWrite, + source: GPUCopyExternalImageSource, +): TextureImageWrite { + const { channels: _channels, ...options } = write; + return { ...options, source }; +} + +export function expandChannelWrites(write: TextureChannelWrite): TextureChannelWriteEntry[] { + for (const key of Object.keys(write.channels)) { + if (!isTextureChannel(key)) { + throw new Error(`Texture channel writes only support single channels: r, g, b, a.`); + } + } + + return TEXTURE_CHANNELS.flatMap((to) => { + const entry = write.channels[to]; + if (!entry) { + return []; + } + + const entryWrite = isChannelWriteImage(entry) ? entry : { source: entry }; + const from = entryWrite.from ?? to; + + if (!isTextureChannel(from)) { + throw new Error(`Invalid source channel '${from}'. Expected one of r, g, b, a.`); + } + + return [{ from, to, write: channelImageWrite(write, entryWrite.source) }]; + }); +} + +export async function createBitmapForBlobWrite(write: TextureBlobWrite): Promise { + if (typeof createImageBitmap !== 'function') { + throw new Error('Texture writeAsync requires createImageBitmap to be available.'); + } + + const resizeTo = + write.resize && write.size + ? size3(write.size, { width: 1, height: 1, depthOrArrayLayers: 1 }) + : undefined; + const bitmapOptions = resizeTo + ? { + resizeWidth: resizeTo.width, + resizeHeight: resizeTo.height, + } + : undefined; + + return createImageBitmap(write.source, bitmapOptions); +} diff --git a/packages/typegpu/src/indexNamedExports.ts b/packages/typegpu/src/indexNamedExports.ts index 8a4ba4d469..051539aa18 100644 --- a/packages/typegpu/src/indexNamedExports.ts +++ b/packages/typegpu/src/indexNamedExports.ts @@ -79,6 +79,13 @@ export type { TgpuRawCodeSnippet, } from './core/rawCodeSnippet/tgpuRawCodeSnippet.ts'; export type { TgpuTexture, TgpuTextureView } from './core/texture/texture.ts'; +export type { + TextureBlobWrite, + TextureChannel, + TextureChannelWrite, + TextureImageWrite, + TextureWriteOptions, +} from './core/texture/textureWrite.ts'; export type { TextureProps } from './core/texture/textureProps.ts'; export type { RenderFlag, SampledFlag } from './core/texture/usageExtension.ts'; export type { InitFromDeviceOptions, InitOptions } from './core/root/init.ts'; diff --git a/packages/typegpu/tests/texture.test.ts b/packages/typegpu/tests/texture.test.ts index 450537c8f5..f497f955d2 100644 --- a/packages/typegpu/tests/texture.test.ts +++ b/packages/typegpu/tests/texture.test.ts @@ -516,7 +516,7 @@ Overload 3 of 4, '(schema: "(Error) Texture not usable as storage, call $usage(' texture: expect.anything(), mipLevel: 0, }), - data.buffer, + data, expect.objectContaining({ bytesPerRow: 16, // 4 pixels * 4 bytes per pixel rowsPerImage: 4, @@ -537,7 +537,7 @@ Overload 3 of 4, '(schema: "(Error) Texture not usable as storage, call $usage(' expect(device.mock.queue.writeTexture).toHaveBeenCalledWith( { texture: expect.anything(), mipLevel: 2 }, - data.buffer, + data, { bytesPerRow: 8, rowsPerImage: 2 }, // 2 pixels * 4 bytes per pixel [2, 2, 1], // Mip level 2 dimensions ); @@ -547,10 +547,12 @@ Overload 3 of 4, '(schema: "(Error) Texture not usable as storage, call $usage(' root, device, }) => { - const texture = root.createTexture({ - size: [32, 32], - format: 'rgba8unorm', - }); + const texture = root + .createTexture({ + size: [32, 32], + format: 'rgba8unorm', + }) + .$usage('render'); const mockImage = { width: 32, @@ -561,36 +563,418 @@ Overload 3 of 4, '(schema: "(Error) Texture not usable as storage, call $usage(' expect(device.mock.queue.copyExternalImageToTexture).toHaveBeenCalledWith( { source: mockImage }, - expect.objectContaining({ + { texture: expect.anything(), + mipLevel: 0, + origin: { x: 0, y: 0, z: 0 }, + }, + { width: 32, height: 32, depthOrArrayLayers: 1 }, + ); + }); + + it('throws a clear error when image source writes are missing render usage', ({ root }) => { + const mockImage = { + width: 32, + height: 32, + } as HTMLImageElement; + + expect(() => + root + .createTexture({ + size: [32, 32], + format: 'rgba8unorm', + }) + .write(mockImage), + ).toThrowErrorMatchingInlineSnapshot( + `[Error: texture.write(...) with image sources requires 'render' usage. Add it via the $usage('render') method.]`, + ); + + expect(() => + root.createTexture({ size: [32, 32], format: 'rgba8unorm' }).write({ + channels: { + r: mockImage, + }, }), - [32, 32], + ).toThrowErrorMatchingInlineSnapshot( + `[Error: texture.write(...) with image sources requires 'render' usage. Add it via the $usage('render') method.]`, ); }); - it('handles resizing when image dimensions do not match texture', ({ root, device }) => { - const texture = root.createTexture({ - size: [64, 64], - format: 'rgba8unorm', - }); + it('throws when image dimensions do not match texture without resize', ({ root }) => { + const texture = root + .createTexture({ + size: [64, 64], + format: 'rgba8unorm', + }) + .$usage('render'); const mockImage = { width: 32, height: 32, } as HTMLImageElement; - texture.write(mockImage); + expect(() => texture.write(mockImage)).toThrowErrorMatchingInlineSnapshot( + `[Error: Texture write source size 32x32 does not match target size 64x64. Pass resize: true to resize explicitly.]`, + ); + }); + + it('handles resizing when image dimensions do not match texture with resize', ({ + root, + device, + renderPassEncoder, + }) => { + const texture = root + .createTexture({ + size: [64, 64], + format: 'rgba8unorm', + }) + .$usage('render'); - // Should create textures for resampling since image size doesn't match texture size - expect(device.mock.createTexture).toHaveBeenCalled(); + const mockImage = { + width: 32, + height: 32, + } as HTMLImageElement; + + texture.write(mockImage, { resize: true }); + + expect(device.mock.createTexture).toHaveBeenCalledTimes(2); expect(device.mock.createShaderModule).toHaveBeenCalled(); expect(device.mock.createRenderPipeline).toHaveBeenCalled(); + expect(renderPassEncoder.mock.setViewport).toHaveBeenCalledWith(0, 0, 64, 64, 0, 1); + expect(renderPassEncoder.mock.setScissorRect).toHaveBeenCalledWith(0, 0, 64, 64); + expect(device.mock.createCommandEncoder).toHaveBeenCalled(); + expect(device.mock.queue.submit).toHaveBeenCalled(); + }); + + it('writes image descriptors with destination origin, size and mip level', ({ + root, + device, + }) => { + const texture = root + .createTexture({ + size: [64, 64], + format: 'rgba8unorm', + mipLevelCount: 2, + }) + .$usage('render'); + + const mockImage = { + width: 8, + height: 9, + } as HTMLImageElement; + + texture.write({ + source: mockImage, + origin: [4, 5], + size: [8, 9], + mipLevel: 1, + }); + + expect(device.mock.queue.copyExternalImageToTexture).toHaveBeenCalledWith( + { source: mockImage }, + { + texture: expect.anything(), + mipLevel: 1, + origin: { x: 4, y: 5, z: 0 }, + }, + { width: 8, height: 9, depthOrArrayLayers: 1 }, + ); + }); + + it('passes source crop options when writing image descriptors', ({ root, device }) => { + const texture = root + .createTexture({ + size: [64, 64], + format: 'rgba8unorm', + }) + .$usage('render'); + + const mockImage = { + width: 16, + height: 16, + } as HTMLImageElement; + + texture.write({ + source: mockImage, + sourceOrigin: [2, 3], + sourceSize: [4, 5], + origin: [6, 7], + }); + + expect(device.mock.queue.copyExternalImageToTexture).toHaveBeenCalledWith( + { source: mockImage, origin: { x: 2, y: 3 } }, + { + texture: expect.anything(), + mipLevel: 0, + origin: { x: 6, y: 7, z: 0 }, + }, + { width: 4, height: 5, depthOrArrayLayers: 1 }, + ); + }); + + it('throws when image descriptor size resizes without resize', ({ root }) => { + const texture = root + .createTexture({ + size: [64, 64], + format: 'rgba8unorm', + }) + .$usage('render'); + + const mockImage = { + width: 16, + height: 16, + } as HTMLImageElement; + + expect(() => + texture.write({ source: mockImage, size: [32, 32] }), + ).toThrowErrorMatchingInlineSnapshot( + `[Error: Texture write source size 16x16 does not match target size 32x32. Pass resize: true to resize explicitly.]`, + ); + }); + + it('uses the render path when image descriptor size resamples the source', ({ + root, + device, + renderPassEncoder, + }) => { + const texture = root + .createTexture({ + size: [64, 64], + format: 'rgba8unorm', + }) + .$usage('render'); + + const mockImage = { + width: 16, + height: 16, + } as HTMLImageElement; + + texture.write({ + source: mockImage, + size: [32, 32], + resize: true, + }); - // Verify that command encoder and render pass are used for resampling + expect(device.mock.createTexture).toHaveBeenCalledTimes(2); + expect(device.mock.createShaderModule).toHaveBeenCalled(); + expect(device.mock.createRenderPipeline).toHaveBeenCalled(); + expect(renderPassEncoder.mock.setViewport).toHaveBeenCalledWith(0, 0, 32, 32, 0, 1); + expect(renderPassEncoder.mock.setScissorRect).toHaveBeenCalledWith(0, 0, 32, 32); expect(device.mock.createCommandEncoder).toHaveBeenCalled(); expect(device.mock.queue.submit).toHaveBeenCalled(); }); + it('keeps sRGB image writes in sRGB when resampling', ({ root, device }) => { + const texture = root + .createTexture({ + size: [64, 64], + format: 'rgba8unorm-srgb', + }) + .$usage('render'); + + const mockImage = { + width: 16, + height: 16, + } as HTMLImageElement; + + texture.write({ + source: mockImage, + size: [32, 32], + resize: true, + }); + + expect(device.mock.createTexture).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ format: 'rgba8unorm-srgb' }), + ); + }); + + it('writes blobs through createImageBitmap', async ({ root, device }) => { + const texture = root + .createTexture({ + size: [32, 32], + format: 'rgba8unorm', + }) + .$usage('render'); + + const blob = new Blob(['image']); + const imageBitmap = { + width: 32, + height: 32, + close: vi.fn(), + } as unknown as ImageBitmap; + const createImageBitmapMock = vi.fn(() => Promise.resolve(imageBitmap)); + vi.stubGlobal('createImageBitmap', createImageBitmapMock); + + await texture.writeAsync(blob); + + expect(createImageBitmapMock).toHaveBeenCalledWith(blob, undefined); + expect(device.mock.queue.copyExternalImageToTexture).toHaveBeenCalledWith( + { source: imageBitmap }, + { + texture: expect.anything(), + mipLevel: 0, + origin: { x: 0, y: 0, z: 0 }, + }, + { width: 32, height: 32, depthOrArrayLayers: 1 }, + ); + expect(imageBitmap.close).toHaveBeenCalled(); + }); + + it('resizes blobs through createImageBitmap when requested', async ({ root, device }) => { + const texture = root + .createTexture({ + size: [64, 64], + format: 'rgba8unorm', + }) + .$usage('render'); + + const blob = new Blob(['image']); + const imageBitmap = { + width: 64, + height: 64, + close: vi.fn(), + } as unknown as ImageBitmap; + const createImageBitmapMock = vi.fn(() => Promise.resolve(imageBitmap)); + vi.stubGlobal('createImageBitmap', createImageBitmapMock); + + await texture.writeAsync({ + source: blob, + size: [64, 64], + resize: true, + }); + + expect(createImageBitmapMock).toHaveBeenCalledWith(blob, { + resizeWidth: 64, + resizeHeight: 64, + }); + expect(device.mock.createRenderPipeline).not.toHaveBeenCalled(); + }); + + it('writes grouped single channels with color write masks', ({ root, device }) => { + const texture = root + .createTexture({ + size: [32, 32], + format: 'rgba8unorm', + }) + .$usage('render'); + + const roughnessMap = { + width: 32, + height: 32, + } as HTMLImageElement; + const maskMap = { + width: 32, + height: 32, + } as HTMLImageElement; + + texture.write({ + channels: { + r: roughnessMap, + a: { source: maskMap, from: 'g' }, + }, + }); + + expect(device.mock.createTexture).toHaveBeenCalledTimes(2); + expect(device.mock.queue.copyExternalImageToTexture).toHaveBeenCalledTimes(2); + expect(device.mock.createCommandEncoder).toHaveBeenCalledTimes(2); + expect(device.mock.createRenderPipeline).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + fragment: expect.objectContaining({ + targets: [{ format: 'rgba8unorm', writeMask: GPUColorWrite.RED }], + }), + }), + ); + expect(device.mock.createRenderPipeline).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + fragment: expect.objectContaining({ + targets: [{ format: 'rgba8unorm', writeMask: GPUColorWrite.ALPHA }], + }), + }), + ); + }); + + it('applies shared regions to channel writes', ({ + root, + commandEncoder, + renderPassEncoder, + }) => { + const texture = root + .createTexture({ + size: [64, 64], + format: 'rgba8unorm', + }) + .$usage('render'); + + const roughnessMap = { + width: 8, + height: 9, + } as HTMLImageElement; + + texture.write({ + channels: { + r: roughnessMap, + }, + origin: [4, 5], + size: [8, 9], + }); + + expect(commandEncoder.mock.copyTextureToTexture).not.toHaveBeenCalled(); + expect(renderPassEncoder.mock.setViewport).toHaveBeenCalledWith(4, 5, 8, 9, 0, 1); + expect(renderPassEncoder.mock.setScissorRect).toHaveBeenCalledWith(4, 5, 8, 9); + }); + + it('requires resize for channel writes with mismatched sizes', ({ root }) => { + const texture = root + .createTexture({ + size: [64, 64], + format: 'rgba8unorm', + }) + .$usage('render'); + + const roughnessMap = { + width: 16, + height: 16, + } as HTMLImageElement; + + expect(() => + texture.write({ + channels: { + r: roughnessMap, + }, + size: [32, 32], + }), + ).toThrowErrorMatchingInlineSnapshot( + `[Error: Texture write source size 16x16 does not match target size 32x32. Pass resize: true to resize explicitly.]`, + ); + }); + + it('rejects grouped channel keys for now', ({ root }) => { + const texture = root + .createTexture({ + size: [32, 32], + format: 'rgba8unorm', + }) + .$usage('render'); + + const roughnessMap = { + width: 32, + height: 32, + } as HTMLImageElement; + + expect(() => + texture.write({ + channels: { + rg: roughnessMap, + }, + } as never), + ).toThrowErrorMatchingInlineSnapshot( + `[Error: Texture channel writes only support single channels: r, g, b, a.]`, + ); + }); + it('calls device methods when copyFrom is called', ({ root, device }) => { const sourceTexture = root.createTexture({ size: [16, 16],