typegpu
Version:
A thin layer between JS and WebGPU/WGSL that improves development experience and allows for faster iteration.
416 lines (415 loc) • 17.3 kB
JavaScript
import { isWgslStorageTexture, textureDescriptorToSchema, } from "../../data/texture.js";
import { inCodegenMode } from "../../execMode.js";
import { snip } from "../../data/snippet.js";
import { getName, setName } from "../../shared/meta.js";
import { getTextureFormatInfo, } from "./textureFormats.js";
import { $gpuValueOf, $internal, $ownSnippet, $repr, $resolve, $soul, } from "../../shared/symbols.js";
import { valueProxyHandler } from "../valueProxyUtils.js";
import { generateTextureMipmaps, getImageSourceDimensions, resampleImage } from "./textureUtils.js";
import { logger } from "../../tgpuLogger.js";
function getDescriptorForProps(props) {
return {
dimension: (props.dimension ?? '2d'),
sampleType: getTextureFormatInfo(props.format).channelType,
multisampled: !((props.sampleCount ?? 1) === 1),
};
}
export function INTERNAL_createTexture(props, root, rawTexture) {
return new TgpuTextureImpl(props, root, rawTexture);
}
export function isTexture(value) {
return value?.resourceType === 'texture' && !!value[$internal];
}
export function isTextureView(value) {
return (value?.resourceType === 'texture-view' &&
!!value[$internal]);
}
// --------------
// Implementation
// --------------
class TgpuTextureImpl {
[$internal];
[$soul];
resourceType = 'texture';
usableAsSampled = false;
usableAsStorage = false;
usableAsRender = false;
#formatInfo;
#destroyed = false;
#ownTexture;
constructor(props, root, rawTexture) {
this.#ownTexture = rawTexture === undefined;
this[$soul] = {
type: 'texture',
device: root.device,
props,
flags: GPUTextureUsage.COPY_DST | GPUTextureUsage.COPY_SRC,
flagsOverridden: false,
usages: [],
raw: rawTexture,
label: undefined,
};
this.#formatInfo = getTextureFormatInfo(props.format);
this[$internal] = {
root,
materialize: () => {
if (this.#destroyed) {
throw new Error('This texture has been destroyed');
}
const soul = this[$soul];
if (!soul.raw) {
const props = soul.props;
soul.raw = soul.device.createTexture({
label: getName(this) ?? '<unnamed>',
format: props.format,
// The WebGPU types accept only mutable arrays, which is too loosely typed
size: props.size,
usage: soul.flags,
dimension: props.dimension ?? '2d',
viewFormats: props.viewFormats ?? [],
mipLevelCount: props.mipLevelCount ?? 1,
sampleCount: props.sampleCount ?? 1,
});
}
return soul.raw;
},
};
}
get props() {
return this[$soul].props;
}
$name(label) {
setName(this, label);
return this;
}
$usage(...usages) {
const soul = this[$soul];
if (soul.flagsOverridden) {
throw new Error('Cannot call $usage() after $overrideFlags().');
}
const hasStorage = usages.includes('storage');
const hasSampled = usages.includes('sampled');
const hasRender = usages.includes('render');
const hasTransient = usages.includes('transient');
const bindingFlags = (hasSampled ? GPUTextureUsage.TEXTURE_BINDING : 0) |
(hasStorage ? GPUTextureUsage.STORAGE_BINDING : 0);
const transientFlags = GPUTextureUsage.TRANSIENT_ATTACHMENT | GPUTextureUsage.RENDER_ATTACHMENT;
const nextFlags = soul.flags | bindingFlags | (hasRender ? GPUTextureUsage.RENDER_ATTACHMENT : 0);
const hasTransientUsage = hasTransient || !!(soul.flags & GPUTextureUsage.TRANSIENT_ATTACHMENT);
const hasSampledOrStorageUsage = !!(nextFlags &
(GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.STORAGE_BINDING));
if (hasTransientUsage && hasSampledOrStorageUsage) {
throw new Error("Transient texture usage cannot be combined with 'sampled' or 'storage'.");
}
soul.flags = hasTransient ? transientFlags : nextFlags;
this.usableAsStorage ||= hasStorage;
this.usableAsSampled ||= hasSampled;
this.usableAsRender ||= hasRender || hasTransient;
for (const usage of usages) {
if (!soul.usages.includes(usage)) {
soul.usages.push(usage);
}
}
return this;
}
$overrideFlags(flags) {
const soul = this[$soul];
soul.flags = flags;
soul.flagsOverridden = true;
this.usableAsSampled = true;
this.usableAsStorage = true;
this.usableAsRender = true;
return this;
}
createView(schema, viewDescriptor) {
if (schema === 'render') {
return new TgpuTextureRenderViewImpl(this, viewDescriptor);
}
return new TgpuFixedTextureViewImpl(schema ?? textureDescriptorToSchema(getDescriptorForProps(this.props)), this, viewDescriptor);
}
#clearMipLevel(mip = 0) {
const scale = 2 ** mip;
const [width, height, depth] = [
Math.max(1, Math.floor((this.props.size[0] ?? 1) / scale)),
Math.max(1, Math.floor((this.props.size[1] ?? 1) / scale)),
Math.max(1, Math.floor((this.props.size[2] ?? 1) / scale)),
];
const texelSize = this.#formatInfo.texelSize;
if (texelSize === 'non-copyable') {
throw new Error(`Cannot clear texture with format '${this.props.format}': this format does not support copy operations.`);
}
this[$soul].device.queue.writeTexture({ texture: this[$internal].materialize(), mipLevel: mip }, new Uint8Array(width * height * depth * texelSize), { bytesPerRow: texelSize * width, rowsPerImage: height }, [width, height, depth]);
}
clear(mipLevel = 'all') {
if (mipLevel === 'all') {
const mipLevels = this.props.mipLevelCount ?? 1;
for (let i = 0; i < mipLevels; i++) {
this.#clearMipLevel(i);
}
}
else {
this.#clearMipLevel(mipLevel);
}
}
generateMipmaps(baseMipLevel = 0, mipLevels) {
if (!this.usableAsRender) {
throw new Error("generateMipmaps called without specifying 'render' usage. Add it via the $usage('render') method.");
}
const actualMipLevels = mipLevels ?? (this.props.mipLevelCount ?? 1) - baseMipLevel;
if (actualMipLevels <= 1) {
logger.warn('suspicious', `generateMipmaps is a no-op: would generate ${actualMipLevels} mip levels (base: ${baseMipLevel}, total: ${this.props.mipLevelCount ?? 1})`);
return;
}
if (baseMipLevel >= (this.props.mipLevelCount ?? 1)) {
throw new Error(`Base mip level ${baseMipLevel} is out of range. Texture has ${this.props.mipLevelCount ?? 1} mip levels.`);
}
generateTextureMipmaps(this[$soul].device, this[$internal].materialize(), baseMipLevel, actualMipLevels);
}
write(source, optionsOrMipLevel = 0) {
if (source instanceof ArrayBuffer || ArrayBuffer.isView(source)) {
this.#writeBufferData(source, typeof optionsOrMipLevel === 'number' ? optionsOrMipLevel : 0);
return;
}
if (!this.usableAsRender) {
throw new Error("texture.write(...) with image sources requires 'render' usage. Add it via the $usage('render') method.");
}
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, options);
return;
}
const layerCount = this.props.size[2] ?? 1;
if (source.length > layerCount) {
logger.warn('suspicious', `Too many image sources provided. Expected ${layerCount} layers, got ${source.length}. Extra sources will be ignored.`);
}
for (let layer = 0; layer < Math.min(source.length, layerCount); layer++) {
const bitmap = source[layer];
if (bitmap) {
this.#writeSingleLayer(bitmap, layer, options);
}
}
}
#writeBufferData(source, mipLevel) {
const mipWidth = Math.max(1, this.props.size[0] >> mipLevel);
const mipHeight = Math.max(1, (this.props.size[1] ?? 1) >> mipLevel);
const mipDepth = Math.max(1, (this.props.size[2] ?? 1) >> mipLevel);
const texelSize = this.#formatInfo.texelSize;
if (texelSize === 'non-copyable') {
throw new Error(`Cannot write to texture with format '${this.props.format}': this format does not support copy operations.`);
}
const expectedSize = mipWidth * mipHeight * mipDepth * texelSize;
const actualSize = source.byteLength ?? source.byteLength;
if (actualSize !== expectedSize) {
throw new Error(`Buffer size mismatch. Expected ${expectedSize} bytes for mip level ${mipLevel}, got ${actualSize} bytes.`);
}
this[$soul].device.queue.writeTexture({
texture: this[$internal].materialize(),
mipLevel,
}, 'buffer' in source ? source.buffer : source, {
bytesPerRow: texelSize * mipWidth,
rowsPerImage: mipHeight,
}, [mipWidth, mipHeight, mipDepth]);
}
#writeSingleLayer(source, layer, options) {
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;
}
this[$soul].device.queue.copyExternalImageToTexture({ source }, {
texture: this[$internal].materialize(),
...(layer !== undefined && { origin: { x: 0, y: 0, z: layer } }),
}, layer !== undefined ? [targetWidth, targetHeight, 1] : this.props.size);
}
copyFrom(source) {
if (source.props.format !== this.props.format) {
throw new Error(`Texture format mismatch. Source texture has format ${source.props.format}, target texture has format ${this.props.format}`);
}
if (source.props.size[0] !== this.props.size[0] ||
(source.props.size[1] ?? 1) !== (this.props.size[1] ?? 1) ||
(source.props.size[2] ?? 1) !== (this.props.size[2] ?? 1)) {
throw new Error(`Texture size mismatch. Source texture has size ${source.props.size.join('x')}, target texture has size ${this.props.size.join('x')}`);
}
const commandEncoder = this[$soul].device.createCommandEncoder();
commandEncoder.copyTextureToTexture({ texture: source[$internal].materialize() }, { texture: this[$internal].materialize() }, source.props.size);
this[$soul].device.queue.submit([commandEncoder.finish()]);
}
toString() {
return `${this.resourceType}:${getName(this) ?? '<unnamed>'}`;
}
get destroyed() {
return this.#destroyed;
}
destroy() {
if (this.#destroyed) {
return;
}
this.#destroyed = true;
if (this.#ownTexture) {
this[$soul].raw?.destroy();
}
}
}
class TgpuFixedTextureViewImpl {
[$internal];
[$soul];
resourceType = 'texture-view';
constructor(schema, baseTexture, descriptor) {
this[$soul] = {
type: 'texture-view',
texture: baseTexture,
schema,
descriptor,
raw: undefined,
label: undefined,
};
this[$internal] = {
unwrap: () => {
const soul = this[$soul];
if (!soul.raw) {
const schema = soul.schema;
const format = isWgslStorageTexture(schema) ? schema.format : soul.texture.props.format;
soul.raw = soul.texture[$internal].materialize().createView({
...soul.descriptor,
label: getName(this) ?? '<unnamed>',
format: soul.descriptor?.format ?? format,
dimension: schema.dimension,
});
}
return soul.raw;
},
format: descriptor?.format ??
(isWgslStorageTexture(schema) ? schema.format : baseTexture.props.format),
aspect: descriptor?.aspect,
};
}
get schema() {
return this[$soul].schema;
}
$name(label) {
setName(this, label);
if (this[$soul].raw) {
this[$soul].raw.label = label;
}
return this;
}
get [$gpuValueOf]() {
const schema = this.schema;
return new Proxy({
[$internal]: true,
get [$ownSnippet]() {
return snip(this, schema, /* origin */ 'handle', false);
},
[$resolve]: (ctx) => ctx.resolve(this),
toString: () => `${this.toString()}.$`,
}, valueProxyHandler);
}
get $() {
if (inCodegenMode()) {
return this[$gpuValueOf];
}
throw new Error('Direct access to texture view values is possible only as part of a compute dispatch or draw call. Try .read() or .write() instead');
}
get size() {
return this[$soul].texture.props.size;
}
toString() {
return `textureView:${getName(this) ?? '<unnamed>'}`;
}
[$resolve](ctx) {
const id = ctx.makeUniqueIdentifier(getName(this), 'global');
const { group, binding } = ctx.allocateFixedEntry(isWgslStorageTexture(this.schema)
? {
storageTexture: this.schema,
}
: {
texture: this.schema,
sampleType: this[$soul].descriptor?.sampleType ?? this.schema.bindingSampleType[0],
}, this);
return ctx.gen.declareGlobalVar({
group,
binding,
id,
dataType: this.schema,
scope: 'handle',
init: undefined,
});
}
}
export class TgpuLaidOutTextureViewImpl {
[$internal] = { unwrap: undefined };
resourceType = 'texture-view';
#membership;
schema;
constructor(schema, membership) {
this.schema = schema;
this.#membership = membership;
setName(this, membership.key);
}
toString() {
return `textureView:${getName(this) ?? '<unnamed>'}`;
}
[$resolve](ctx) {
const id = ctx.makeUniqueIdentifier(getName(this), 'global');
const group = ctx.allocateLayoutEntry(this.#membership.layout);
ctx.addDeclaration(` var ${id}: ${ctx.resolve(this.schema).value};`, id);
return snip(id, this.schema, /* origin */ 'handle');
}
get [$gpuValueOf]() {
const schema = this.schema;
return new Proxy({
[$internal]: true,
get [$ownSnippet]() {
return snip(this, schema, /* origin */ 'handle', false);
},
[$resolve]: (ctx) => ctx.resolve(this),
toString: () => `${this.toString()}.$`,
}, valueProxyHandler);
}
get $() {
if (inCodegenMode()) {
return this[$gpuValueOf];
}
throw new Error(`Accessed view '${getName(this) ?? '<unnamed>'}' outside of codegen mode. Direct access to texture views values is possible only as part of a compute dispatch or draw call. Try .read() or .write() instead`);
}
$name(label) {
setName(this, label);
return this;
}
}
export class TgpuTextureRenderViewImpl {
[$internal];
[$soul];
resourceType = 'texture-view';
constructor(baseTexture, descriptor = {}) {
this[$soul] = {
type: 'texture-view',
texture: baseTexture,
schema: 'render',
descriptor,
raw: undefined,
label: undefined,
};
this[$internal] = {
unwrap: () => {
return baseTexture[$internal].materialize().createView({
label: getName(this) ?? '<unnamed>',
...this.descriptor,
});
},
format: descriptor.format ?? baseTexture.props.format,
aspect: descriptor.aspect,
};
}
get descriptor() {
return this[$soul].descriptor ?? {};
}
}