playcanvas
Version:
Open-source WebGL/WebGPU 3D engine for the web
481 lines (480 loc) • 19.6 kB
JavaScript
var __defProp = Object.defineProperty;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
import { TRACEID_RENDER_QUEUE } from "../../../core/constants.js";
import { Debug, DebugHelper } from "../../../core/debug.js";
import { math } from "../../../core/math/math.js";
import {
pixelFormatInfo,
isCompressedPixelFormat,
getPixelFormatArrayType,
ADDRESS_REPEAT,
ADDRESS_CLAMP_TO_EDGE,
ADDRESS_MIRRORED_REPEAT,
PIXELFORMAT_RGBA16F,
PIXELFORMAT_RGBA32F,
PIXELFORMAT_DEPTHSTENCIL,
SAMPLETYPE_UNFILTERABLE_FLOAT,
SAMPLETYPE_DEPTH,
FILTER_NEAREST,
FILTER_LINEAR,
FILTER_NEAREST_MIPMAP_NEAREST,
FILTER_NEAREST_MIPMAP_LINEAR,
FILTER_LINEAR_MIPMAP_NEAREST,
FILTER_LINEAR_MIPMAP_LINEAR,
isIntegerPixelFormat,
SAMPLETYPE_INT,
SAMPLETYPE_UINT,
BUFFERUSAGE_READ,
BUFFERUSAGE_COPY_DST
} from "../constants.js";
import { TextureUtils } from "../texture-utils.js";
import { WebgpuDebug } from "./webgpu-debug.js";
import { gpuTextureFormats } from "./constants.js";
const gpuAddressModes = [];
gpuAddressModes[ADDRESS_REPEAT] = "repeat";
gpuAddressModes[ADDRESS_CLAMP_TO_EDGE] = "clamp-to-edge";
gpuAddressModes[ADDRESS_MIRRORED_REPEAT] = "mirror-repeat";
const gpuFilterModes = [];
gpuFilterModes[FILTER_NEAREST] = { level: "nearest", mip: "nearest" };
gpuFilterModes[FILTER_LINEAR] = { level: "linear", mip: "nearest" };
gpuFilterModes[FILTER_NEAREST_MIPMAP_NEAREST] = { level: "nearest", mip: "nearest" };
gpuFilterModes[FILTER_NEAREST_MIPMAP_LINEAR] = { level: "nearest", mip: "linear" };
gpuFilterModes[FILTER_LINEAR_MIPMAP_NEAREST] = { level: "linear", mip: "nearest" };
gpuFilterModes[FILTER_LINEAR_MIPMAP_LINEAR] = { level: "linear", mip: "linear" };
const dummyUse = (thingOne) => {
};
class WebgpuTexture {
constructor(texture) {
/**
* @type {GPUTexture}
* @private
*/
__publicField(this, "gpuTexture");
/**
* @type {GPUTextureView}
* @private
*/
__publicField(this, "view");
/**
* An array of samplers, addressed by SAMPLETYPE_*** constant, allowing texture to be sampled
* using different samplers. Most textures are sampled as interpolated floats, but some can
* additionally be sampled using non-interpolated floats (raw data) or compare sampling
* (shadow maps).
*
* @type {GPUSampler[]}
* @private
*/
__publicField(this, "samplers", []);
/**
* @type {GPUTextureDescriptor}
* @private
*/
__publicField(this, "desc");
/**
* @type {GPUTextureFormat}
* @private
*/
__publicField(this, "format");
/**
* A cache of texture views keyed by TextureView.key, used for storage texture bindings.
*
* @type {Map<number, GPUTextureView>}
* @private
*/
__publicField(this, "viewCache", /* @__PURE__ */ new Map());
this.texture = texture;
this.format = gpuTextureFormats[texture.format];
Debug.assert(this.format !== "", `WebGPU does not support texture format ${texture.format} [${pixelFormatInfo.get(texture.format)?.name}] for texture ${texture.name}`, texture);
this.create(texture.device);
}
create(device) {
const texture = this.texture;
const wgpu = device.wgpu;
const numLevels = texture.numLevels;
Debug.assert(texture.width > 0 && texture.height > 0, `Invalid texture dimensions ${texture.width}x${texture.height} for texture ${texture.name}`, texture);
if (isCompressedPixelFormat(texture.format) && (texture.width % 4 !== 0 || texture.height % 4 !== 0)) {
Debug.error(`Compressed texture '${texture.name}' [${pixelFormatInfo.get(texture.format)?.name}] dimensions ${texture.width}x${texture.height} are not a multiple of the block size 4. WebGPU requires compressed texture dimensions to be multiples of the block size. Rounding up to ${math.roundUp(texture.width, 4)}x${math.roundUp(texture.height, 4)}, which may cause minor rendering artifacts.`, texture);
texture._width = math.roundUp(texture.width, 4);
texture._height = math.roundUp(texture.height, 4);
}
this.desc = {
size: {
width: texture.width,
height: texture.height,
depthOrArrayLayers: texture.cubemap ? 6 : texture.array ? texture.arrayLength : 1
},
format: this.format,
mipLevelCount: numLevels,
sampleCount: 1,
dimension: texture.volume ? "3d" : "2d",
// TODO: use only required usage flags
// COPY_SRC - probably only needed on render target textures, to support copyRenderTarget (grab pass needs it)
// RENDER_ATTACHMENT - needed for mipmap generation
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.COPY_SRC | (isCompressedPixelFormat(texture.format) ? 0 : GPUTextureUsage.RENDER_ATTACHMENT) | (texture.storage ? GPUTextureUsage.STORAGE_BINDING : 0)
};
WebgpuDebug.validate(device);
this.gpuTexture = wgpu.createTexture(this.desc);
DebugHelper.setLabel(this.gpuTexture, `${texture.name}${texture.cubemap ? "[cubemap]" : ""}${texture.volume ? "[3d]" : ""}`);
WebgpuDebug.end(device, "Texture creation", {
desc: this.desc,
texture
});
let viewDescr;
if (this.texture.format === PIXELFORMAT_DEPTHSTENCIL) {
viewDescr = {
format: "depth24plus",
aspect: "depth-only"
};
}
this.view = this.createView(viewDescr);
this.viewCache.clear();
}
destroy(device) {
device.deferDestroy(this.gpuTexture);
this.gpuTexture = null;
this.view = null;
this.viewCache.clear();
this.samplers.length = 0;
}
propertyChanged(flag) {
this.samplers.length = 0;
}
/**
* Returns a texture view. If a TextureView is provided, returns a cached view for those
* specific parameters (creating it if needed). Otherwise returns the default view.
*
* @param {WebgpuGraphicsDevice} device - The graphics device.
* @param {TextureView} [textureView] - Optional TextureView specifying view parameters.
* @returns {GPUTextureView} - Returns the view.
* @private
*/
getView(device, textureView) {
this.uploadImmediate(device, this.texture);
if (textureView) {
let view = this.viewCache.get(textureView.key);
if (!view) {
view = this.createView({
baseMipLevel: textureView.baseMipLevel,
mipLevelCount: textureView.mipLevelCount,
baseArrayLayer: textureView.baseArrayLayer,
arrayLayerCount: textureView.arrayLayerCount
});
this.viewCache.set(textureView.key, view);
}
return view;
}
Debug.call(() => {
if (!this.view) {
Debug.errorOnce("View failed to be created for texture, texture is possibly destroyed", this);
}
});
return this.view;
}
createView(viewDescr) {
const options = viewDescr ?? {};
const textureDescr = this.desc;
const texture = this.texture;
const defaultViewDimension = () => {
if (texture.cubemap) return "cube";
if (texture.volume) return "3d";
if (texture.array) return "2d-array";
return "2d";
};
const desc = {
format: options.format ?? textureDescr.format,
dimension: options.dimension ?? defaultViewDimension(),
aspect: options.aspect ?? "all",
baseMipLevel: options.baseMipLevel ?? 0,
mipLevelCount: options.mipLevelCount ?? textureDescr.mipLevelCount,
baseArrayLayer: options.baseArrayLayer ?? 0,
arrayLayerCount: options.arrayLayerCount ?? textureDescr.depthOrArrayLayers
};
const view = this.gpuTexture.createView(desc);
DebugHelper.setLabel(view, `${viewDescr ? `CustomView${JSON.stringify(viewDescr)}` : "DefaultView"}:${this.texture.name}`);
return view;
}
// TODO: share a global map of samplers. Possibly even use shared samplers for bind group,
// or maybe even have some attached in view bind group and use globally
/**
* @param {any} device - The Graphics Device.
* @param {number} [sampleType] - A sample type for the sampler, SAMPLETYPE_*** constant. If not
* specified, the sampler type is based on the texture format / texture sampling type.
* @returns {any} - Returns the sampler.
*/
getSampler(device, sampleType) {
let sampler = this.samplers[sampleType];
if (!sampler) {
const texture = this.texture;
let label;
const desc = {
addressModeU: gpuAddressModes[texture.addressU],
addressModeV: gpuAddressModes[texture.addressV],
addressModeW: gpuAddressModes[texture.addressW]
};
if (!sampleType && texture.compareOnRead) {
sampleType = SAMPLETYPE_DEPTH;
}
if (sampleType === SAMPLETYPE_DEPTH || sampleType === SAMPLETYPE_INT || sampleType === SAMPLETYPE_UINT) {
desc.compare = "less";
desc.magFilter = "linear";
desc.minFilter = "linear";
label = "Compare";
} else if (sampleType === SAMPLETYPE_UNFILTERABLE_FLOAT) {
desc.magFilter = "nearest";
desc.minFilter = "nearest";
desc.mipmapFilter = "nearest";
label = "Unfilterable";
} else {
const forceNearest = !device.textureFloatFilterable && (texture.format === PIXELFORMAT_RGBA32F || texture.format === PIXELFORMAT_RGBA16F);
if (forceNearest || this.texture.format === PIXELFORMAT_DEPTHSTENCIL || isIntegerPixelFormat(this.texture.format)) {
desc.magFilter = "nearest";
desc.minFilter = "nearest";
desc.mipmapFilter = "nearest";
label = "Nearest";
} else {
desc.magFilter = gpuFilterModes[texture.magFilter].level;
desc.minFilter = gpuFilterModes[texture.minFilter].level;
desc.mipmapFilter = gpuFilterModes[texture.minFilter].mip;
Debug.call(() => {
label = `Texture:${texture.magFilter}-${texture.minFilter}-${desc.mipmapFilter}`;
});
}
}
const allLinear = desc.minFilter === "linear" && desc.magFilter === "linear" && desc.mipmapFilter === "linear";
desc.maxAnisotropy = allLinear ? math.clamp(Math.round(texture._anisotropy), 1, device.maxTextureAnisotropy) : 1;
sampler = device.wgpu.createSampler(desc);
DebugHelper.setLabel(sampler, label);
this.samplers[sampleType] = sampler;
}
return sampler;
}
loseContext() {
}
/**
* @param {WebgpuGraphicsDevice} device - The graphics device.
* @param {Texture} texture - The texture.
*/
uploadImmediate(device, texture) {
if (texture._needsUpload || texture._needsMipmapsUpload) {
Debug.assert(
!device.insideRenderPass,
`Texture.upload() for "${texture.name}" was called while inside a render pass, which is not currently supported. Move texture updates to the before() or after() function of the RenderPass.`
);
this.uploadData(device);
texture._needsUpload = false;
texture._needsMipmapsUpload = false;
}
}
/**
* @param {WebgpuGraphicsDevice} device - The graphics
* device.
*/
uploadData(device) {
const texture = this.texture;
if (this.desc && (this.desc.size.width !== texture.width || this.desc.size.height !== texture.height)) {
Debug.warnOnce(`Texture '${texture.name}' is being recreated due to dimension change from ${this.desc.size.width}x${this.desc.size.height} to ${texture.width}x${texture.height}. Consider creating the texture with correct dimensions to avoid recreation.`);
device.deferDestroy(this.gpuTexture);
this.create(device);
texture.renderVersionDirty = device.renderVersion;
}
if (texture._levels) {
let anyUploads = false;
let anyLevelMissing = false;
const requiredMipLevels = texture.numLevels;
for (let mipLevel = 0; mipLevel < requiredMipLevels; mipLevel++) {
const mipObject = texture._levels[mipLevel];
if (mipObject) {
if (texture.cubemap) {
for (let face = 0; face < 6; face++) {
const faceSource = mipObject[face];
if (faceSource) {
if (this.isExternalImage(faceSource)) {
this.uploadExternalImage(device, faceSource, mipLevel, face);
anyUploads = true;
} else if (ArrayBuffer.isView(faceSource)) {
this.uploadTypedArrayData(device, faceSource, mipLevel, face);
anyUploads = true;
} else {
Debug.error("Unsupported texture source data for cubemap face", faceSource);
}
} else {
anyLevelMissing = true;
}
}
} else if (texture._volume) {
Debug.warn("Volume texture data upload is not supported yet", this.texture);
} else if (texture.array) {
if (texture.arrayLength === mipObject.length) {
for (let index = 0; index < texture._arrayLength; index++) {
const arraySource = mipObject[index];
if (this.isExternalImage(arraySource)) {
this.uploadExternalImage(device, arraySource, mipLevel, index);
anyUploads = true;
} else if (ArrayBuffer.isView(arraySource)) {
this.uploadTypedArrayData(device, arraySource, mipLevel, index);
anyUploads = true;
} else {
Debug.error("Unsupported texture source data for texture array entry", arraySource);
}
}
} else {
anyLevelMissing = true;
}
} else {
if (this.isExternalImage(mipObject)) {
this.uploadExternalImage(device, mipObject, mipLevel, 0);
anyUploads = true;
} else if (ArrayBuffer.isView(mipObject)) {
this.uploadTypedArrayData(device, mipObject, mipLevel, 0);
anyUploads = true;
} else {
Debug.error("Unsupported texture source data", mipObject);
}
}
} else {
anyLevelMissing = true;
}
}
if (anyUploads && anyLevelMissing && texture.mipmaps && !isCompressedPixelFormat(texture.format) && !isIntegerPixelFormat(texture.format)) {
device.mipmapRenderer.generate(this);
}
if (texture._gpuSize) {
texture.adjustVramSizeTracking(device._vram, -texture._gpuSize);
}
texture._gpuSize = texture.gpuSize;
texture.adjustVramSizeTracking(device._vram, texture._gpuSize);
if (texture.releaseSourceAfterUpload) {
texture.releaseImageSources();
}
}
}
// image types supported by copyExternalImageToTexture
isExternalImage(image) {
return typeof ImageBitmap !== "undefined" && image instanceof ImageBitmap || typeof HTMLVideoElement !== "undefined" && image instanceof HTMLVideoElement || typeof HTMLCanvasElement !== "undefined" && image instanceof HTMLCanvasElement || typeof OffscreenCanvas !== "undefined" && image instanceof OffscreenCanvas;
}
uploadExternalImage(device, image, mipLevel, index) {
Debug.assert(mipLevel < this.desc.mipLevelCount, `Accessing mip level ${mipLevel} of texture with ${this.desc.mipLevelCount} mip levels`, this);
const src = {
source: image,
origin: [0, 0],
flipY: false
};
const dst = {
texture: this.gpuTexture,
mipLevel,
origin: [0, 0, index],
aspect: "all",
// can be: "all", "stencil-only", "depth-only"
premultipliedAlpha: this.texture._premultiplyAlpha
};
const copySize = {
width: this.desc.size.width,
height: this.desc.size.height,
depthOrArrayLayers: 1
// single layer
};
device.submit();
dummyUse(image instanceof HTMLCanvasElement && image.getContext("2d"));
Debug.trace(TRACEID_RENDER_QUEUE, `IMAGE-TO-TEX: mip:${mipLevel} index:${index} ${this.texture.name}`);
device.wgpu.queue.copyExternalImageToTexture(src, dst, copySize);
}
uploadTypedArrayData(device, data, mipLevel, index) {
const texture = this.texture;
const wgpu = device.wgpu;
const dest = {
texture: this.gpuTexture,
origin: [0, 0, index],
mipLevel
};
const width = TextureUtils.calcLevelDimension(texture.width, mipLevel);
const height = TextureUtils.calcLevelDimension(texture.height, mipLevel);
const byteSize = TextureUtils.calcLevelGpuSize(width, height, 1, texture.format);
Debug.assert(
byteSize === data.byteLength,
`Error uploading data to texture, the data byte size of ${data.byteLength} does not match required ${byteSize}`,
texture
);
const formatInfo = pixelFormatInfo.get(texture.format);
Debug.assert(formatInfo);
let dataLayout;
let size;
if (formatInfo.size) {
dataLayout = {
offset: 0,
bytesPerRow: formatInfo.size * width,
rowsPerImage: height
};
size = {
width,
height
};
} else if (formatInfo.blockSize) {
const blockDim = (size2) => {
return Math.floor((size2 + 3) / 4);
};
dataLayout = {
offset: 0,
bytesPerRow: formatInfo.blockSize * blockDim(width),
rowsPerImage: blockDim(height)
};
size = {
width: Math.max(4, width),
height: Math.max(4, height)
};
} else {
Debug.assert(false, `WebGPU does not yet support texture format ${formatInfo.name} for texture ${texture.name}`, texture);
}
device.submit();
Debug.trace(TRACEID_RENDER_QUEUE, `WRITE-TEX: mip:${mipLevel} index:${index} ${this.texture.name}`);
wgpu.queue.writeTexture(dest, data, dataLayout, size);
}
read(x, y, width, height, options) {
const mipLevel = options.mipLevel ?? 0;
const face = options.face ?? 0;
const data = options.data ?? null;
const immediate = options.immediate ?? false;
const texture = this.texture;
const formatInfo = pixelFormatInfo.get(texture.format);
Debug.assert(formatInfo);
Debug.assert(formatInfo.size);
const bytesPerRow = width * formatInfo.size;
const paddedBytesPerRow = math.roundUp(bytesPerRow, 256);
const size = paddedBytesPerRow * height;
const device = texture.device;
const stagingBuffer = device.createBufferImpl(BUFFERUSAGE_READ | BUFFERUSAGE_COPY_DST);
stagingBuffer.allocate(device, size);
const src = {
texture: this.gpuTexture,
mipLevel,
origin: [x, y, face]
};
const dst = {
buffer: stagingBuffer.buffer,
offset: 0,
bytesPerRow: paddedBytesPerRow
};
const copySize = {
width,
height,
depthOrArrayLayers: 1
// single layer
};
const commandEncoder = device.getCommandEncoder();
commandEncoder.copyTextureToBuffer(src, dst, copySize);
return device.readBuffer(stagingBuffer, size, null, immediate).then((temp) => {
const ArrayType = getPixelFormatArrayType(texture.format);
const targetBuffer = data?.buffer ?? new ArrayBuffer(height * bytesPerRow);
const target = new Uint8Array(targetBuffer, data?.byteOffset ?? 0, height * bytesPerRow);
for (let i = 0; i < height; i++) {
const srcOffset = i * paddedBytesPerRow;
const dstOffset = i * bytesPerRow;
target.set(temp.subarray(srcOffset, srcOffset + bytesPerRow), dstOffset);
}
return data ?? new ArrayType(targetBuffer);
});
}
}
export {
WebgpuTexture
};