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.
452 lines (451 loc) • 17.6 kB
JavaScript
import { generateUUID, toKebabCase } from "../../utils/utils.mjs";
import { isRenderer } from "../renderers/utils.mjs";
import { BufferBinding } from "../bindings/BufferBinding.mjs";
import { WritableBufferBinding } from "../bindings/WritableBufferBinding.mjs";
//#region src/core/bindGroups/BindGroup.ts
/**
* Used to handle all inputs data sent to the GPU.
*
* In WebGPU, data (buffers, textures or samplers, called bindings) are organised by bind groups, containing those bindings.
*
* ## Bindings
*
* A {@link BindGroup} is responsible for creating each {@link BufferBinding} {@link GPUBuffer} and then the {@link GPUBindGroup} and {@link GPUBindGroupLayout} that are used to create {@link GPUComputePipeline} or {@link GPURenderPipeline}.
*
* Those are generally automatically created by the {@link core/materials/Material.Material | Material} using this {@link BindGroup}. If you need to manually create them, you will have to call its {@link BindGroup#createBindGroup | `createBindGroup()` method}
*
* ### Samplers and textures
*
* A {@link BindGroup} is best suited to handle {@link GPUBuffer} only bindings. If you need to handle {@link GPUSampler}, a {@link GPUTexture} or a {@link GPUExternalTexture}, you should use a {@link core/bindGroups/TextureBindGroup.TextureBindGroup | TextureBindGroup} instead.
*
* ### Updating a GPUBindGroup or GPUBindGroupLayout
*
* Each time one of the {@link https://developer.mozilla.org/en-US/docs/Web/API/GPUDevice/createBindGroup#resource | binding resource} changes, its {@link BindGroup#bindGroup | bindGroup} will be recreated (usually, when a {@link GPUTexture} is uploaded).
*
* Each time one of the {@link https://developer.mozilla.org/en-US/docs/Web/API/GPUDevice/createBindGroupLayout#resource_layout_objects | binding resource layout} changes, its {@link BindGroup#bindGroupLayout | bindGroupLayout} and {@link BindGroup#bindGroup | bindGroup} will be recreated, and the {@link GPUComputePipeline} or {@link GPURenderPipeline} will be recreated as well.
*
* @example
* ```javascript
* // set our main GPUCurtains instance
* const gpuCurtains = new GPUCurtains({
* container: '#canvas' // selector of our WebGPU canvas container
* })
*
* // set the GPU device
* // note this is asynchronous
* await gpuCurtains.setDevice()
*
* const bindGroup = new BindGroup(gpuCurtains, {
* label: 'My bind group',
* uniforms: {
* params: {
* visibility: ['fragment'],
* struct: {
* opacity: {
* value: 'f32',
* value: 1,
* },
* mousePosition: {
* value: 'vec2f',
* value: new Vec2(),
* },
* },
* },
* },
* })
*
* // create the GPU buffer, bindGroupLayout and bindGroup
* bindGroup.createBindGroup()
* ```
*/
var BindGroup = class {
/**
* BindGroup constructor
* @param renderer - a {@link Renderer} class object or a {@link GPUCurtains} class object.
* @param parameters - {@link BindGroupParams | parameters} used to create our {@link BindGroup}.
*/
constructor(renderer, { label = "BindGroup", index = 0, bindings = [], uniforms, storages } = {}) {
this.type = "BindGroup";
this.setRenderer(renderer);
this.options = {
label,
index,
bindings,
...uniforms && { uniforms },
...storages && { storages }
};
this.index = index;
this.uuid = generateUUID();
this.bindings = [];
this.bufferBindings = [];
this.uniforms = {};
this.storages = {};
bindings.length && this.addBindings(bindings);
if (this.options.uniforms || this.options.storages) this.setInputBindings();
this.layoutCacheKey = "";
this.pipelineCacheKey = "";
this.resetEntries();
this.bindGroupLayout = null;
this.bindGroup = null;
this.needsPipelineFlush = false;
this.consumers = /* @__PURE__ */ new Set();
this.renderer.addBindGroup(this);
}
/**
* Set or reset this {@link BindGroup} {@link BindGroup.renderer | renderer}.
* @param renderer - New {@link Renderer} or {@link GPUCurtains} instance to use.
*/
setRenderer(renderer) {
renderer = isRenderer(renderer, this.type);
this.renderer = renderer;
}
/**
* Sets our {@link BindGroup#index | bind group index}.
* @param index - {@link BindGroup#index | bind group index} to set.
*/
setIndex(index) {
this.index = index;
}
/**
* Adds an array of already created {@link bindings} (buffers, texture, etc.) to the {@link bindings} array.
* @param bindings - {@link bindings} to add.
*/
addBindings(bindings = []) {
bindings.forEach((binding) => {
this.addBinding(binding);
});
}
/**
* Adds an already created {@link bindings} (buffers, texture, etc.) to the {@link bindings} array.
* @param binding - binding to add.
*/
addBinding(binding) {
if ("buffer" in binding) {
if (binding.parent) {
this.renderer.deviceManager.bufferBindings.set(binding.parent.cacheKey, binding.parent);
binding.parent.buffer.consumers.add(this.uuid);
} else {
this.renderer.deviceManager.bufferBindings.set(binding.cacheKey, binding);
binding.buffer.consumers.add(this.uuid);
}
if ("resultBuffer" in binding) binding.resultBuffer.consumers.add(this.uuid);
this.bufferBindings.push(binding);
if (binding.bindingType === "uniform") this.uniforms[binding.name] = binding.inputs;
else this.storages[binding.name] = binding.inputs;
}
this.bindings.push(binding);
}
/**
* Destroy a {@link BufferBinding} buffers.
* @param binding - {@link BufferBinding} from which to destroy the buffers.
*/
destroyBufferBinding(binding) {
if ("buffer" in binding) {
this.renderer.removeBuffer(binding.buffer);
binding.buffer.consumers.delete(this.uuid);
if (!binding.buffer.consumers.size) binding.buffer.destroy();
if (binding.parent) {
binding.parent.buffer.consumers.delete(this.uuid);
if (!binding.parent.buffer.consumers.size) {
this.renderer.removeBuffer(binding.parent.buffer);
binding.parent.buffer.destroy();
}
}
}
if ("resultBuffer" in binding) {
this.renderer.removeBuffer(binding.resultBuffer);
binding.resultBuffer.consumers.delete(this.uuid);
if (!binding.resultBuffer.consumers.size) binding.resultBuffer.destroy();
}
}
/**
* Creates Bindings based on a list of inputs.
* @param bindingType - {@link core/bindings/Binding.Binding#bindingType | binding type}.
* @param inputs - {@link ReadOnlyInputBindings | inputs (uniform or storage)} that will be used to create the binding.
* @returns - A {@link bindings} array.
*/
createInputBindings(bindingType = "uniform", inputs = {}) {
let bindings = [...Object.keys(inputs).map((inputKey) => {
const binding = inputs[inputKey];
if (!binding.struct) return;
const bindingParams = {
label: toKebabCase(binding.label || inputKey),
name: inputKey,
bindingType,
visibility: binding.access === "read_write" ? ["compute"] : binding.visibility,
useStruct: true,
access: binding.access ?? "read",
...binding.usage && { usage: binding.usage },
struct: binding.struct,
...binding.shouldCopyResult !== void 0 && { shouldCopyResult: binding.shouldCopyResult }
};
if (binding.useStruct !== false) {
let key = `${bindingType},${binding.visibility === void 0 ? "all" : binding.access === "read_write" ? "compute" : binding.visibility},true,${binding.access ?? "read"},`;
Object.keys(binding.struct).forEach((bindingKey) => {
key += `${bindingKey},${binding.struct[bindingKey].type},`;
});
if (binding.shouldCopyResult !== void 0) key += `${binding.shouldCopyResult},`;
const cachedBinding = this.renderer.deviceManager.bufferBindings.get(key);
if (cachedBinding) return cachedBinding.clone(bindingParams);
}
const BufferBindingConstructor = bindingParams.access === "read_write" ? WritableBufferBinding : BufferBinding;
return binding.useStruct !== false ? new BufferBindingConstructor(bindingParams) : Object.keys(binding.struct).map((bindingKey) => {
bindingParams.label = toKebabCase(binding.label ? binding.label + bindingKey : inputKey + bindingKey);
bindingParams.name = inputKey + bindingKey;
bindingParams.useStruct = false;
bindingParams.struct = { [bindingKey]: binding.struct[bindingKey] };
return new BufferBindingConstructor(bindingParams);
});
})].flat();
bindings = bindings.filter(Boolean);
bindings.forEach((binding) => {
this.renderer.deviceManager.bufferBindings.set(binding.cacheKey, binding);
});
return bindings;
}
/**
* Create and adds {@link bindings} based on inputs provided upon creation.
*/
setInputBindings() {
this.addBindings([...this.createInputBindings("uniform", this.options.uniforms), ...this.createInputBindings("storage", this.options.storages)]);
}
/**
* Get whether the GPU bind group is ready to be created.
* It can be created if it has {@link bindings} and has not been created yet.
* @readonly
*/
get shouldCreateBindGroup() {
return !this.bindGroup && !!this.bindings.length;
}
/**
* Reset our {@link BindGroup} {@link entries}.
*/
resetEntries() {
this.entries = {
bindGroupLayout: [],
bindGroup: []
};
}
/**
* Create the GPU buffers, {@link bindings}, {@link entries}, {@link bindGroupLayout} and {@link bindGroup}.
*/
createBindGroup() {
this.fillEntries();
this.setBindGroupLayout();
this.setBindGroup();
}
/**
* Reset the {@link BindGroup#entries.bindGroup | bindGroup entries}, recreates them and then recreate the {@link BindGroup#bindGroup | GPU bind group}.
*/
resetBindGroup() {
this.entries.bindGroup = [];
this.pipelineCacheKey = "";
for (const binding of this.bindings) this.addBindGroupEntry(binding);
this.setBindGroup();
}
/**
* Add a {@link BindGroup#entries.bindGroup | bindGroup entry}
* @param binding - {@link BindGroupBindingElement | binding} to add
*/
addBindGroupEntry(binding) {
this.entries.bindGroup.push({
binding: this.entries.bindGroup.length,
resource: binding.resource
});
this.pipelineCacheKey += binding.cacheKey;
}
/**
* Reset the {@link BindGroup#entries.bindGroupLayout | bindGroupLayout entries}, recreates them and then recreate the {@link BindGroup#bindGroupLayout | GPU bind group layout}
*/
resetBindGroupLayout() {
this.entries.bindGroupLayout = [];
this.layoutCacheKey = "";
for (const binding of this.bindings) this.addBindGroupLayoutEntry(binding);
this.setBindGroupLayout();
}
/**
* Add a {@link BindGroup#entries.bindGroupLayout | bindGroupLayout entry}
* @param binding - {@link BindGroupBindingElement | binding} to add
*/
addBindGroupLayoutEntry(binding) {
this.entries.bindGroupLayout.push({
binding: this.entries.bindGroupLayout.length,
...binding.resourceLayout,
visibility: binding.visibility
});
this.layoutCacheKey += binding.resourceLayoutCacheKey;
}
/**
* Called when the {@link core/renderers/GPUDeviceManager.GPUDeviceManager#device | device} has been lost to prepare everything for restoration
*/
loseContext() {
this.resetEntries();
for (const binding of this.bufferBindings) {
binding.buffer.reset();
if (binding.parent) binding.parent.buffer.reset();
if ("resultBuffer" in binding) binding.resultBuffer.reset();
}
this.bindGroup = null;
this.bindGroupLayout = null;
this.needsPipelineFlush = true;
}
/**
* Called when the {@link core/renderers/GPUDeviceManager.GPUDeviceManager#device | device} has been restored to update our bindings.
*/
restoreContext() {
if (this.shouldCreateBindGroup) this.createBindGroup();
for (const bufferBinding of this.bufferBindings) bufferBinding.shouldUpdate = true;
}
/**
* Creates binding GPUBuffer with correct params.
* @param binding - The binding element.
* @param optionalLabel - Optional label to use for the {@link GPUBuffer}.
*/
createBindingBuffer(binding, optionalLabel = null) {
binding.buffer.createBuffer(this.renderer, {
label: optionalLabel || this.options.label + ": " + binding.bindingType + " buffer from: " + binding.label,
usage: [...[
"copySrc",
"copyDst",
binding.bindingType
], ...binding.options.usage]
});
if ("resultBuffer" in binding) binding.resultBuffer.createBuffer(this.renderer, {
label: this.options.label + ": Result buffer from: " + binding.label,
size: binding.arrayBuffer.byteLength,
usage: ["copyDst", "mapRead"]
});
this.renderer.deviceManager.bufferBindings.set(binding.cacheKey, binding);
}
/**
* Fill in our entries bindGroupLayout and bindGroup arrays with the correct binding resources.
* For buffer struct, create a GPUBuffer first if needed
*/
fillEntries() {
for (const binding of this.bindings) {
if (!binding.visibility) binding.visibility = GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT | GPUShaderStage.COMPUTE;
if ("buffer" in binding) {
if (binding.parent && !binding.parent.buffer.GPUBuffer) this.createBindingBuffer(binding.parent, binding.parent.options.label);
else if (!binding.buffer.GPUBuffer && !binding.parent) this.createBindingBuffer(binding);
}
this.addBindGroupLayoutEntry(binding);
this.addBindGroupEntry(binding);
}
}
/**
* Get a bind group binding by name/key
* @param bindingName - the binding name or key
* @returns - the found binding, or null if not found
*/
getBindingByName(bindingName = "") {
return this.bindings.find((binding) => binding.name === bindingName);
}
/**
* Create a GPUBindGroupLayout and set our {@link bindGroupLayout}
*/
setBindGroupLayout() {
const bindGroupLayout = this.renderer.deviceManager.bindGroupLayouts.get(this.layoutCacheKey);
if (bindGroupLayout) this.bindGroupLayout = bindGroupLayout;
else {
this.bindGroupLayout = this.renderer.createBindGroupLayout({
label: this.options.label + " layout",
entries: this.entries.bindGroupLayout
});
this.renderer.deviceManager.bindGroupLayouts.set(this.layoutCacheKey, this.bindGroupLayout);
}
}
/**
* Create a GPUBindGroup and set our {@link bindGroup}
*/
setBindGroup() {
this.bindGroup = this.renderer.createBindGroup({
label: this.options.label,
layout: this.bindGroupLayout,
entries: this.entries.bindGroup
});
}
/**
* Check whether we should update (write) our {@link GPUBuffer} or not.
*/
updateBufferBindings() {
this.bindings.forEach((binding, index) => {
if ("buffer" in binding) {
binding.update();
if (binding.shouldUpdate && binding.buffer.GPUBuffer) {
if (!binding.useStruct && binding.bufferElements.length > 1) this.renderer.queueWriteBuffer(binding.buffer.GPUBuffer, 0, binding.bufferElements[index].view);
else this.renderer.queueWriteBuffer(binding.buffer.GPUBuffer, 0, binding.arrayBuffer);
binding.shouldUpdate = false;
}
}
});
}
/**
* Update the {@link BindGroup}, which means update its {@link BindGroup#bufferBindings | buffer bindings} and {@link BindGroup#resetBindGroup | reset it} if needed.
* Called at each render from the parentMesh {@link core/materials/Material.Material | material}
*/
update() {
this.updateBufferBindings();
const needBindGroupReset = this.bindings.some((binding) => binding.shouldResetBindGroup);
const needBindGroupLayoutReset = this.bindings.some((binding) => binding.shouldResetBindGroupLayout);
if (needBindGroupReset || needBindGroupLayoutReset) this.renderer.onAfterCommandEncoderSubmission.add(() => {
for (const binding of this.bindings) {
binding.shouldResetBindGroup = false;
binding.shouldResetBindGroupLayout = false;
}
}, { once: true });
if (needBindGroupLayoutReset) {
this.resetBindGroupLayout();
this.needsPipelineFlush = true;
}
if (needBindGroupReset) this.resetBindGroup();
}
/**
* Clones a {@link BindGroup} from a list of {@link BindGroup.bindings | bindings}.
* Useful to create a new bind group with already created buffers, but swapped.
* @param parameters - parameters to use for cloning.
* @param parameters.bindings - our input {@link BindGroup.bindings | bindings}.
* @param [parameters.keepLayout=false] - whether we should keep original {@link bindGroupLayout} or not.
* @returns - the cloned {@link BindGroup}.
*/
clone({ bindings = [], keepLayout = false } = {}) {
const params = { ...this.options };
params.label += " (copy)";
const bindGroupCopy = new this.constructor(this.renderer, { label: params.label });
bindGroupCopy.setIndex(this.index);
bindGroupCopy.options = params;
const bindingsRef = bindings.length ? bindings : this.bindings;
for (const binding of bindingsRef) {
bindGroupCopy.addBinding(binding);
if ("buffer" in binding) {
if (binding.parent && !binding.parent.buffer.GPUBuffer) {
this.createBindingBuffer(binding.parent, binding.parent.options.label);
binding.parent.buffer.consumers.add(bindGroupCopy.uuid);
} else if (!binding.buffer.GPUBuffer && !binding.parent) this.createBindingBuffer(binding);
if ("resultBuffer" in binding) binding.resultBuffer.consumers.add(bindGroupCopy.uuid);
}
if (!keepLayout) bindGroupCopy.addBindGroupLayoutEntry(binding);
bindGroupCopy.addBindGroupEntry(binding);
}
if (keepLayout) {
bindGroupCopy.entries.bindGroupLayout = [...this.entries.bindGroupLayout];
bindGroupCopy.layoutCacheKey = this.layoutCacheKey;
}
bindGroupCopy.setBindGroupLayout();
bindGroupCopy.setBindGroup();
return bindGroupCopy;
}
/**
* Destroy our {@link BindGroup}
* Most important is to destroy the GPUBuffers to free the memory
*/
destroy() {
this.renderer.removeBindGroup(this);
for (const binding of this.bufferBindings) this.destroyBufferBinding(binding);
this.bindings = [];
this.bindGroupLayout = null;
this.bindGroup = null;
this.resetEntries();
}
};
//#endregion
export { BindGroup };