UNPKG

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
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(`@group(${group}) @binding(${this.#membership.idx}) 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 ?? {}; } }