From 63983714421b1451c6ff1b666bc61497fa741515 Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Fri, 19 Jun 2026 16:08:38 +0200 Subject: [PATCH 1/9] work --- .../src/content/docs/apis/textures.mdx | 114 ++++++- .../src/webgpuGlobals.ts | 8 + packages/typegpu/src/core/texture/texture.ts | 139 ++++++-- .../typegpu/src/core/texture/textureUtils.ts | 238 ++++++++++---- .../typegpu/src/core/texture/textureWrite.ts | 283 ++++++++++++++++ packages/typegpu/src/indexNamedExports.ts | 7 + packages/typegpu/tests/texture.test.ts | 311 +++++++++++++++++- 7 files changed, 992 insertions(+), 108 deletions(-) create mode 100644 packages/typegpu/src/core/texture/textureWrite.ts diff --git a/apps/typegpu-docs/src/content/docs/apis/textures.mdx b/apps/typegpu-docs/src/content/docs/apis/textures.mdx index a14907fc26..1b7e65508b 100644 --- a/apps/typegpu-docs/src/content/docs/apis/textures.mdx +++ b/apps/typegpu-docs/src/content/docs/apis/textures.mdx @@ -28,10 +28,9 @@ const texture = root.createTexture({ const response = await fetch('path/to/image.png'); const blob = await response.blob(); -const image = await createImageBitmap(blob); -// Uploading image data to the texture (will be resampled if sizes differ) -texture.write(image); +// Decode and upload the image data to the texture +await texture.writeAsync({ source: blob, size: [256, 256], resize: true }); // Creating a view to use in shader const sampledView = texture.createView(); @@ -101,7 +100,16 @@ The `.write()` method provides multiple overloads for different data sources: ```ts // Image sources (single or array) -write(source: ExternalImageSource | ExternalImageSource[]): void +write(source: GPUCopyExternalImageSource | GPUCopyExternalImageSource[], options?: Pick): void + +// Image source with destination/source rectangles +write(source: TextureImageWrite): void + +// Single destination channel writes +write(source: TextureChannelWrite): void + +// Blob decode/resize path +writeAsync(source: Blob | TextureBlobWrite): Promise // Raw binary data with optional mip level write(source: ArrayBuffer | TypedArray | DataView, mipLevel?: number): void @@ -109,7 +117,7 @@ write(source: ArrayBuffer | TypedArray | DataView, mipLevel?: number): void ### Writing image data -You can write various image sources to textures. `ExternalImageSource` includes: +You can write various image sources to textures. `GPUCopyExternalImageSource` includes: - `HTMLCanvasElement` - `HTMLImageElement` - `HTMLVideoElement` @@ -131,7 +139,7 @@ const texture = root.createTexture({ const response = await fetch('path/to/image.png'); const blob = await response.blob(); const imageBitmap = await createImageBitmap(blob); -texture.write(imageBitmap); +texture.write(imageBitmap, { resize: true }); // From an HTMLCanvasElement const canvas = document.createElement('canvas'); @@ -141,9 +149,101 @@ texture.write(canvas); ``` :::tip -If image dimensions don't match the texture size, the image will be automatically resampled to fit (requires 'render' usage). +Synchronous image writes require matching source and destination sizes by default. Pass `resize: true` when you want TypeGPU to resample an image source. ::: +### Writing image regions + +Use a descriptor with `source`, `origin` and `size` to write into a destination rectangle. `origin` is the destination origin in texture pixels, and `size` is the destination extent in pixels. + +```ts twoslash +import tgpu from 'typegpu'; +const root = await tgpu.init(); +declare const imageBitmap: ImageBitmap; +// ---cut--- +const texture = root.createTexture({ + size: [512, 512], + format: 'rgba8unorm', +}).$usage('sampled'); + +texture.write({ + source: imageBitmap, + origin: [128, 64], + size: [256, 256], + resize: true, +}); +``` + +You can crop the source before writing: + +```ts +texture.write({ + source: imageBitmap, + sourceOrigin: [16, 16], + sourceSize: [128, 128], + origin: [0, 0], +}); +``` + +### Writing blobs + +Use `writeAsync` when you want TypeGPU to decode a `Blob`. Blob writes use `createImageBitmap`, so resizing happens before upload: + +```ts twoslash +import tgpu from 'typegpu'; +const root = await tgpu.init(); +// ---cut--- +const texture = root.createTexture({ + size: [512, 512], + format: 'rgba8unorm', +}).$usage('sampled'); + +const blob = await (await fetch('path/to/image.png')).blob(); + +await texture.writeAsync({ + source: blob, + size: [512, 512], + resize: true, +}); +``` + +### Writing single channels + +Use `channels` when you want to update individual destination channels while leaving the others unchanged. + +```ts twoslash +import tgpu from 'typegpu'; +const root = await tgpu.init(); +declare const roughnessMap: ImageBitmap; +declare const metalnessMap: ImageBitmap; +declare const maskMap: ImageBitmap; +// ---cut--- +const texture = root.createTexture({ + size: [512, 512], + format: 'rgba8unorm', +}).$usage('sampled'); + +texture.write({ + channels: { + r: roughnessMap, + g: metalnessMap, + a: { source: maskMap, from: 'r' }, + }, +}); +``` + +Only single destination channels are supported for now: `r`, `g`, `b`, and `a`. Region options apply to the grouped write: + +```ts +texture.write({ + channels: { + r: roughnessMap, + }, + origin: [32, 16], + size: [128, 128], +}); +``` + ### Writing arrays of images For 3D textures or texture arrays, you can write multiple images: 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..a8007cdb0c 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..4c8ccab59b 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,8 @@ type BlitResources = { type DeviceCache = { vertexModule: GPUShaderModule; - filterableResources: Map; + filterableResources: Map; + channelModules: Map; layoutResources: Map< string, { bindGroupLayout: GPUBindGroupLayout; pipelineLayout: GPUPipelineLayout } @@ -90,6 +78,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 +86,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 +171,13 @@ type BlitOptions = { format: GPUTextureFormat; filterable: boolean; sampleType: GPUTextureSampleType; + filter?: GPUFilterMode; encoder?: GPUCommandEncoder; }; 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, @@ -199,10 +216,57 @@ function blit(options: BlitOptions): void { } } +function createStagedImageTexture(device: GPUDevice, write: TextureImageWriteLayout): GPUTexture { + const inputTexture = device.createTexture({ + size: write.sourceSize, + format: 'rgba8unorm', + usage: + GPUTextureUsage.TEXTURE_BINDING | + GPUTextureUsage.COPY_DST | + GPUTextureUsage.RENDER_ATTACHMENT, + }); + + 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); } +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( device: GPUDevice, format: GPUTextureFormat, @@ -273,35 +337,17 @@ 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 inputTexture = createStagedImageTexture(device, write); const renderTexture = device.createTexture({ - size: [targetTexture.width, targetTexture.height], + size: write.targetSize, format: targetTexture.format, usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC, }); @@ -315,17 +361,93 @@ export function resampleImage( format: targetTexture.format, filterable, sampleType: 'float', // Input is always rgba8unorm which is filterable + ...(write.filter !== undefined && { filter: write.filter }), encoder, }); encoder.copyTextureToTexture( { texture: renderTexture }, - { texture: targetTexture, origin: { x: 0, y: 0, z: layer } }, { - width: targetTexture.width, - height: targetTexture.height, - depthOrArrayLayers: 1, + texture: targetTexture, + mipLevel: write.mipLevel, + origin: write.targetOrigin, + }, + write.targetSize, + ); + + device.queue.submit([encoder.finish()]); + + inputTexture.destroy(); + renderTexture.destroy(); +} + +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 = createStagedImageTexture(device, write); + const renderTexture = device.createTexture({ + size: write.targetSize, + format: targetTexture.format, + usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC | GPUTextureUsage.COPY_DST, + }); + 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(); + + encoder.copyTextureToTexture( + { + texture: targetTexture, + mipLevel: write.mipLevel, + origin: write.targetOrigin, + }, + { texture: renderTexture }, + write.targetSize, + ); + + const pass = encoder.beginRenderPass({ + colorAttachments: [ + { + view: renderTexture.createView(), + loadOp: 'load', + storeOp: 'store', + }, + ], + }); + pass.setPipeline(pipeline); + pass.setBindGroup(0, bindGroup); + pass.draw(3); + pass.end(); + + encoder.copyTextureToTexture( + { texture: renderTexture }, + { + texture: targetTexture, + mipLevel: write.mipLevel, + origin: write.targetOrigin, }, + write.targetSize, ); 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..92ef84c5fb 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 ); @@ -561,14 +561,16 @@ 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(), - }), - [32, 32], + mipLevel: 0, + origin: { x: 0, y: 0, z: 0 }, + }, + { width: 32, height: 32, depthOrArrayLayers: 1 }, ); }); - it('handles resizing when image dimensions do not match texture', ({ root, device }) => { + it('throws when image dimensions do not match texture without resize', ({ root }) => { const texture = root.createTexture({ size: [64, 64], format: 'rgba8unorm', @@ -579,7 +581,24 @@ Overload 3 of 4, '(schema: "(Error) Texture not usable as storage, call $usage(' height: 32, } as HTMLImageElement; - texture.write(mockImage); + expect(() => texture.write(mockImage)).toThrow('Pass resize: true'); + }); + + it('handles resizing when image dimensions do not match texture with resize', ({ + root, + device, + }) => { + const texture = root.createTexture({ + size: [64, 64], + format: 'rgba8unorm', + }); + + const mockImage = { + width: 32, + height: 32, + } as HTMLImageElement; + + texture.write(mockImage, { resize: true }); // Should create textures for resampling since image size doesn't match texture size expect(device.mock.createTexture).toHaveBeenCalled(); @@ -591,6 +610,284 @@ Overload 3 of 4, '(schema: "(Error) Texture not usable as storage, call $usage(' 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, + }); + + 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', + }); + + 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', + }); + + const mockImage = { + width: 16, + height: 16, + } as HTMLImageElement; + + expect(() => texture.write({ source: mockImage, size: [32, 32] })).toThrow( + 'Pass resize: true', + ); + }); + + it('uses the render path when image descriptor size resamples the source', ({ + root, + device, + }) => { + const texture = root.createTexture({ + size: [64, 64], + format: 'rgba8unorm', + }); + + const mockImage = { + width: 16, + height: 16, + } as HTMLImageElement; + + texture.write({ + source: mockImage, + size: [32, 32], + resize: true, + }); + + expect(device.mock.createTexture).toHaveBeenCalled(); + expect(device.mock.createShaderModule).toHaveBeenCalled(); + expect(device.mock.createRenderPipeline).toHaveBeenCalled(); + expect(device.mock.createCommandEncoder).toHaveBeenCalled(); + expect(device.mock.queue.submit).toHaveBeenCalled(); + }); + + it('writes blobs through createImageBitmap', async ({ root, device }) => { + const texture = root.createTexture({ + size: [32, 32], + format: 'rgba8unorm', + }); + + 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', + }); + + 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', + }); + + 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.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, device }) => { + const texture = root.createTexture({ + size: [64, 64], + format: 'rgba8unorm', + }); + + const roughnessMap = { + width: 8, + height: 9, + } as HTMLImageElement; + + texture.write({ + channels: { + r: roughnessMap, + }, + origin: [4, 5], + size: [8, 9], + }); + + const commandEncoder = + device.mock.createCommandEncoder.mock.results[ + device.mock.createCommandEncoder.mock.results.length - 1 + ]?.value; + expect(commandEncoder?.copyTextureToTexture).toHaveBeenCalledWith( + { + texture: expect.anything(), + mipLevel: 0, + origin: { x: 4, y: 5, z: 0 }, + }, + { texture: expect.anything() }, + { width: 8, height: 9, depthOrArrayLayers: 1 }, + ); + }); + + it('requires resize for channel writes with mismatched sizes', ({ root }) => { + const texture = root.createTexture({ + size: [64, 64], + format: 'rgba8unorm', + }); + + const roughnessMap = { + width: 16, + height: 16, + } as HTMLImageElement; + + expect(() => + texture.write({ + channels: { + r: roughnessMap, + }, + size: [32, 32], + }), + ).toThrow('Pass resize: true'); + }); + + it('rejects grouped channel keys for now', ({ root }) => { + const texture = root.createTexture({ + size: [32, 32], + format: 'rgba8unorm', + }); + + const roughnessMap = { + width: 32, + height: 32, + } as HTMLImageElement; + + expect(() => + texture.write({ + channels: { + rg: roughnessMap, + }, + } as never), + ).toThrow('single channels'); + }); + it('calls device methods when copyFrom is called', ({ root, device }) => { const sourceTexture = root.createTexture({ size: [16, 16], From 35aee48842f97790837815337bdb3802beeedb87 Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Fri, 19 Jun 2026 17:33:32 +0200 Subject: [PATCH 2/9] update example --- .../src/examples/tests/texture-test/index.ts | 187 ++++++++++++++---- 1 file changed, 146 insertions(+), 41 deletions(-) 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..eab0324030 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,91 @@ 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, + filter: 'nearest', + }); + 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 +205,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 +279,7 @@ export const controls = defineControls({ onSliderChange: (value) => biasUniform.write(value), }, Clear: { - onButtonClick: () => texture.clear(), + onButtonClick: () => write('clear'), }, }); From 849a98844010da60c0c79ea96d07c3d76c34cf53 Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Mon, 22 Jun 2026 12:32:04 +0200 Subject: [PATCH 3/9] use in example and docs --- .../src/content/docs/apis/textures.mdx | 64 ++++++++++--------- .../algorithms/genetic-racing/index.ts | 2 +- .../examples/simulation/stable-fluid/index.ts | 13 +--- 3 files changed, 38 insertions(+), 41 deletions(-) diff --git a/apps/typegpu-docs/src/content/docs/apis/textures.mdx b/apps/typegpu-docs/src/content/docs/apis/textures.mdx index 1b7e65508b..3d0928baaa 100644 --- a/apps/typegpu-docs/src/content/docs/apis/textures.mdx +++ b/apps/typegpu-docs/src/content/docs/apis/textures.mdx @@ -96,24 +96,14 @@ const texture = root.createTexture({ ## Writing to a texture -The `.write()` method provides multiple overloads for different data sources: +Texture writes cover decoded images, blobs, single-channel writes, and raw texel data: -```ts -// Image sources (single or array) -write(source: GPUCopyExternalImageSource | GPUCopyExternalImageSource[], options?: Pick): void - -// Image source with destination/source rectangles -write(source: TextureImageWrite): void - -// Single destination channel writes -write(source: TextureChannelWrite): void - -// Blob decode/resize path -writeAsync(source: Blob | TextureBlobWrite): Promise - -// Raw binary data with optional mip level -write(source: ArrayBuffer | TypedArray | DataView, mipLevel?: number): void -``` +- `texture.write(source)` uploads a decoded image source, such as an `ImageBitmap`, `ImageData`, canvas, video frame, or video element. +- `texture.write(source, { resize: true })` allows a decoded image source to be resampled when its size does not match the destination. +- `texture.write({ source, sourceOrigin, sourceSize, origin, size })` writes a cropped source rectangle into a chosen destination rectangle. +- `texture.write({ channels })` updates selected destination channels, for example packing grayscale roughness, metalness, and mask maps into one RGBA texture. +- `texture.write(bytes, mipLevel?)` uploads raw texel data directly. +- `texture.writeAsync({ source: blob, size, resize: true })` decodes and resizes a `Blob` without TypeGPU's render-pass resize path. ### Writing image data @@ -126,6 +116,10 @@ You can write various image sources to textures. `GPUCopyExternalImageSource` in - `OffscreenCanvas` - `VideoFrame` +:::caution[Browser support] +`HTMLImageElement` and `ImageData` sources are not widely available in WebGPU yet. Firefox and Safari do not support them currently, so avoid them in portable code; decode to an `ImageBitmap` instead. +::: + ```ts twoslash import tgpu from 'typegpu'; const root = await tgpu.init(); @@ -139,7 +133,7 @@ const texture = root.createTexture({ const response = await fetch('path/to/image.png'); const blob = await response.blob(); const imageBitmap = await createImageBitmap(blob); -texture.write(imageBitmap, { resize: true }); +texture.write(imageBitmap); // From an HTMLCanvasElement const canvas = document.createElement('canvas'); @@ -148,13 +142,20 @@ const ctx = canvas.getContext('2d'); texture.write(canvas); ``` -:::tip -Synchronous image writes require matching source and destination sizes by default. Pass `resize: true` when you want TypeGPU to resample an image source. -::: +The source and destination sizes must match unless you pass `resize: true`. Matching sizes use a direct WebGPU copy. Mismatched sizes use TypeGPU's render-pass blit path, so the destination texture needs `.$usage('render')` and a 2D format that can be sampled as floats and used as a render attachment. + +```ts +const texture = root.createTexture({ + size: [256, 256], + format: 'rgba8unorm', +}).$usage('sampled', 'render'); + +texture.write(imageBitmap, { resize: true }); +``` ### Writing image regions -Use a descriptor with `source`, `origin` and `size` to write into a destination rectangle. `origin` is the destination origin in texture pixels, and `size` is the destination extent in pixels. +Use an object descriptor when you need a source crop, a destination rectangle, or a specific mip level. `sourceOrigin` and `sourceSize` pick pixels from the source image; `origin` and `size` choose where they land in the texture. ```ts twoslash import tgpu from 'typegpu'; @@ -164,17 +165,18 @@ declare const imageBitmap: ImageBitmap; const texture = root.createTexture({ size: [512, 512], format: 'rgba8unorm', -}).$usage('sampled'); +}).$usage('sampled', 'render'); texture.write({ source: imageBitmap, + sourceOrigin: [16, 16], + sourceSize: [128, 128], origin: [128, 64], - size: [256, 256], - resize: true, + size: [128, 128], }); ``` -You can crop the source before writing: +If `sourceSize` and `size` differ, add `resize: true`; this uses the same render-pass path as other decoded-source resizing: ```ts texture.write({ @@ -182,12 +184,14 @@ texture.write({ sourceOrigin: [16, 16], sourceSize: [128, 128], origin: [0, 0], + size: [256, 256], + resize: true, }); ``` ### Writing blobs -Use `writeAsync` when you want TypeGPU to decode a `Blob`. Blob writes use `createImageBitmap`, so resizing happens before upload: +Use `writeAsync` when your source is still a `Blob`, such as a fetched PNG or JPEG. Its main purpose is resized uploads without a TypeGPU render pass: with `resize: true`, TypeGPU asks `createImageBitmap` for the requested `size`, then uploads that bitmap. ```ts twoslash import tgpu from 'typegpu'; @@ -196,7 +200,7 @@ const root = await tgpu.init(); const texture = root.createTexture({ size: [512, 512], format: 'rgba8unorm', -}).$usage('sampled'); +}).$usage('sampled', 'render'); const blob = await (await fetch('path/to/image.png')).blob(); @@ -207,6 +211,8 @@ await texture.writeAsync({ }); ``` +This avoids TypeGPU's render-pass resize and its current format limits. The destination texture still needs `.$usage('render')`, because WebGPU requires `RENDER_ATTACHMENT` for external image copies. + ### Writing single channels Use `channels` when you want to update individual destination channels while leaving the others unchanged. @@ -232,7 +238,7 @@ texture.write({ }); ``` -Only single destination channels are supported for now: `r`, `g`, `b`, and `a`. Region options apply to the grouped write: +Only single destination channels are supported for now: `r`, `g`, `b`, and `a`. Each write uses a WebGPU render pass with a color write mask, so the target texture must be 2D and use a format supported by TypeGPU's render-pass write path. Region options apply to the grouped write: ```ts texture.write({ 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 86a13accbb..065f11f077 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')]; From daf603be9cbac9148aaa8b2211bfb5e4e39e0300 Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Mon, 22 Jun 2026 12:46:20 +0200 Subject: [PATCH 4/9] simpler and better --- .../src/content/docs/apis/textures.mdx | 18 +- packages/typegpu/src/core/texture/texture.ts | 16 +- packages/typegpu/tests/texture.test.ts | 178 ++++++++++++------ 3 files changed, 144 insertions(+), 68 deletions(-) diff --git a/apps/typegpu-docs/src/content/docs/apis/textures.mdx b/apps/typegpu-docs/src/content/docs/apis/textures.mdx index 3d0928baaa..c7c0ce86e1 100644 --- a/apps/typegpu-docs/src/content/docs/apis/textures.mdx +++ b/apps/typegpu-docs/src/content/docs/apis/textures.mdx @@ -24,7 +24,7 @@ const root = await tgpu.init(); const texture = root.createTexture({ size: [256, 256], format: 'rgba8unorm' as const, -}).$usage('sampled'); +}).$usage('sampled', 'render'); const response = await fetch('path/to/image.png'); const blob = await response.blob(); @@ -79,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: @@ -98,7 +98,7 @@ const texture = root.createTexture({ Texture writes cover decoded images, blobs, single-channel writes, and raw texel data: -- `texture.write(source)` uploads a decoded image source, such as an `ImageBitmap`, `ImageData`, canvas, video frame, or video element. +- `texture.write(source)` uploads a decoded image source, such as an `ImageBitmap`, `ImageData`, canvas, video frame, or video element. Decoded image writes require `.$usage('render')`. - `texture.write(source, { resize: true })` allows a decoded image source to be resampled when its size does not match the destination. - `texture.write({ source, sourceOrigin, sourceSize, origin, size })` writes a cropped source rectangle into a chosen destination rectangle. - `texture.write({ channels })` updates selected destination channels, for example packing grayscale roughness, metalness, and mask maps into one RGBA texture. @@ -127,7 +127,7 @@ const root = await tgpu.init(); const texture = root.createTexture({ size: [256, 256], format: 'rgba8unorm', -}).$usage('sampled'); +}).$usage('sampled', 'render'); // From an ImageBitmap const response = await fetch('path/to/image.png'); @@ -142,7 +142,7 @@ const ctx = canvas.getContext('2d'); texture.write(canvas); ``` -The source and destination sizes must match unless you pass `resize: true`. Matching sizes use a direct WebGPU copy. Mismatched sizes use TypeGPU's render-pass blit path, so the destination texture needs `.$usage('render')` and a 2D format that can be sampled as floats and used as a render attachment. +Image-source writes require `.$usage('render')`. The source and destination sizes must match unless you pass `resize: true`. Mismatched sizes additionally use TypeGPU's render-pass blit path, so the destination texture must be 2D and use a format that can be sampled as floats and used as a render attachment. ```ts const texture = root.createTexture({ @@ -211,7 +211,7 @@ await texture.writeAsync({ }); ``` -This avoids TypeGPU's render-pass resize and its current format limits. The destination texture still needs `.$usage('render')`, because WebGPU requires `RENDER_ATTACHMENT` for external image copies. +This avoids TypeGPU's render-pass resize and its current format limits. The destination texture still needs `.$usage('render')`. ### Writing single channels @@ -227,7 +227,7 @@ declare const maskMap: ImageBitmap; const texture = root.createTexture({ size: [512, 512], format: 'rgba8unorm', -}).$usage('sampled'); +}).$usage('sampled', 'render'); texture.write({ channels: { @@ -238,7 +238,7 @@ texture.write({ }); ``` -Only single destination channels are supported for now: `r`, `g`, `b`, and `a`. Each write uses a WebGPU render pass with a color write mask, so the target texture must be 2D and use a format supported by TypeGPU's render-pass write path. Region options apply to the grouped write: +Only single destination channels are supported for now: `r`, `g`, `b`, and `a`. Channel image writes require `.$usage('render')`. Each write uses a WebGPU render pass with a color write mask, so the target texture must be 2D and use a format supported by TypeGPU's render-pass write path. Region options apply to the grouped write: ```ts texture.write({ @@ -265,7 +265,7 @@ const texture3d = 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]); diff --git a/packages/typegpu/src/core/texture/texture.ts b/packages/typegpu/src/core/texture/texture.ts index a8007cdb0c..98111d882c 100644 --- a/packages/typegpu/src/core/texture/texture.ts +++ b/packages/typegpu/src/core/texture/texture.ts @@ -511,6 +511,8 @@ class TgpuTextureImpl implements TgpuTexture implements TgpuTexture implements TgpuTexture) { if (source.props.format !== this.props.format) { throw new Error( diff --git a/packages/typegpu/tests/texture.test.ts b/packages/typegpu/tests/texture.test.ts index 92ef84c5fb..6e1451aa68 100644 --- a/packages/typegpu/tests/texture.test.ts +++ b/packages/typegpu/tests/texture.test.ts @@ -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, @@ -570,28 +572,62 @@ Overload 3 of 4, '(schema: "(Error) Texture not usable as storage, call $usage(' ); }); + 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, + }, + }), + ).toThrowErrorMatchingInlineSnapshot( + `[Error: texture.write(...) with image sources requires 'render' usage. Add it via the $usage('render') method.]`, + ); + }); + it('throws when image dimensions do not match texture without resize', ({ root }) => { - const texture = root.createTexture({ - size: [64, 64], - format: 'rgba8unorm', - }); + const texture = root + .createTexture({ + size: [64, 64], + format: 'rgba8unorm', + }) + .$usage('render'); const mockImage = { width: 32, height: 32, } as HTMLImageElement; - expect(() => texture.write(mockImage)).toThrow('Pass resize: true'); + 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, }) => { - const texture = root.createTexture({ - size: [64, 64], - format: 'rgba8unorm', - }); + const texture = root + .createTexture({ + size: [64, 64], + format: 'rgba8unorm', + }) + .$usage('render'); const mockImage = { width: 32, @@ -614,11 +650,13 @@ Overload 3 of 4, '(schema: "(Error) Texture not usable as storage, call $usage(' root, device, }) => { - const texture = root.createTexture({ - size: [64, 64], - format: 'rgba8unorm', - mipLevelCount: 2, - }); + const texture = root + .createTexture({ + size: [64, 64], + format: 'rgba8unorm', + mipLevelCount: 2, + }) + .$usage('render'); const mockImage = { width: 8, @@ -644,10 +682,12 @@ Overload 3 of 4, '(schema: "(Error) Texture not usable as storage, call $usage(' }); it('passes source crop options when writing image descriptors', ({ root, device }) => { - const texture = root.createTexture({ - size: [64, 64], - format: 'rgba8unorm', - }); + const texture = root + .createTexture({ + size: [64, 64], + format: 'rgba8unorm', + }) + .$usage('render'); const mockImage = { width: 16, @@ -673,18 +713,22 @@ Overload 3 of 4, '(schema: "(Error) Texture not usable as storage, call $usage(' }); it('throws when image descriptor size resizes without resize', ({ root }) => { - const texture = root.createTexture({ - size: [64, 64], - format: 'rgba8unorm', - }); + 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] })).toThrow( - 'Pass resize: true', + 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.]`, ); }); @@ -692,10 +736,12 @@ Overload 3 of 4, '(schema: "(Error) Texture not usable as storage, call $usage(' root, device, }) => { - const texture = root.createTexture({ - size: [64, 64], - format: 'rgba8unorm', - }); + const texture = root + .createTexture({ + size: [64, 64], + format: 'rgba8unorm', + }) + .$usage('render'); const mockImage = { width: 16, @@ -716,10 +762,12 @@ Overload 3 of 4, '(schema: "(Error) Texture not usable as storage, call $usage(' }); it('writes blobs through createImageBitmap', async ({ root, device }) => { - const texture = root.createTexture({ - size: [32, 32], - format: 'rgba8unorm', - }); + const texture = root + .createTexture({ + size: [32, 32], + format: 'rgba8unorm', + }) + .$usage('render'); const blob = new Blob(['image']); const imageBitmap = { @@ -746,10 +794,12 @@ Overload 3 of 4, '(schema: "(Error) Texture not usable as storage, call $usage(' }); it('resizes blobs through createImageBitmap when requested', async ({ root, device }) => { - const texture = root.createTexture({ - size: [64, 64], - format: 'rgba8unorm', - }); + const texture = root + .createTexture({ + size: [64, 64], + format: 'rgba8unorm', + }) + .$usage('render'); const blob = new Blob(['image']); const imageBitmap = { @@ -774,10 +824,12 @@ Overload 3 of 4, '(schema: "(Error) Texture not usable as storage, call $usage(' }); it('writes grouped single channels with color write masks', ({ root, device }) => { - const texture = root.createTexture({ - size: [32, 32], - format: 'rgba8unorm', - }); + const texture = root + .createTexture({ + size: [32, 32], + format: 'rgba8unorm', + }) + .$usage('render'); const roughnessMap = { width: 32, @@ -814,10 +866,12 @@ Overload 3 of 4, '(schema: "(Error) Texture not usable as storage, call $usage(' }); it('applies shared regions to channel writes', ({ root, device }) => { - const texture = root.createTexture({ - size: [64, 64], - format: 'rgba8unorm', - }); + const texture = root + .createTexture({ + size: [64, 64], + format: 'rgba8unorm', + }) + .$usage('render'); const roughnessMap = { width: 8, @@ -848,10 +902,12 @@ Overload 3 of 4, '(schema: "(Error) Texture not usable as storage, call $usage(' }); it('requires resize for channel writes with mismatched sizes', ({ root }) => { - const texture = root.createTexture({ - size: [64, 64], - format: 'rgba8unorm', - }); + const texture = root + .createTexture({ + size: [64, 64], + format: 'rgba8unorm', + }) + .$usage('render'); const roughnessMap = { width: 16, @@ -865,14 +921,18 @@ Overload 3 of 4, '(schema: "(Error) Texture not usable as storage, call $usage(' }, size: [32, 32], }), - ).toThrow('Pass resize: true'); + ).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', - }); + const texture = root + .createTexture({ + size: [32, 32], + format: 'rgba8unorm', + }) + .$usage('render'); const roughnessMap = { width: 32, @@ -885,7 +945,9 @@ Overload 3 of 4, '(schema: "(Error) Texture not usable as storage, call $usage(' rg: roughnessMap, }, } as never), - ).toThrow('single channels'); + ).toThrowErrorMatchingInlineSnapshot( + `[Error: Texture channel writes only support single channels: r, g, b, a.]`, + ); }); it('calls device methods when copyFrom is called', ({ root, device }) => { From f293dda8646f64ec3ca315e8e1dca3ccbe08c8ad Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Mon, 22 Jun 2026 12:51:59 +0200 Subject: [PATCH 5/9] correct docs --- apps/typegpu-docs/src/content/docs/apis/textures.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/typegpu-docs/src/content/docs/apis/textures.mdx b/apps/typegpu-docs/src/content/docs/apis/textures.mdx index c7c0ce86e1..5b4d40814c 100644 --- a/apps/typegpu-docs/src/content/docs/apis/textures.mdx +++ b/apps/typegpu-docs/src/content/docs/apis/textures.mdx @@ -238,7 +238,7 @@ texture.write({ }); ``` -Only single destination channels are supported for now: `r`, `g`, `b`, and `a`. Channel image writes require `.$usage('render')`. Each write uses a WebGPU render pass with a color write mask, so the target texture must be 2D and use a format supported by TypeGPU's render-pass write path. Region options apply to the grouped write: +Channel image writes require `.$usage('render')`. The target texture must be 2D and use a format supported by TypeGPU's render-pass write path. Region options apply to the grouped write: ```ts texture.write({ From 9019970584fc3f9912b39827f83e6bc65f49c5d9 Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Mon, 22 Jun 2026 13:08:29 +0200 Subject: [PATCH 6/9] better util path --- .../typegpu-testing-utility/src/extendedIt.ts | 2 + .../typegpu/src/core/texture/textureUtils.ts | 137 ++++++++++-------- packages/typegpu/tests/texture.test.ts | 38 ++--- 3 files changed, 96 insertions(+), 81 deletions(-) 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/src/core/texture/textureUtils.ts b/packages/typegpu/src/core/texture/textureUtils.ts index 4c8ccab59b..f331bb5bcc 100644 --- a/packages/typegpu/src/core/texture/textureUtils.ts +++ b/packages/typegpu/src/core/texture/textureUtils.ts @@ -62,6 +62,7 @@ type DeviceCache = { vertexModule: GPUShaderModule; filterableResources: Map; channelModules: Map; + stagedTexture?: { key: string; texture: GPUTexture }; layoutResources: Map< string, { bindGroupLayout: GPUBindGroupLayout; pipelineLayout: GPUPipelineLayout } @@ -173,6 +174,8 @@ type BlitOptions = { sampleType: GPUTextureSampleType; filter?: GPUFilterMode; encoder?: GPUCommandEncoder; + loadOp?: GPULoadOp; + viewport?: { x: number; y: number; width: number; height: number }; }; function blit(options: BlitOptions): void { @@ -201,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); @@ -216,15 +235,23 @@ function blit(options: BlitOptions): void { } } -function createStagedImageTexture(device: GPUDevice, write: TextureImageWriteLayout): GPUTexture { - const inputTexture = device.createTexture({ - size: write.sourceSize, - format: 'rgba8unorm', - usage: - GPUTextureUsage.TEXTURE_BINDING | - GPUTextureUsage.COPY_DST | - GPUTextureUsage.RENDER_ATTACHMENT, - }); +function getStagedImageTexture(device: GPUDevice, write: TextureImageWriteLayout): GPUTexture { + const cache = getOrCreateDeviceCache(device); + const key = `${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: 'rgba8unorm', + usage: + GPUTextureUsage.TEXTURE_BINDING | + GPUTextureUsage.COPY_DST | + GPUTextureUsage.RENDER_ATTACHMENT, + }); + cache.stagedTexture = { key, texture: inputTexture }; + } device.queue.copyExternalImageToTexture( { @@ -242,7 +269,11 @@ function createStagedImageTexture(device: GPUDevice, write: TextureImageWriteLay } 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( @@ -299,6 +330,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, @@ -344,41 +399,24 @@ export function resampleImage( } const { filterable } = validateBlitFormat(device, targetTexture.format, 'resample'); - const inputTexture = createStagedImageTexture(device, write); - - const renderTexture = device.createTexture({ - size: write.targetSize, - format: targetTexture.format, - usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC, - }); + const inputTexture = getStagedImageTexture(device, write); 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 ...(write.filter !== undefined && { filter: write.filter }), encoder, + loadOp: 'load', + viewport: targetViewport(write), }); - encoder.copyTextureToTexture( - { texture: renderTexture }, - { - texture: targetTexture, - mipLevel: write.mipLevel, - origin: write.targetOrigin, - }, - write.targetSize, - ); - device.queue.submit([encoder.finish()]); - - inputTexture.destroy(); - renderTexture.destroy(); } export function writeTextureChannel( @@ -391,12 +429,7 @@ export function writeTextureChannel( } const { filterable } = validateBlitFormat(device, targetTexture.format, 'write channels'); - const inputTexture = createStagedImageTexture(device, write); - const renderTexture = device.createTexture({ - size: write.targetSize, - format: targetTexture.format, - usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC | GPUTextureUsage.COPY_DST, - }); + const inputTexture = getStagedImageTexture(device, write); const resources = getBlitResources(device, filterable, 'float', write.filter); const pipeline = device.createRenderPipeline({ layout: resources.pipelineLayout, @@ -416,42 +449,22 @@ export function writeTextureChannel( }); const encoder = device.createCommandEncoder(); - encoder.copyTextureToTexture( - { - texture: targetTexture, - mipLevel: write.mipLevel, - origin: write.targetOrigin, - }, - { texture: renderTexture }, - write.targetSize, - ); - const pass = encoder.beginRenderPass({ colorAttachments: [ { - view: renderTexture.createView(), + 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(); - encoder.copyTextureToTexture( - { texture: renderTexture }, - { - texture: targetTexture, - mipLevel: write.mipLevel, - origin: write.targetOrigin, - }, - write.targetSize, - ); - device.queue.submit([encoder.finish()]); - - inputTexture.destroy(); - renderTexture.destroy(); } diff --git a/packages/typegpu/tests/texture.test.ts b/packages/typegpu/tests/texture.test.ts index 6e1451aa68..f01a5b49fd 100644 --- a/packages/typegpu/tests/texture.test.ts +++ b/packages/typegpu/tests/texture.test.ts @@ -621,6 +621,7 @@ Overload 3 of 4, '(schema: "(Error) Texture not usable as storage, call $usage(' it('handles resizing when image dimensions do not match texture with resize', ({ root, device, + renderPassEncoder, }) => { const texture = root .createTexture({ @@ -636,12 +637,11 @@ Overload 3 of 4, '(schema: "(Error) Texture not usable as storage, call $usage(' texture.write(mockImage, { resize: true }); - // Should create textures for resampling since image size doesn't match texture size - expect(device.mock.createTexture).toHaveBeenCalled(); + expect(device.mock.createTexture).toHaveBeenCalledTimes(2); expect(device.mock.createShaderModule).toHaveBeenCalled(); expect(device.mock.createRenderPipeline).toHaveBeenCalled(); - - // Verify that command encoder and render pass are used for resampling + 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(); }); @@ -735,6 +735,7 @@ Overload 3 of 4, '(schema: "(Error) Texture not usable as storage, call $usage(' it('uses the render path when image descriptor size resamples the source', ({ root, device, + renderPassEncoder, }) => { const texture = root .createTexture({ @@ -754,9 +755,11 @@ Overload 3 of 4, '(schema: "(Error) Texture not usable as storage, call $usage(' resize: true, }); - expect(device.mock.createTexture).toHaveBeenCalled(); + 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(); }); @@ -847,6 +850,9 @@ Overload 3 of 4, '(schema: "(Error) Texture not usable as storage, call $usage(' }, }); + 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({ @@ -865,7 +871,11 @@ Overload 3 of 4, '(schema: "(Error) Texture not usable as storage, call $usage(' ); }); - it('applies shared regions to channel writes', ({ root, device }) => { + it('applies shared regions to channel writes', ({ + root, + commandEncoder, + renderPassEncoder, + }) => { const texture = root .createTexture({ size: [64, 64], @@ -886,19 +896,9 @@ Overload 3 of 4, '(schema: "(Error) Texture not usable as storage, call $usage(' size: [8, 9], }); - const commandEncoder = - device.mock.createCommandEncoder.mock.results[ - device.mock.createCommandEncoder.mock.results.length - 1 - ]?.value; - expect(commandEncoder?.copyTextureToTexture).toHaveBeenCalledWith( - { - texture: expect.anything(), - mipLevel: 0, - origin: { x: 4, y: 5, z: 0 }, - }, - { texture: expect.anything() }, - { width: 8, height: 9, depthOrArrayLayers: 1 }, - ); + 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 }) => { From aa29cd07f47a96eee08b7b2c4a22d93254485c1d Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Mon, 22 Jun 2026 13:17:43 +0200 Subject: [PATCH 7/9] respect srgb --- .../src/examples/tests/texture-test/index.ts | 1 - .../typegpu/src/core/texture/textureUtils.ts | 25 ++++++++++++++----- packages/typegpu/tests/texture.test.ts | 25 +++++++++++++++++++ 3 files changed, 44 insertions(+), 7 deletions(-) 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 eab0324030..df900dea65 100644 --- a/apps/typegpu-docs/src/examples/tests/texture-test/index.ts +++ b/apps/typegpu-docs/src/examples/tests/texture-test/index.ts @@ -131,7 +131,6 @@ function writeCroppedContent() { ...cropRect(), size: currentSize, resize: true, - filter: 'nearest', }); texture.generateMipmaps(); } diff --git a/packages/typegpu/src/core/texture/textureUtils.ts b/packages/typegpu/src/core/texture/textureUtils.ts index f331bb5bcc..698c6cc91f 100644 --- a/packages/typegpu/src/core/texture/textureUtils.ts +++ b/packages/typegpu/src/core/texture/textureUtils.ts @@ -235,16 +235,29 @@ function blit(options: BlitOptions): void { } } -function getStagedImageTexture(device: GPUDevice, write: TextureImageWriteLayout): GPUTexture { +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 key = `${write.sourceSize.width}x${write.sourceSize.height}`; + 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: 'rgba8unorm', + format, usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | @@ -399,7 +412,7 @@ export function resampleImage( } const { filterable } = validateBlitFormat(device, targetTexture.format, 'resample'); - const inputTexture = getStagedImageTexture(device, write); + const inputTexture = getStagedImageTexture(device, write, targetTexture.format); const encoder = device.createCommandEncoder(); @@ -409,7 +422,7 @@ export function resampleImage( 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', @@ -429,7 +442,7 @@ export function writeTextureChannel( } const { filterable } = validateBlitFormat(device, targetTexture.format, 'write channels'); - const inputTexture = getStagedImageTexture(device, write); + const inputTexture = getStagedImageTexture(device, write, targetTexture.format); const resources = getBlitResources(device, filterable, 'float', write.filter); const pipeline = device.createRenderPipeline({ layout: resources.pipelineLayout, diff --git a/packages/typegpu/tests/texture.test.ts b/packages/typegpu/tests/texture.test.ts index f01a5b49fd..f497f955d2 100644 --- a/packages/typegpu/tests/texture.test.ts +++ b/packages/typegpu/tests/texture.test.ts @@ -764,6 +764,31 @@ Overload 3 of 4, '(schema: "(Error) Texture not usable as storage, call $usage(' 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({ From 5f3eaf511d5edead73f327288f03e5e8366c1ea2 Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Mon, 22 Jun 2026 15:15:29 +0200 Subject: [PATCH 8/9] hacky test fix --- .../utils/baseTest.ts | 21 +++++++++++++++++++ .../utils/commonMocks.ts | 12 ++++++----- 2 files changed, 28 insertions(+), 5 deletions(-) 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; From bdebc1f61c69a183e97b6ac516de4da705d988a5 Mon Sep 17 00:00:00 2001 From: Konrad Reczko Date: Mon, 22 Jun 2026 17:24:55 +0200 Subject: [PATCH 9/9] better docs --- .../src/content/docs/apis/textures.mdx | 207 +++++++----------- 1 file changed, 79 insertions(+), 128 deletions(-) diff --git a/apps/typegpu-docs/src/content/docs/apis/textures.mdx b/apps/typegpu-docs/src/content/docs/apis/textures.mdx index 5b4d40814c..6f7faf586b 100644 --- a/apps/typegpu-docs/src/content/docs/apis/textures.mdx +++ b/apps/typegpu-docs/src/content/docs/apis/textures.mdx @@ -10,27 +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 response = await fetch('path/to/image.png'); +const blob = await response.blob(); +const imageBitmap = await createImageBitmap(blob); + const texture = root.createTexture({ - size: [256, 256], + size: [imageBitmap.width, imageBitmap.height], format: 'rgba8unorm' as const, }).$usage('sampled', 'render'); -const response = await fetch('path/to/image.png'); -const blob = await response.blob(); - -// Decode and upload the image data to the texture -await texture.writeAsync({ source: blob, size: [256, 256], resize: true }); +texture.write(imageBitmap); // Creating a view to use in shader const sampledView = texture.createView(); @@ -96,102 +96,66 @@ const texture = root.createTexture({ ## Writing to a texture -Texture writes cover decoded images, blobs, single-channel writes, and raw texel data: +Most texture writes are one of: decoded images, blobs, image arrays, raw bytes, regions, or channel writes. -- `texture.write(source)` uploads a decoded image source, such as an `ImageBitmap`, `ImageData`, canvas, video frame, or video element. Decoded image writes require `.$usage('render')`. -- `texture.write(source, { resize: true })` allows a decoded image source to be resampled when its size does not match the destination. -- `texture.write({ source, sourceOrigin, sourceSize, origin, size })` writes a cropped source rectangle into a chosen destination rectangle. -- `texture.write({ channels })` updates selected destination channels, for example packing grayscale roughness, metalness, and mask maps into one RGBA texture. -- `texture.write(bytes, mipLevel?)` uploads raw texel data directly. -- `texture.writeAsync({ source: blob, size, resize: true })` decodes and resizes a `Blob` without TypeGPU's render-pass resize path. +| 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. | -### Writing image data +Image writes require `.$usage('render')`. Source and destination sizes must match unless you pass `resize: true`. -You can write various image sources to textures. `GPUCopyExternalImageSource` includes: -- `HTMLCanvasElement` -- `HTMLImageElement` -- `HTMLVideoElement` -- `ImageBitmap` -- `ImageData` -- `OffscreenCanvas` -- `VideoFrame` - -:::caution[Browser support] -`HTMLImageElement` and `ImageData` sources are not widely available in WebGPU yet. Firefox and Safari do not support them currently, so avoid them in portable code; decode to an `ImageBitmap` instead. -::: +### Exact-size image upload ```ts twoslash import tgpu from 'typegpu'; const root = await tgpu.init(); -// ---cut--- -const texture = root.createTexture({ - size: [256, 256], - format: 'rgba8unorm', -}).$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); - -// From an HTMLCanvasElement -const canvas = document.createElement('canvas'); -const ctx = canvas.getContext('2d'); -// ... draw on canvas -texture.write(canvas); -``` - -Image-source writes require `.$usage('render')`. The source and destination sizes must match unless you pass `resize: true`. Mismatched sizes additionally use TypeGPU's render-pass blit path, so the destination texture must be 2D and use a format that can be sampled as floats and used as a render attachment. - -```ts +// ---cut--- const texture = root.createTexture({ - size: [256, 256], + size: [imageBitmap.width, imageBitmap.height], format: 'rgba8unorm', }).$usage('sampled', 'render'); -texture.write(imageBitmap, { resize: true }); +texture.write(imageBitmap); ``` -### Writing image regions +:::caution[Browser support] +`GPUCopyExternalImageSource` also includes canvas, video, `ImageData`, `HTMLImageElement`, and `VideoFrame`, but browser support varies. Decode fetched images to `ImageBitmap` for portable code. +::: -Use an object descriptor when you need a source crop, a destination rectangle, or a specific mip level. `sourceOrigin` and `sourceSize` pick pixels from the source image; `origin` and `size` choose where they land in the texture. +### Resizing decoded images -```ts twoslash -import tgpu from 'typegpu'; -const root = await tgpu.init(); -declare const imageBitmap: ImageBitmap; -// ---cut--- +```ts const texture = root.createTexture({ size: [512, 512], format: 'rgba8unorm', }).$usage('sampled', 'render'); -texture.write({ - source: imageBitmap, - sourceOrigin: [16, 16], - sourceSize: [128, 128], - origin: [128, 64], - size: [128, 128], -}); -``` - -If `sourceSize` and `size` differ, add `resize: true`; this uses the same render-pass path as other decoded-source resizing: +texture.write(imageBitmap, { resize: true }); -```ts texture.write({ source: imageBitmap, sourceOrigin: [16, 16], sourceSize: [128, 128], - origin: [0, 0], size: [256, 256], resize: true, }); ``` -### Writing blobs +:::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 -Use `writeAsync` when your source is still a `Blob`, such as a fetched PNG or JPEG. Its main purpose is resized uploads without a TypeGPU render pass: with `resize: true`, TypeGPU asks `createImageBitmap` for the requested `size`, then uploads that bitmap. +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'; @@ -211,69 +175,34 @@ await texture.writeAsync({ }); ``` -This avoids TypeGPU's render-pass resize and its current format limits. The destination texture still needs `.$usage('render')`. +The resulting bitmap is still written as an image source, so the texture needs `.$usage('render')`. -### Writing single channels +### Texture arrays and 3D textures -Use `channels` when you want to update individual destination channels while leaving the others unchanged. +Pass an array of images to write one image per layer. ```ts twoslash import tgpu from 'typegpu'; const root = await tgpu.init(); -declare const roughnessMap: ImageBitmap; -declare const metalnessMap: ImageBitmap; -declare const maskMap: ImageBitmap; +declare const layer0: ImageBitmap; +declare const layer1: ImageBitmap; +declare const layer2: ImageBitmap; // ---cut--- const texture = root.createTexture({ - size: [512, 512], + size: [256, 256, 3], format: 'rgba8unorm', }).$usage('sampled', 'render'); -texture.write({ - channels: { - r: roughnessMap, - g: metalnessMap, - a: { source: maskMap, from: 'r' }, - }, -}); +texture.write([layer0, layer1, layer2]); ``` -Channel image writes require `.$usage('render')`. The target texture must be 2D and use a format supported by TypeGPU's render-pass write path. Region options apply to the grouped write: - -```ts -texture.write({ - channels: { - r: roughnessMap, - }, - origin: [32, 16], - size: [128, 128], -}); -``` +Each image must match the layer size unless you pass `{ resize: true }`. -### Writing arrays of images - -For 3D textures or texture arrays, you can write multiple images: - -```ts twoslash -import tgpu from 'typegpu'; -const root = await tgpu.init(); -declare const imageBitmap1: ImageBitmap; -declare const imageBitmap2: ImageBitmap; -declare const imageBitmap3: ImageBitmap; -// ---cut--- -const texture3d = root.createTexture({ - size: [256, 256, 3], - format: 'rgba8unorm', - dimension: '3d', -}).$usage('sampled', 'render'); - -// Write array of images for each layer -texture3d.write([imageBitmap1, imageBitmap2, imageBitmap3]); -``` +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'; @@ -282,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) @@ -294,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: @@ -340,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. @@ -360,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';