gpu-curtains
Version:
gpu-curtains is a 3D WebGPU rendering engine. It can be used as a standalone 3D engine, but also includes extra classes focused on mapping 3d objects to DOM elements; It allows users to synchronize values such as position, sizing, or scale between them.
520 lines (510 loc) • 19.9 kB
JavaScript
import { throwError, throwWarning } from "../../utils/utils.mjs";
import { PipelineManager } from "../pipelines/PipelineManager.mjs";
//#region src/core/renderers/GPUDeviceManager.ts
/**
* Responsible for the WebGPU {@link GPUAdapter | adapter} and {@link GPUDevice | device} creations, losing and restoration.
*
* It will create all the GPU objects that need a {@link GPUDevice | device} to do so, as well as a {@link PipelineManager}. It will also keep a track of all the {@link Renderer}, {@link AllowedBindGroups | bind groups}, {@link Sampler}, {@link MediaTexture} and {@link GPUBuffer | GPU buffers} created.
*
* The {@link GPUDeviceManager} is also responsible for creating the {@link GPUCommandBuffer}, rendering all the {@link Renderer} and then submitting the {@link GPUCommandBuffer} at each {@link GPUDeviceManager#render | render} calls.
*/
var GPUDeviceManager = class {
/** @ignore */
#mipsGeneration;
/**
* GPUDeviceManager constructor
* @param parameters - {@link GPUDeviceManagerParams | parameters} used to create this {@link GPUDeviceManager}.
*/
constructor({ label, production = false, adapterOptions = {}, requiredFeatures = [], requestAdapterLimits = [], autoRender = true, onError = (message) => {}, onDeviceLost = (info) => {}, onDeviceDestroyed = (info) => {} } = {}) {
this._onBeforeRenderCallback = () => {};
this._onAfterRenderCallback = () => {};
this.index = 0;
this.ready = false;
this.options = {
label: label ?? "GPUDeviceManager instance",
production,
adapterOptions,
requiredFeatures,
requestAdapterLimits,
autoRender
};
this.onError = onError;
this.onDeviceLost = onDeviceLost;
this.onDeviceDestroyed = onDeviceDestroyed;
this.gpu = navigator.gpu;
this.setPipelineManager();
this.setDeviceObjects();
this.#mipsGeneration = {
sampler: null,
module: null,
pipelineByFormatAndView: {}
};
if (this.options.autoRender) this.animate();
}
/**
* Set our {@link adapter} and {@link device} if possible.
* @param parameters - {@link GPUAdapter} and/or {@link GPUDevice} to use if set.
*/
async setAdapterAndDevice({ adapter = null, device = null } = {}) {
await this.setAdapter(adapter);
await this.setDevice(device);
}
/**
* Set up our {@link adapter} and {@link device} and all the already created {@link renderers} contexts.
* @param parameters - {@link GPUAdapter} and/or {@link GPUDevice} to use if set. Allow to use already created adapter and device.
*/
async init({ adapter = null, device = null } = {}) {
await this.setAdapterAndDevice({
adapter,
device
});
if (this.device) {
this.#createSamplers();
for (const renderer of this.renderers) if (!renderer.context) renderer.setContext();
}
}
/**
* Set our {@link GPUDeviceManager.adapter | adapter} if possible.
* The adapter represents a specific GPU. Some devices have multiple GPUs.
* @param adapter - {@link GPUAdapter} to use if set.
*/
async setAdapter(adapter = null) {
if (!this.gpu) {
const errorMessage = `GPUDeviceManager (${this.options.label}): WebGPU is not supported on your browser/OS No 'gpu' object in 'navigator'.`;
this.onError(errorMessage);
throwError(errorMessage);
}
if (adapter) this.adapter = adapter;
else try {
this.adapter = await this.gpu?.requestAdapter(this.options.adapterOptions);
if (!this.adapter) throwError(`GPUDeviceManager (${this.options.label}): WebGPU is not supported on your browser/OS 'requestAdapter' failed.`);
} catch (e) {
this.onError(e.message);
}
}
/**
* Set our {@link GPUDeviceManager.device | device}.
* @param device - {@link GPUDevice} to use if set.
*/
async setDevice(device = null) {
if (!this.adapter) return;
if (device) {
this.device = device;
this.ready = true;
this.index++;
} else try {
const { limits, features } = this.adapter;
if (this.options.adapterOptions.featureLevel === "compatibility") this.options.requestAdapterLimits.push("maxStorageBuffersInVertexStage", "maxStorageTexturesInVertexStage", "maxStorageBuffersInFragmentStage", "maxStorageTexturesInFragmentStage");
const requiredLimits = {};
for (const key in limits) if (this.options.requestAdapterLimits.includes(key)) requiredLimits[key] = limits[key];
const requiredFeatures = [];
this.options.requiredFeatures.forEach((feature) => {
if (features.has(feature)) requiredFeatures.push(feature);
});
this.device = await this.adapter?.requestDevice({
label: this.options.label + " " + this.index,
requiredFeatures,
requiredLimits
});
if (this.device) {
this.device.lost.then((info) => {
throwWarning(`GPUDeviceManager (${this.options.label}): WebGPU device was lost: ${info.message}`);
this.loseDevice();
if (info.reason !== "destroyed") this.onDeviceLost(info);
else this.onDeviceDestroyed(info);
});
this.device.addEventListener("uncapturederror", (event) => {
this.ready = false;
const errorMessage = `GPUDeviceManager (${this.options.label}): Uncaptured WebGPU device error: ${event.error.message}`;
this.onError(errorMessage);
throwError(errorMessage);
});
this.ready = true;
this.index++;
} else throwError(`GPUDeviceManager (${this.options.label}): WebGPU is not supported on your browser/OS 'requestDevice' failed.`);
} catch (e) {
this.onError(e.message);
}
}
/**
* Set our {@link pipelineManager | pipeline manager}.
*/
setPipelineManager() {
this.pipelineManager = new PipelineManager();
}
/**
* Called when the {@link device} is lost.
* Reset all our renderers.
*/
loseDevice() {
this.ready = false;
this.pipelineManager.resetCurrentPipeline();
const usedPipelineEntries = /* @__PURE__ */ new Set();
this.deviceRenderedObjects.forEach((object) => {
if (object.material && object.material.pipelineEntry) usedPipelineEntries.add(object.material.pipelineEntry.uuid);
});
this.pipelineManager.pipelineEntries = this.pipelineManager.pipelineEntries.filter((pipelineEntry) => usedPipelineEntries.has(pipelineEntry.uuid));
this.samplers.forEach((sampler) => sampler.sampler = null);
this.renderers.forEach((renderer) => renderer.loseContext());
this.bindGroupLayouts.clear();
this.buffers.clear();
this.#mipsGeneration = {
sampler: null,
module: null,
pipelineByFormatAndView: {}
};
}
/**
* Called when the {@link device} should be restored.
* Restore all our renderers.
* @param parameters - {@link GPUAdapter} and/or {@link GPUDevice} to use if set.
*/
async restoreDevice({ adapter = null, device = null } = {}) {
await this.setAdapterAndDevice({
adapter,
device
});
if (this.device) {
this.#createSamplers();
this.indirectBuffers.forEach((indirectBuffer) => indirectBuffer.create());
this.renderers.forEach((renderer) => renderer.restoreContext());
}
}
/**
* Set all objects arrays that we'll keep track of.
*/
setDeviceObjects() {
this.renderers = [];
this.bindGroups = /* @__PURE__ */ new Map();
this.buffers = /* @__PURE__ */ new Map();
this.indirectBuffers = /* @__PURE__ */ new Map();
this.bindGroupLayouts = /* @__PURE__ */ new Map();
this.bufferBindings = /* @__PURE__ */ new Map();
this.samplers = [];
this.texturesQueue = [];
}
/**
* Add a {@link Renderer} to our {@link renderers} array.
* @param renderer - {@link Renderer} to add.
*/
addRenderer(renderer) {
this.renderers.push(renderer);
}
/**
* Remove a {@link Renderer} from our {@link renderers} array.
* @param renderer - {@link Renderer} to remove.
*/
removeRenderer(renderer) {
this.renderers = this.renderers.filter((r) => r.uuid !== renderer.uuid);
}
/**
* Get all the rendered objects (i.e. compute passes, meshes, ping pong planes and shader passes) created by this {@link GPUDeviceManager}.
* @readonly
*/
get deviceRenderedObjects() {
return this.renderers.map((renderer) => renderer.renderedObjects).flat();
}
/**
* Add a {@link AllowedBindGroups | bind group} to our {@link bindGroups | bind groups array}.
* @param bindGroup - {@link AllowedBindGroups | bind group} to add.
*/
addBindGroup(bindGroup) {
this.bindGroups.set(bindGroup.uuid, bindGroup);
}
/**
* Remove a {@link AllowedBindGroups | bind group} from our {@link bindGroups | bind groups array}.
* @param bindGroup - {@link AllowedBindGroups | bind group} to remove.
*/
removeBindGroup(bindGroup) {
this.bindGroups.delete(bindGroup.uuid);
}
/**
* Add a {@link GPUBuffer} to our our {@link buffers} array.
* @param buffer - {@link Buffer} to add.
*/
addBuffer(buffer) {
this.buffers.set(buffer.uuid, buffer);
}
/**
* Remove a {@link Buffer} from our {@link buffers} Map.
* @param buffer - {@link Buffer} to remove.
*/
removeBuffer(buffer) {
this.buffers.delete(buffer?.uuid);
}
/**
* Create or recreate (on device restoration) all {@link samplers}.
* @private
*/
#createSamplers() {
this.samplers.forEach((sampler) => {
sampler.createSampler();
});
}
/**
* Add a {@link Sampler} to our {@link samplers} array.
* @param sampler - {@link Sampler} to add.
*/
addSampler(sampler) {
this.samplers.push(sampler);
}
/**
* Remove a {@link Sampler} from our {@link samplers} array.
* @param sampler - {@link Sampler} to remove.
*/
removeSampler(sampler) {
this.samplers = this.samplers.filter((s) => s.uuid !== sampler.uuid);
}
/**
* Copy an external image to the GPU.
* @param source - {@link https://gpuweb.github.io/types/interfaces/GPUCopyExternalImageSourceInfo.html | GPUCopyExternalImageSourceInfo (WebGPU API reference)} to use.
* @param destination - {@link https://gpuweb.github.io/types/interfaces/GPUCopyExternalImageDestInfo.html | GPUCopyExternalImageDestInfo (WebGPU API reference)} to use.
* @param copySize - {@link https://gpuweb.github.io/types/types/GPUExtent3DStrict.html | GPUExtent3DStrict (WebGPU API reference)} to use.
*/
copyExternalImageToTexture(source, destination, copySize) {
this.device?.queue.copyExternalImageToTexture(source, destination, copySize);
}
/**
* Upload a {@link MediaTexture#texture | texture} to the GPU.
* @param texture - {@link MediaTexture} containing the {@link GPUTexture} to upload.
* @param sourceIndex - Index of the source to upload (for cube maps). Default to `0`.
*/
uploadTexture(texture, sourceIndex = 0) {
if ("sources" in texture && texture.sources.length) try {
console.log(texture.sources[sourceIndex].source);
this.device?.queue.copyExternalImageToTexture({
source: texture.sources[sourceIndex].source,
flipY: texture.options.flipY
}, {
texture: texture.texture,
premultipliedAlpha: texture.options.premultipliedAlpha,
aspect: texture.options.aspect,
colorSpace: texture.options.colorSpace,
origin: [
0,
0,
sourceIndex
]
}, {
width: texture.size.width,
height: texture.size.height,
depthOrArrayLayers: 1
});
if (texture.texture.mipLevelCount > 1) this.generateMips(texture);
this.texturesQueue.push({
sourceIndex,
texture
});
} catch ({ message }) {
throwError(`GPUDeviceManager (${this.options.label}): could not upload texture: ${texture.options.name} because: ${message}`);
}
else for (let i = 0; i < texture.size.depth; i++) this.device?.queue.writeTexture({
texture: texture.texture,
origin: [
0,
0,
i
]
}, new Uint8Array(texture.options.placeholderColor), { bytesPerRow: texture.size.width * 4 }, {
width: 1,
height: 1,
depthOrArrayLayers: 1
});
}
/**
* Mips generation helper on the GPU using our {@link device}. Caches sampler, module and pipeline (by {@link GPUTexture} formats) for faster generation.
* Ported from https://webgpufundamentals.org/webgpu/lessons/webgpu-importing-textures.html
* @param texture - {@link Texture} for which to generate the mips.
* @param commandEncoder - optional {@link GPUCommandEncoder} to use if we're already in the middle of a command encoding process.
*/
generateMips(texture, commandEncoder = null) {
if (!this.device) return;
if (!this.#mipsGeneration.module) {
this.#mipsGeneration.module = this.device.createShaderModule({
label: "textured quad shaders for mip level generation",
code: `
const faceMat = array(
mat3x3f( 0, 0, -2, 0, -2, 0, 1, 1, 1), // pos-x
mat3x3f( 0, 0, 2, 0, -2, 0, -1, 1, -1), // neg-x
mat3x3f( 2, 0, 0, 0, 0, 2, -1, 1, -1), // pos-y
mat3x3f( 2, 0, 0, 0, 0, -2, -1, -1, 1), // neg-y
mat3x3f( 2, 0, 0, 0, -2, 0, -1, 1, 1), // pos-z
mat3x3f(-2, 0, 0, 0, -2, 0, 1, 1, -1) // neg-z
);
struct VSOutput {
position: vec4f,
texcoord: vec2f,
baseArrayLayer: u32,
};
fn vs(
vertexIndex : u32,
baseArrayLayer: u32,
) -> VSOutput {
let pos = array(
vec2f( 0.0, 0.0), // center
vec2f( 1.0, 0.0), // right, center
vec2f( 0.0, 1.0), // center, top
// 2st triangle
vec2f( 0.0, 1.0), // center, top
vec2f( 1.0, 0.0), // right, center
vec2f( 1.0, 1.0), // right, top
);
var vsOutput: VSOutput;
let xy = pos[vertexIndex];
vsOutput.position = vec4f(xy * 2.0 - 1.0, 0.0, 1.0);
vsOutput.texcoord = vec2f(xy.x, 1.0 - xy.y);
vsOutput.baseArrayLayer = baseArrayLayer;
return vsOutput;
}
var ourSampler: sampler;
var ourTexture2d: texture_2d<f32>;
fn fs2d(fsInput: VSOutput) -> vec4f {
return textureSample(ourTexture2d, ourSampler, fsInput.texcoord);
}
var ourTexture2dArray: texture_2d_array<f32>;
fn fs2darray(fsInput: VSOutput) -> vec4f {
return textureSample(
ourTexture2dArray,
ourSampler,
fsInput.texcoord,
fsInput.baseArrayLayer);
}
var ourTextureCube: texture_cube<f32>;
fn fscube(fsInput: VSOutput) -> vec4f {
return textureSample(
ourTextureCube,
ourSampler,
faceMat[fsInput.baseArrayLayer] * vec3f(fract(fsInput.texcoord), 1));
}
`
});
this.#mipsGeneration.sampler = this.device.createSampler({
minFilter: "linear",
magFilter: "linear"
});
}
const textureBindingViewDimension = texture.texture.textureBindingViewDimension ?? "2d-array";
if (!this.#mipsGeneration.pipelineByFormatAndView[texture.texture.format + textureBindingViewDimension]) {
const entryPoint = `fs${textureBindingViewDimension.replace(/[\W]/, "")}`;
this.#mipsGeneration.pipelineByFormatAndView[texture.texture.format + textureBindingViewDimension] = this.device.createRenderPipeline({
label: `Mip level generator pipeline for ${textureBindingViewDimension}, format: ${texture.texture.format}`,
layout: "auto",
vertex: { module: this.#mipsGeneration.module },
fragment: {
module: this.#mipsGeneration.module,
entryPoint,
targets: [{ format: texture.texture.format }]
}
});
}
const pipeline = this.#mipsGeneration.pipelineByFormatAndView[texture.texture.format + textureBindingViewDimension];
const encoder = commandEncoder || this.device.createCommandEncoder({ label: "Mip gen encoder" });
for (let baseMipLevel = 1; baseMipLevel < texture.texture.mipLevelCount; ++baseMipLevel) for (let layer = 0; layer < texture.texture.depthOrArrayLayers; ++layer) {
const bindGroup = this.device.createBindGroup({
layout: pipeline.getBindGroupLayout(0),
entries: [{
binding: 0,
resource: this.#mipsGeneration.sampler
}, {
binding: 1,
resource: texture.texture.createView({
dimension: textureBindingViewDimension,
baseMipLevel: baseMipLevel - 1,
mipLevelCount: 1
})
}]
});
const renderPassDescriptor = {
label: "Mip generation render pass",
colorAttachments: [{
view: texture.texture.createView({
dimension: "2d",
baseMipLevel,
mipLevelCount: 1,
baseArrayLayer: layer,
arrayLayerCount: 1
}),
loadOp: "clear",
storeOp: "store"
}]
};
const pass = encoder.beginRenderPass(renderPassDescriptor);
pass.setPipeline(pipeline);
pass.setBindGroup(0, bindGroup);
pass.draw(6, 1, 0, layer);
pass.end();
}
if (!commandEncoder) {
const commandBuffer = encoder.finish();
this.device.queue.submit([commandBuffer]);
}
}
/**
* Create a requestAnimationFrame loop and run it.
*/
animate() {
this.render();
this.animationFrameID = requestAnimationFrame(this.animate.bind(this));
}
/**
* Called each frame before rendering.
* @param callback - callback to run at each render.
* @returns - our {@link GPUDeviceManager}.
*/
onBeforeRender(callback) {
if (callback) this._onBeforeRenderCallback = callback;
return this;
}
/**
* Called each frame after rendering.
* @param callback - callback to run at each render.
* @returns - our {@link GPUDeviceManager}.
*/
onAfterRender(callback) {
if (callback) this._onAfterRenderCallback = callback;
return this;
}
/**
* Render everything:
* - call all our {@link onBeforeRender} callback.
* - call all our {@link renderers} {@link core/renderers/GPURenderer.GPURenderer#onBeforeCommandEncoder | onBeforeCommandEncoder} callbacks.
* - create a {@link GPUCommandEncoder}.
* - render all our {@link renderers}.
* - submit our {@link GPUCommandBuffer}.
* - upload {@link MediaTexture#texture | MediaTexture textures} that need it.
* - empty our {@link texturesQueue} array.
* - call all our {@link renderers} {@link core/renderers/GPURenderer.GPURenderer#onAfterCommandEncoder | onAfterCommandEncoder} callbacks.
* - call all our {@link onAfterRender} callback.
*/
render() {
if (!this.ready) return;
this._onBeforeRenderCallback && this._onBeforeRenderCallback();
for (const renderer of this.renderers) if (renderer.shouldRender) renderer.onBeforeCommandEncoder();
const commandEncoder = this.device?.createCommandEncoder({ label: this.options.label + " command encoder" });
!this.options.production && commandEncoder.pushDebugGroup(this.options.label + " command encoder: main render loop");
this.renderers.forEach((renderer) => renderer.render(commandEncoder));
!this.options.production && commandEncoder.popDebugGroup();
const commandBuffer = commandEncoder.finish();
this.device?.queue.submit([commandBuffer]);
for (const texture of this.texturesQueue) texture.texture.setSourceUploaded(texture.sourceIndex);
this.texturesQueue = [];
for (const renderer of this.renderers) if (renderer.shouldRender) renderer.onAfterCommandEncoder();
this._onAfterRenderCallback && this._onAfterRenderCallback();
}
/**
* Destroy the {@link GPUDeviceManager} and its {@link renderers}.
*/
destroy() {
if (this.animationFrameID) cancelAnimationFrame(this.animationFrameID);
this.animationFrameID = null;
this.device?.destroy();
this.device = null;
this.renderers.forEach((renderer) => renderer.destroy());
this.bindGroups.forEach((bindGroup) => bindGroup.destroy());
this.buffers.forEach((buffer) => buffer?.destroy());
this.indirectBuffers.forEach((indirectBuffer) => indirectBuffer.destroy());
this.setDeviceObjects();
}
};
//#endregion
export { GPUDeviceManager };