UNPKG

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
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 { @builtin(position) position: vec4f, @location(0) texcoord: vec2f, @location(1) @interpolate(flat, either) baseArrayLayer: u32, }; @vertex fn vs( @builtin(vertex_index) vertexIndex : u32, @builtin(instance_index) 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; } @group(0) @binding(0) var ourSampler: sampler; @group(0) @binding(1) var ourTexture2d: texture_2d<f32>; @fragment fn fs2d(fsInput: VSOutput) -> @location(0) vec4f { return textureSample(ourTexture2d, ourSampler, fsInput.texcoord); } @group(0) @binding(1) var ourTexture2dArray: texture_2d_array<f32>; @fragment fn fs2darray(fsInput: VSOutput) -> @location(0) vec4f { return textureSample( ourTexture2dArray, ourSampler, fsInput.texcoord, fsInput.baseArrayLayer); } @group(0) @binding(1) var ourTextureCube: texture_cube<f32>; @fragment fn fscube(fsInput: VSOutput) -> @location(0) 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 };