Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 17 additions & 13 deletions apps/typegpu-docs/src/content/docs/apis/textures.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,14 @@ 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();
const image = await createImageBitmap(blob);

// Uploading image data to the texture (will be resampled if sizes differ)
texture.write(image);
// Uploading image data to the texture (explicitly resampling if sizes differ)
texture.write(image, { fit: 'stretch' });

// Creating a view to use in shader
const sampledView = texture.createView();
Expand Down Expand Up @@ -119,12 +119,14 @@ The `.write()` method provides multiple overloads for different data sources:

```ts
// Image sources (single or array)
write(source: ExternalImageSource | ExternalImageSource[]): void
write(source: ExternalImageSource | ExternalImageSource[], options?: TextureWriteOptions): void

// Raw binary data with optional mip level
write(source: ArrayBuffer | TypedArray | DataView, mipLevel?: number): void
```

Writing image sources requires the `'render'` usage flag. Raw binary writes do not.

### Writing image data

You can write various image sources to textures. `ExternalImageSource` includes:
Expand All @@ -143,24 +145,26 @@ 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');
const blob = await response.blob();
const imageBitmap = await createImageBitmap(blob);
texture.write(imageBitmap);
texture.write(imageBitmap, { fit: 'stretch' });

// From an HTMLCanvasElement
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// ... draw on canvas
texture.write(canvas);
texture.write(canvas, { fit: 'stretch' });
```

:::tip
If image dimensions don't match the texture size, the image will be automatically resampled to fit (requires 'render' usage).
:::
If image dimensions don't match the texture size, a plain `.write()` call throws. Pass `{ fit: 'stretch' }` to explicitly resample the image to fit (requires `'render'` usage):

```ts
texture.write(imageBitmap, { fit: 'stretch' });
```

### Writing arrays of images

Expand All @@ -177,9 +181,9 @@ const texture3d = root.createTexture({
size: [256, 256, 3],
format: 'rgba8unorm',
dimension: '3d',
}).$usage('sampled');
}).$usage('sampled', 'render');

// Write array of images for each layer
// Write an image to each layer (each image must match the layer size)
texture3d.write([imageBitmap1, imageBitmap2, imageBitmap3]);
```

Expand Down Expand Up @@ -244,7 +248,7 @@ const texture = root.createTexture({
mipLevelCount: 9, // log2(256) + 1
}).$usage('sampled', 'render');

texture.write(imageBitmap);
texture.write(imageBitmap, { fit: 'stretch' });
texture.generateMipmaps(); // Generate all mip levels automatically
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ const carSpriteTexture = root
format: 'rgba8unorm',
})
.$usage('sampled', 'render');
carSpriteTexture.write(carBitmap);
carSpriteTexture.write(carBitmap, { fit: 'stretch' });
const carSpriteView = carSpriteTexture.createView();

const linearSampler = root.createSampler({
Expand Down
4 changes: 2 additions & 2 deletions apps/typegpu-docs/src/examples/simulation/gravity/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export async function loadSkyBox(root: TgpuRoot) {
}),
);

texture.write(bitmaps);
texture.write(bitmaps, { fit: 'stretch' });

return texture;
}
Expand Down Expand Up @@ -144,7 +144,7 @@ export async function loadSphereTextures(root: TgpuRoot) {
return await createImageBitmap(blob);
}),
);
texture.write(planets);
texture.write(planets, { fit: 'stretch' });

return texture;
}
Expand Down
4 changes: 2 additions & 2 deletions apps/typegpu-docs/src/examples/tests/texture-test/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,15 +114,15 @@ function createPipelineForFormat(format: TestFormat) {
}

let texture = createTestTexture(currentFormat, currentSize);
texture.write(imageBitmap);
texture.write(imageBitmap, { fit: 'stretch' });
texture.generateMipmaps();

let { layout, pipeline } = createPipelineForFormat(currentFormat);
let bindGroup = root.createBindGroup(layout, { myTexture: texture });

function recreateTexture() {
texture = createTestTexture(currentFormat, currentSize);
texture.write(imageBitmap);
texture.write(imageBitmap, { fit: 'stretch' });
texture.generateMipmaps();
({ layout, pipeline } = createPipelineForFormat(currentFormat));
bindGroup = root.createBindGroup(layout, { myTexture: texture });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,37 +24,15 @@ describe('jelly-slider example', () => {
mockFonts();
mockImageLoading();
mockResizeObserver();
mockCreateImageBitmap();
mockCreateImageBitmap({ width: 512, height: 256 });
},
expectedCalls: 6,
expectedCalls: 4,
},
device,
);

expect(shaderCodes).toMatchInlineSnapshot(`
"
struct VertexOutput {
@builtin(position) pos: vec4f,
@location(0) uv: vec2f,
}

@vertex
fn vs_main(@builtin(vertex_index) i: u32) -> VertexOutput {
const pos = array(vec2f(-1, -1), vec2f(3, -1), vec2f(-1, 3));
const uv = array(vec2f(0, 1), vec2f(2, 1), vec2f(0, -1));
return VertexOutput(vec4f(pos[i], 0, 1), uv[i]);
}


@group(0) @binding(0) var src: texture_2d<f32>;
@group(0) @binding(1) var samp: sampler;

@fragment
fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f {
return textureSample(src, samp, uv);
}

struct fullScreenTriangle_Output {
"struct fullScreenTriangle_Output {
@builtin(position) pos: vec4f,
@location(0) uv: vec2f,
}
Expand Down
38 changes: 31 additions & 7 deletions packages/typegpu/src/core/texture/texture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,18 @@ export type ExternalImageSource =
| OffscreenCanvas
| VideoFrame;

export type TextureWriteFit = 'stretch';

export type TextureWriteOptions = {
/**
* How to handle a source whose dimensions do not match the texture.
*
* By default, mismatched writes throw. Use `'stretch'` to resample the
* source to the texture's dimensions.
*/
fit?: TextureWriteFit;
};

type TgpuTextureViewDescriptor = {
/**
* Which {@link GPUTextureAspect | aspect(s)} of the texture are accessible to the texture view.
Expand Down Expand Up @@ -209,7 +221,7 @@ export interface TgpuTexture<TProps extends TextureProps = any> extends TgpuNama

clear(mipLevel?: number | 'all'): void;
generateMipmaps(baseMipLevel?: number, mipLevels?: number): void;
write(source: ExternalImageSource | ExternalImageSource[]): void;
write(source: ExternalImageSource | ExternalImageSource[], options?: TextureWriteOptions): void;
write(source: ArrayBuffer | TypedArray | DataView, mipLevel?: number): void;
// TODO: support copies from GPUBuffers and TgpuBuffers
copyFrom<T extends CopyCompatibleTexture<TProps>>(source: T): void;
Expand Down Expand Up @@ -477,22 +489,29 @@ class TgpuTextureImpl<TProps extends TextureProps> implements TgpuTexture<TProps
);
}

write(source: ExternalImageSource | ExternalImageSource[]): void;
write(source: ExternalImageSource | ExternalImageSource[], options?: TextureWriteOptions): void;
write(source: ArrayBuffer | TypedArray | DataView, mipLevel?: number): void;
write(
source: ExternalImageSource | ExternalImageSource[] | ArrayBuffer | TypedArray | DataView,
mipLevel = 0,
optionsOrMipLevel: TextureWriteOptions | number = 0,
) {
if (source instanceof ArrayBuffer || ArrayBuffer.isView(source)) {
this.#writeBufferData(source, mipLevel);
this.#writeBufferData(source, typeof optionsOrMipLevel === 'number' ? optionsOrMipLevel : 0);
return;
}

if (!this.usableAsRender) {
Comment thread
reczkok marked this conversation as resolved.
throw new Error(
"texture.write(...) with image sources requires 'render' usage. Add it via the $usage('render') method.",
);
}
Comment thread
reczkok marked this conversation as resolved.

const options = typeof optionsOrMipLevel === 'number' ? undefined : optionsOrMipLevel;
const dimension = this.props.dimension ?? '2d';
const isArray = Array.isArray(source);

if (!isArray) {
this.#writeSingleLayer(source, dimension === '3d' ? 0 : undefined);
this.#writeSingleLayer(source, dimension === '3d' ? 0 : undefined, options);
return;
}

Expand All @@ -507,7 +526,7 @@ class TgpuTextureImpl<TProps extends TextureProps> implements TgpuTexture<TProps
for (let layer = 0; layer < Math.min(source.length, layerCount); layer++) {
const bitmap = source[layer];
if (bitmap) {
this.#writeSingleLayer(bitmap, layer);
this.#writeSingleLayer(bitmap, layer, options);
}
}
}
Expand Down Expand Up @@ -547,13 +566,18 @@ class TgpuTextureImpl<TProps extends TextureProps> implements TgpuTexture<TProps
);
}

#writeSingleLayer(source: ExternalImageSource, layer?: number) {
#writeSingleLayer(source: ExternalImageSource, layer?: number, options?: TextureWriteOptions) {
const targetWidth = this.props.size[0];
const targetHeight = this.props.size[1] ?? 1;
const { width: sourceWidth, height: sourceHeight } = getImageSourceDimensions(source);
const needsResampling = sourceWidth !== targetWidth || sourceHeight !== targetHeight;

if (needsResampling) {
if (options?.fit !== 'stretch') {
throw new Error(
`Texture write source size ${sourceWidth}x${sourceHeight} does not match target size ${targetWidth}x${targetHeight}. Pass fit: 'stretch' to resize explicitly.`,
);
}
resampleImage(this[$soul].device, this[$internal].materialize(), source, layer);
return;
}
Expand Down
7 changes: 6 additions & 1 deletion packages/typegpu/src/indexNamedExports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,12 @@ export type {
RawCodeSnippetOrigin,
TgpuRawCodeSnippet,
} from './core/rawCodeSnippet/tgpuRawCodeSnippet.ts';
export type { TgpuTexture, TgpuTextureView } from './core/texture/texture.ts';
export type {
TextureWriteFit,
TextureWriteOptions,
TgpuTexture,
TgpuTextureView,
} from './core/texture/texture.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';
Expand Down
55 changes: 48 additions & 7 deletions packages/typegpu/tests/texture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -614,10 +614,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,
Expand All @@ -635,9 +637,9 @@ Overload 3 of 4, '(schema: "(Error) Texture not usable as storage, call $usage('
);
});

it('handles resizing when image dimensions do not match texture', ({ root, device }) => {
it('throws when image source writes are missing render usage', ({ root }) => {
const texture = root.createTexture({
size: [64, 64],
size: [32, 32],
format: 'rgba8unorm',
});

Expand All @@ -646,7 +648,46 @@ Overload 3 of 4, '(schema: "(Error) Texture not usable as storage, call $usage('
height: 32,
} as HTMLImageElement;

texture.write(mockImage);
expect(() => texture.write(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 a fit mode', ({ root }) => {
const texture = root
.createTexture({
size: [64, 64],
format: 'rgba8unorm',
})
.$usage('render');

const mockImage = {
width: 32,
height: 32,
} as HTMLImageElement;

expect(() => texture.write(mockImage)).toThrowErrorMatchingInlineSnapshot(
`[Error: Texture write source size 32x32 does not match target size 64x64. Pass fit: 'stretch' to resize explicitly.]`,
);
});

it('handles resizing when image dimensions do not match texture with fit: stretch', ({
root,
device,
}) => {
const texture = root
.createTexture({
size: [64, 64],
format: 'rgba8unorm',
})
.$usage('render');

const mockImage = {
width: 32,
height: 32,
} as HTMLImageElement;

texture.write(mockImage, { fit: 'stretch' });

// Should create textures for resampling since image size doesn't match texture size
expect(device.mock.createTexture).toHaveBeenCalled();
Expand Down
Loading