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.
447 lines (446 loc) • 17.4 kB
JavaScript
import { generateUUID, throwWarning } from "../../utils/utils.mjs";
import { isRenderer } from "../renderers/utils.mjs";
import { BufferBinding } from "../bindings/BufferBinding.mjs";
import { MediaTexture } from "../textures/MediaTexture.mjs";
import { IndirectBuffer } from "../../extras/buffers/IndirectBuffer.mjs";
import { FullscreenPlane } from "../meshes/FullscreenPlane.mjs";
//#region src/core/renderPasses/RenderBundle.ts
let bundleIndex = 0;
/**
* Used to create a {@link GPURenderBundle} and its associated {@link GPURenderBundleEncoder}.
*
* Render bundle are a powerful tool that can significantly reduce the amount of CPU time spent issuing repeated rendered commands. In other words, it can be used to draw given set of meshes that share the same {@link RenderPass | output target} faster (up to 1.5x in some cases) and with less CPU overhead.
*
* The main drawback is that {@link RenderBundle} works best when the number of meshes drawn is known in advance and is not subject to change.
*
* @example
* ```javascript
* const nbMeshes = 100
*
* // assuming 'renderer' is a valid renderer or curtains instance
* const renderBundle = new RenderBundle(renderer, {
* label: 'Custom render bundle',
* size: nbMeshes,
* useBuffer: true, // use a single buffer to handle all 100 meshes transformations
* })
*
* for (let i = 0; i < nbMeshes; i++) {
* const mesh = new Mesh(renderer, {
* label: 'Cube ' + i,
* geometry: new BoxGeometry(),
* renderBundle,
* })
*
* mesh.onBeforeRender(() => {
* mesh.rotation.y += 0.02
* })
* }
* ```
*/
var RenderBundle = class {
/** @ignore */
#useProjection;
/** @ignore */
#ready;
/**
* RenderBundle constructor
* @param renderer - {@link Renderer} or {@link GPUCurtains} class object used to create this {@link RenderBundle}.
* @param parameters - {@link RenderBundleParams | parameters} use to create this {@link RenderBundle}.
*/
constructor(renderer, { label, renderPass = null, renderOrder = 0, transparent = null, visible = true, size = 0, useBuffer = false, useIndirectDraw = false } = {}) {
this.type = "RenderBundle";
renderer = isRenderer(renderer, this.type);
this.uuid = generateUUID();
Object.defineProperty(this, "index", { value: bundleIndex++ });
this.renderOrder = renderOrder;
this.transparent = transparent;
this.visible = visible;
label = label ?? this.type + this.index;
this.options = {
label,
renderPass,
useBuffer,
size,
useIndirectDraw
};
this.meshes = /* @__PURE__ */ new Map();
this.encoder = null;
this.bundle = null;
this.#ready = false;
this.binding = null;
this.indirectBuffer = null;
this.setRenderer(renderer);
if (this.options.useIndirectDraw) this.indirectBuffer = new IndirectBuffer(this.renderer);
if (this.options.useBuffer) {
this.#useProjection = true;
if (this.options.size !== 0) this.#setBinding();
else {
this.options.useBuffer = false;
if (!this.renderer.production) throwWarning(`${this.options.label} (${this.type}): Cannot use a single transformation buffer if the size parameter has not been set upon creation.`);
}
}
}
/**
* Set the {@link RenderBundle} {@link RenderBundle.renderer | renderer} and eventually remove/add to the {@link core/scenes/Scene.Scene | Scene}.
* @param renderer - new {@link Renderer} to use.
*/
setRenderer(renderer) {
if (this.renderer) {
this.removeFromScene();
this.renderer.renderBundles.delete(this.uuid);
}
this.renderer = renderer;
this.renderer.renderBundles.set(this.uuid, this);
if (this.meshes.size >= 1) this.addToScene();
}
/**
* Add our {@link RenderBundle} to the {@link core/scenes/Scene.Scene | Scene}.
* Once we have at least one mesh in our {@link meshes} Map, we can add the {@link RenderBundle} to the {@link core/scenes/Scene.Scene | Scene} at the right place.
*/
addToScene() {
const firstEntry = this.meshes.entries().next();
if (firstEntry && firstEntry.value && firstEntry.value.length && firstEntry.value[1]) {
const mesh = firstEntry.value[1];
const isTransparent = !!mesh.transparent;
if (this.transparent === null) this.transparent = isTransparent;
if (!(mesh instanceof FullscreenPlane && mesh.constructor !== FullscreenPlane)) {
const { useProjection } = mesh.material.options.rendering;
if (this.useProjection === null) this.useProjection = useProjection;
const projectionStack = this.renderer.scene.getMeshProjectionStack(mesh);
this.renderer.scene.addRenderBundle(this, projectionStack);
} else {
this.size = 1;
mesh.renderOrder = this.renderOrder;
this.useProjection = false;
}
}
}
/**
* Remove our {@link RenderBundle} from the {@link core/scenes/Scene.Scene | Scene}.
*/
removeFromScene() {
this.renderer.scene.removeRenderBundle(this);
}
/**
* Get whether our {@link RenderBundle} handles {@link core/renderers/GPURenderer.ProjectedMesh | projected meshes} or not (useful to know in which {@link core/scenes/Scene.Scene | Scene} stack it has been added.
* @readonly
* @returns - Whether our {@link RenderBundle} handles {@link core/renderers/GPURenderer.ProjectedMesh | projected meshes} or not.
*/
get useProjection() {
return this.#useProjection;
}
/**
* Set whether our {@link RenderBundle} handles {@link core/renderers/GPURenderer.ProjectedMesh | projected meshes} or not.
* @param value - New projection value.
*/
set useProjection(value) {
this.#useProjection = value;
}
/**
* Set the {@link binding} and patches its array and buffer size if needed.
* @private
*/
#setBinding() {
this.binding = new BufferBinding({
label: this.options.label + " matrices",
name: "matrices",
visibility: ["vertex", "fragment"],
struct: {
model: {
type: "array<mat4x4f>",
value: new Float32Array(16 * this.options.size)
},
modelView: {
type: "array<mat4x4f>",
value: new Float32Array(16 * this.options.size)
},
normal: {
type: "array<mat3x3f>",
value: new Float32Array(12 * this.options.size)
}
}
});
this.#patchBindingOffset(this.options.size);
}
/**
* Path the {@link binding} array and buffer size with the minimum {@link core/renderers/GPURenderer.GPURenderer#device | device} buffer offset alignment.
* @param size - new {@link binding} size to use.
* @private
*/
#patchBindingOffset(size) {
const minOffset = this.renderer.device?.limits.minUniformBufferOffsetAlignment || 256;
if (this.binding.arrayBufferSize < size * minOffset) {
this.binding.arrayBufferSize = size * minOffset;
this.binding.arrayBuffer = new ArrayBuffer(this.binding.arrayBufferSize);
this.binding.arrayView = new DataView(this.binding.arrayBuffer, 0, this.binding.arrayBufferSize);
this.binding.buffer.size = this.binding.arrayBuffer.byteLength;
}
}
/**
* Called each time the {@link RenderBundle} size has actually changed.
* @param newSize - new {@link RenderBundle} size to set.
* @private
*/
#onSizeChanged(newSize) {
if (newSize > this.options.size && this.binding) {
this.#patchBindingOffset(newSize);
let offset = 0;
this.meshes.forEach((mesh) => {
mesh.patchRenderBundleBinding(offset);
offset++;
});
if (this.binding.buffer.GPUBuffer) {
this.binding.buffer.GPUBuffer.destroy();
this.binding.buffer.createBuffer(this.renderer, {
label: this.binding.options.label,
usage: [...[
"copySrc",
"copyDst",
this.binding.bindingType
], ...this.binding.options.usage]
});
this.binding.shouldUpdate = true;
}
}
}
/**
* Set the new {@link RenderBundle} size. Should be used before adding or removing {@link meshes} to the {@link RenderBundle} if the {@link bundle} has already been created (especially if it's using a {@link binding}).
* @param value - New size to set.
*/
set size(value) {
if (value !== this.options.size) {
if (this.ready && !this.renderer.production) throwWarning(`${this.options.label} (${this.type}): The content of a render bundle is meant to be static. You should not change its size after it has been created.`);
this.ready = false;
this.#onSizeChanged(value);
this.options.size = value;
}
}
/**
* Get whether our {@link RenderBundle} is ready.
* @readonly
* @returns - Whether our {@link RenderBundle} is ready.
*/
get ready() {
return this.renderer.ready && this.#ready;
}
/**
* Set whether our {@link RenderBundle} is ready and encode it if needed.
* @param value - New ready state.
*/
set ready(value) {
if (value && !this.ready) {
const init = () => {
this.size = this.meshes.size;
if (this.options.useIndirectDraw) {
this.meshes.forEach((mesh) => {
this.indirectBuffer.addGeometry(mesh.geometry);
});
this.indirectBuffer.create();
}
this.#encodeRenderCommands();
this.#ready = value;
};
if (this.renderer.device) init();
else {
const taskId = this.renderer.onBeforeCommandEncoderCreation.add(() => {
if (this.renderer.device) {
this.renderer.onBeforeCommandEncoderCreation.remove(taskId);
init();
}
}, { once: false });
}
} else if (!value && this.#ready) {
this.bundle = null;
this.#ready = value;
}
}
/**
* Called by the {@link core/scenes/Scene.Scene | Scene} to eventually add a {@link RenderedMesh | mesh} to this {@link RenderBundle}. Can set the {@link RenderBundleOptions#renderPass | render pass} if needed. If the {@link RenderBundleOptions#renderPass | render pass} is already set and the mesh output {@link RenderPass} does not match, it won't be added.
* @param mesh - {@link RenderedMesh | Mesh} to eventually add.
* @param outputPass - The mesh output {@link RenderPass}.
*/
addMesh(mesh, outputPass) {
if (!this.options.renderPass) this.options.renderPass = outputPass;
else if (outputPass.uuid !== this.options.renderPass.uuid) {
if (!this.renderer.production) throwWarning(`${this.options.label} (${this.type}): Cannot add Mesh ${mesh.options.label} to this render bundle because the output render passes do not match.`);
mesh.renderBundle = null;
return;
}
if (mesh.options.stencil) {
if (!this.renderer.production) throwWarning(`${this.options.label} (${this.type}): Cannot add Mesh ${mesh.options.label} to this render bundle because stencil operations are not supported by render bundles.`);
mesh.renderBundle = null;
return;
}
if (this.ready && !this.renderer.production) throwWarning(`${this.options.label} (${this.type}): The content of a render bundle is meant to be static. You should not add meshes to it after it has been created (mesh added: ${mesh.options.label}).`);
this.ready = false;
this.meshes.set(mesh.uuid, mesh);
if (this.meshes.size === 1) this.addToScene();
}
/**
* Remove any {@link RenderedMesh | rendered mesh} from this {@link RenderBundle}.
* @param mesh - {@link RenderedMesh | Mesh} to remove.
*/
removeSceneObject(mesh) {
if (this.ready && !this.renderer.production) throwWarning(`${this.options.label} (${this.type}): The content of a render bundle is meant to be static. You should not remove meshes from it after it has been created (mesh removed: ${mesh.options.label}).`);
this.ready = false;
this.meshes.delete(mesh.uuid);
mesh.setRenderBundle(null, false);
if (this.options.useIndirectDraw) mesh.geometry.indirectDraw = null;
}
/**
* Remove a {@link SceneStackedMesh | scene stacked mesh} from this {@link RenderBundle}.
* @param mesh - {@link SceneStackedMesh | Scene stacked mesh} to remove.
* @param keepMesh - Whether to preserve the mesh in order to render it normally again. Default to `true`.
*/
removeMesh(mesh, keepMesh = true) {
this.removeSceneObject(mesh);
const isPostOrPingPongPass = mesh instanceof FullscreenPlane && mesh.constructor !== FullscreenPlane;
if (keepMesh && !isPostOrPingPongPass) this.renderer.scene.addMesh(mesh);
if (this.meshes.size === 0) this.renderer.scene.removeRenderBundle(this);
}
/**
* Set the {@link descriptor} based on the {@link RenderBundleOptions#renderPass | render pass}.
* @private
*/
#setDescriptor() {
this.descriptor = {
...this.options.renderPass.options.colorAttachments && { colorFormats: this.options.renderPass.options.colorAttachments.map((colorAttachment) => colorAttachment.targetFormat) },
...this.options.renderPass.options.useDepth && { depthStencilFormat: this.options.renderPass.options.depthFormat },
sampleCount: this.options.renderPass.options.sampleCount
};
}
/**
* Create the {@link descriptor}, {@link encoder} and {@link bundle} used by this {@link RenderBundle}.
* @private
*/
#encodeRenderCommands() {
this.#setDescriptor();
this.renderer.pipelineManager.resetCurrentPipeline();
this.encoder = this.renderer.device.createRenderBundleEncoder({
...this.descriptor,
label: this.options.label + " (encoder)"
});
if (!this.renderer.production) this.encoder.pushDebugGroup(`${this.options.label}: create encoder`);
let offset = 0;
this.meshes.forEach((mesh) => {
if (mesh.visible) {
mesh.material.render(this.encoder);
mesh.geometry.render(this.encoder);
}
offset++;
});
if (!this.renderer.production) this.encoder.popDebugGroup();
this.bundle = this.encoder.finish({ label: this.options.label + " (bundle)" });
this.renderer.pipelineManager.resetCurrentPipeline();
}
/**
* Update the {@link binding} buffer if needed.
*/
updateBinding() {
if (this.binding && this.binding.shouldUpdate && this.binding.buffer.GPUBuffer) {
this.renderer.queueWriteBuffer(this.binding.buffer.GPUBuffer, 0, this.binding.arrayBuffer);
this.binding.shouldUpdate = false;
}
}
/**
* If one of the {@link meshes} is using a {@link core/textures/Texture.Texture | Texture} dependent of the {@link renderer}, invalidate the {@link RenderBundle} in order to resize the {@link core/textures/Texture.Texture | Texture}.
*/
resize() {
for (const [_uuid, mesh] of this.meshes) if (mesh.textures.find((texture) => !texture.options.fixedSize)) {
this.ready = false;
break;
}
}
/**
* Render the {@link RenderBundle}.
*
* If it is ready, execute each {@link core/meshes/Mesh.Mesh.onBeforeRenderPass | mesh onBeforeRenderPass method}, {@link updateBinding | update the binding} if needed, execute the {@link bundle} and finally execute each {@link core/meshes/Mesh.Mesh.onAfterRenderPass | mesh onAfterRenderPass method}.
*
* If not, just render its {@link meshes} as usual and check whether they are all ready and if we can therefore encode our {@link RenderBundle}.
* @param pass - {@link GPURenderPassEncoder} to use.
*/
render(pass) {
if (!this.renderer.ready) return;
let shouldRender = false;
for (const [_uuid, mesh] of this.meshes) if (mesh.visible) {
shouldRender = true;
break;
}
if (this.ready && this.bundle && this.visible && shouldRender) {
this.meshes.forEach((mesh) => {
mesh.onBeforeRenderPass();
});
this.updateBinding();
this.renderer.pipelineManager.resetCurrentPipeline();
if (!this.renderer.production) pass.pushDebugGroup(`${this.options.label}: execute bundle`);
pass.executeBundles([this.bundle]);
if (!this.renderer.production) pass.popDebugGroup();
this.renderer.pipelineManager.resetCurrentPipeline();
this.meshes.forEach((mesh) => {
mesh.onAfterRenderPass();
});
}
let index = 0;
if (!this.ready) {
let isReady = true;
for (const [_key, mesh] of this.meshes) {
mesh.render(pass);
if (!mesh.ready) isReady = false;
for (const texture of mesh.textures) if (texture instanceof MediaTexture && !texture.sourcesUploaded) isReady = false;
index++;
}
this.updateBinding();
this.ready = isReady;
}
}
/**
* Called when the {@link core/renderers/GPURenderer.GPURenderer#device | WebGPU device} has been lost.
* Just set the {@link ready} flag to `false` to eventually invalidate the {@link bundle}.
*/
loseContext() {
this.ready = false;
}
/**
* Empty the {@link RenderBundle}. Can eventually re-add the {@link SceneStackedMesh | scene stacked meshes} to the {@link core/scenes/Scene.Scene | Scene} in order to render them normally again.
* @param keepMeshes - Whether to preserve the {@link meshes} in order to render them normally again. Default to `true`.
*/
empty(keepMeshes = true) {
this.ready = false;
this.meshes.forEach((mesh) => {
this.removeMesh(mesh, keepMeshes);
});
this.size = 0;
}
/**
* Destroy the {@link binding} buffer if needed and remove the {@link RenderBundle} from the {@link Renderer}.
* @private
*/
#cleanUp() {
if (this.binding) {
this.renderer.removeBuffer(this.binding.buffer);
this.binding.buffer.destroy();
}
if (this.indirectBuffer) this.indirectBuffer.destroy();
this.renderer.renderBundles.delete(this.uuid);
}
/**
* Remove the {@link RenderBundle}, i.e. destroy it while preserving the {@link SceneStackedMesh | scene stacked meshes} by re-adding them to the {@link core/scenes/Scene.Scene | Scene}.
*/
remove() {
this.empty(true);
this.#cleanUp();
}
/**
* Remove the {@link RenderBundle} from our {@link core/scenes/Scene.Scene | Scene}, {@link RenderedMesh#remove | remove the meshes}, eventually destroy the {@link binding} and remove the {@link RenderBundle} from the {@link Renderer}.
*/
destroy() {
this.ready = false;
this.meshes.forEach((mesh) => {
mesh.remove();
});
this.size = 0;
this.#cleanUp();
}
};
//#endregion
export { RenderBundle };