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.
548 lines (547 loc) • 24.7 kB
JavaScript
import { throwWarning, toCamelCase, toKebabCase } from "../../utils/utils.mjs";
import { Vec3 } from "../../math/Vec3.mjs";
import { getBindGroupLayoutBindingType, getBindingWGSLVarType, getBufferLayout } from "./utils.mjs";
import { Binding } from "./Binding.mjs";
import { Vec2 } from "../../math/Vec2.mjs";
import { BufferElement } from "./bufferElements/BufferElement.mjs";
import { BufferArrayElement } from "./bufferElements/BufferArrayElement.mjs";
import { BufferInterleavedArrayElement } from "./bufferElements/BufferInterleavedArrayElement.mjs";
import { Buffer } from "../buffers/Buffer.mjs";
//#region src/core/bindings/BufferBinding.ts
/**
* Used to format {@link BufferBindingParams#struct | uniforms or storages struct inputs} and create a single typed array that will hold all those inputs values. The array needs to be correctly padded depending on every value type, so it can be safely used as a GPUBuffer input.
*
* It will also create WGSL Structs and variables according to the {@link BufferBinding} inputs parameters.
*
* The WGSL structs and variables declaration may vary based on the input types, especially if there's one or more arrays involved (i.e. `array<f32>`, `array<vec3f>` etc.).
*
* It is possible to create complex WGSL structs with children structs by using the {@link BufferBindingParams#childrenBindings | childrenBindings} parameter.
*
* There's a helper tool to help you understand and debug your {@link BufferBinding} WGSL declaration: [BufferBinding WGSL generation helper](https://martinlaxenaire.github.io/gpu-curtains/examples/buffer-binding-wgsl-helper/)
*
* A {@link BufferBinding} can also have a {@link parent | parent BufferBinding}, in which case it won't create a GPUBuffer but use its parent GPUBuffer at the right offset. Useful to create a unique {@link BufferBinding} with a single GPUBuffer to handle multiple {@link BufferBinding} and update them with a single `writeBuffer` call.
*
* @example
* ```javascript
* // create a GPU buffer binding
* const bufferBinding = new BufferBinding({
* name: 'params', // name of the WGSL object
* bindingType: 'uniform', // should be 'storage' for large arrays
* struct: {
* opacity: {
* type: 'f32',
* value: 1,
* },
* mousePosition: {
* type: 'vec2f',
* value: new Vec2(),
* },
* },
* })
* ```
*/
var BufferBinding = class BufferBinding extends Binding {
/** @ignore */
#parent;
/**
* BufferBinding constructor
* @param parameters - {@link BufferBindingParams | parameters} used to create our BufferBindings
*/
constructor({ label = "Uniform", name = "uniform", bindingType, visibility, useStruct = true, access = "read", usage = [], struct = {}, childrenBindings = [], buffer = null, parent = null, minOffset = 256, offset = 0 }) {
bindingType = bindingType ?? "uniform";
super({
label,
name,
bindingType,
visibility
});
this.options = {
...this.options,
useStruct,
access,
usage,
struct,
childrenBindings,
buffer,
parent,
minOffset,
offset
};
this.cacheKey += `${useStruct},${access},`;
this.arrayBufferSize = 0;
this.shouldUpdate = false;
this.useStruct = useStruct;
this.bufferElements = [];
this.inputs = {};
this.buffer = this.options.buffer ?? new Buffer();
if (Object.keys(struct).length) {
this.setBindings(struct);
this.setInputsAlignment();
}
this.setChildrenBindings(childrenBindings);
if (Object.keys(struct).length || this.childrenBindings.length) {
this.setBufferAttributes();
this.setWGSLFragment();
}
this.parent = parent;
}
/**
* Clone a {@link BufferBindingParams#struct | struct object} width new default values.
* @param struct - New cloned struct object.
*/
static cloneStruct(struct) {
return Object.keys(struct).reduce((acc, bindingKey) => {
const binding = struct[bindingKey];
let value;
if (Array.isArray(binding.value) || ArrayBuffer.isView(binding.value)) value = new binding.value.constructor(binding.value.length);
else if (typeof binding.value === "number") value = 0;
else value = new binding.value.constructor();
return {
...acc,
[bindingKey]: {
type: binding.type,
value
}
};
}, {});
}
/**
* Get the {@link BufferBinding} parent if any.
* @readonly
* @returns - The {@link BufferBinding} parent if any.
*/
get parent() {
return this.#parent;
}
/**
* Set the new {@link BufferBinding} parent.
* @param value - New {@link BufferBinding} parent to set if any.
*/
set parent(value) {
if (!!value) {
this.parentView = new DataView(value.arrayBuffer, this.offset, this.getMinOffsetSize(this.arrayBufferSize));
const getAllBufferElements = (binding) => {
const getBufferElements = (binding) => {
return binding.bufferElements;
};
return [...getBufferElements(binding), binding.childrenBindings.map((child) => getAllBufferElements(child)).flat()].flat();
};
this.parentViewSetBufferEls = getAllBufferElements(this).map((bufferElement) => {
switch (bufferElement.bufferLayout.View) {
case Int32Array: return {
bufferElement,
viewSetFunction: this.parentView.setInt32.bind(this.parentView)
};
case Uint16Array: return {
bufferElement,
viewSetFunction: this.parentView.setUint16.bind(this.parentView)
};
case Uint32Array: return {
bufferElement,
viewSetFunction: this.parentView.setUint32.bind(this.parentView)
};
case Float32Array:
default: return {
bufferElement,
viewSetFunction: this.parentView.setFloat32.bind(this.parentView)
};
}
});
if (!this.parent && this.buffer.GPUBuffer && !this.options.buffer) this.buffer.destroy();
} else {
this.parentView = null;
this.parentViewSetBufferEls = null;
}
this.#parent = value;
}
/**
* Round the given size value to the nearest minimum {@link GPUDevice} buffer offset alignment.
* @param value - Size to round.
*/
getMinOffsetSize(value) {
return Math.ceil(value / this.options.minOffset) * this.options.minOffset;
}
/**
* Get this {@link BufferBinding} offset in bytes inside the {@link arrayBuffer | parent arrayBuffer}.
* @readonly
* @returns - The offset in bytes inside the {@link arrayBuffer | parent arrayBuffer}
*/
get offset() {
return this.getMinOffsetSize(this.options.offset * this.getMinOffsetSize(this.arrayBufferSize));
}
/**
* Get {@link GPUDevice.createBindGroupLayout().descriptor.entries.resource | GPUBindGroupLayout entry resource}.
* @readonly
*/
get resourceLayout() {
return {
buffer: { type: getBindGroupLayoutBindingType(this) },
...this.parent && {
offset: this.offset,
size: this.arrayBufferSize
}
};
}
/**
* Get the resource cache key
* @readonly
*/
get resourceLayoutCacheKey() {
return `buffer,${getBindGroupLayoutBindingType(this)},${this.visibility},`;
}
/**
* Get {@link GPUDevice.createBindGroup().descriptor.entries.resource | GPUBindGroup entry resource}.
* @readonly
*/
get resource() {
return {
buffer: this.parent ? this.parent.buffer.GPUBuffer : this.buffer.GPUBuffer,
...this.parent && {
offset: this.offset,
size: this.arrayBufferSize
}
};
}
/**
* Clone this {@link BufferBinding} into a new one. Allows to skip buffer layout alignment computations.
* @param params - params to use for cloning
*/
clone(params = {}) {
let { struct, childrenBindings, parent, ...defaultParams } = params;
const { label, name, bindingType, visibility, useStruct, access, usage } = this.options;
defaultParams = {
label,
name,
bindingType,
visibility,
useStruct,
access,
usage,
...defaultParams
};
const bufferBindingCopy = new this.constructor(defaultParams);
struct = struct || BufferBinding.cloneStruct(this.options.struct);
bufferBindingCopy.options.struct = struct;
bufferBindingCopy.setBindings(struct);
bufferBindingCopy.arrayBufferSize = this.arrayBufferSize;
bufferBindingCopy.arrayBuffer = new ArrayBuffer(bufferBindingCopy.arrayBufferSize);
bufferBindingCopy.arrayView = new DataView(bufferBindingCopy.arrayBuffer, 0, bufferBindingCopy.arrayBuffer.byteLength);
if (!bufferBindingCopy.options.buffer) bufferBindingCopy.buffer.size = bufferBindingCopy.arrayBuffer.byteLength;
this.bufferElements.forEach((bufferElement) => {
const newBufferElement = new bufferElement.constructor({
name: bufferElement.name,
key: bufferElement.key,
type: bufferElement.type,
...bufferElement.arrayLength && { arrayLength: bufferElement.arrayLength }
});
newBufferElement.alignment = JSON.parse(JSON.stringify(bufferElement.alignment));
if (bufferElement.arrayStride) newBufferElement.arrayStride = bufferElement.arrayStride;
newBufferElement.setView(bufferBindingCopy.arrayBuffer, bufferBindingCopy.arrayView);
bufferBindingCopy.bufferElements.push(newBufferElement);
});
if (this.options.childrenBindings) {
bufferBindingCopy.options.childrenBindings = this.options.childrenBindings;
bufferBindingCopy.options.childrenBindings.forEach((child) => {
const count = child.count ? Math.max(1, child.count) : 1;
bufferBindingCopy.cacheKey += `child(count:${count}):${child.binding.cacheKey}`;
});
bufferBindingCopy.options.childrenBindings.forEach((child) => {
bufferBindingCopy.childrenBindings = [...bufferBindingCopy.childrenBindings, Array.from(Array(Math.max(1, child.count || 1)).keys()).map((i) => {
return child.binding.clone({
...child.binding.options,
struct: BufferBinding.cloneStruct(child.binding.options.struct)
});
})].flat();
});
bufferBindingCopy.childrenBindings.forEach((binding, index) => {
let offset = this.arrayView.byteLength;
for (let i = 0; i < index; i++) offset += this.childrenBindings[i].arrayBuffer.byteLength;
binding.bufferElements.forEach((bufferElement, i) => {
bufferElement.alignment.start.row = this.childrenBindings[index].bufferElements[i].alignment.start.row;
bufferElement.alignment.end.row = this.childrenBindings[index].bufferElements[i].alignment.end.row;
});
binding.arrayView = new DataView(bufferBindingCopy.arrayBuffer, offset, binding.arrayBuffer.byteLength);
for (const bufferElement of binding.bufferElements) bufferElement.setView(bufferBindingCopy.arrayBuffer, binding.arrayView);
});
}
bufferBindingCopy.setWGSLFragment();
if (parent) bufferBindingCopy.parent = parent;
bufferBindingCopy.shouldUpdate = bufferBindingCopy.arrayBufferSize > 0;
return bufferBindingCopy;
}
/**
* Format bindings struct and set our {@link inputs}
* @param bindings - bindings inputs
*/
setBindings(bindings) {
for (const bindingKey of Object.keys(bindings)) {
const binding = {};
for (const key in bindings[bindingKey]) if (key !== "value") binding[key] = bindings[bindingKey][key];
binding.name = bindingKey;
Object.defineProperty(binding, "value", {
get() {
return binding._value;
},
set(v) {
binding._value = v;
binding.shouldUpdate = true;
}
});
binding.value = bindings[bindingKey].value;
if (binding.value instanceof Vec2 || binding.value instanceof Vec3) {
const _onChangeCallback = binding.value._onChangeCallback;
binding.value._onChangeCallback = () => {
if (_onChangeCallback) _onChangeCallback();
binding.shouldUpdate = true;
};
}
this.inputs[bindingKey] = binding;
this.cacheKey += `${bindingKey},${bindings[bindingKey].type},`;
}
}
/**
* Set this {@link BufferBinding} optional {@link BufferBinding.childrenBindings | childrenBindings}.
* @param childrenBindings - Array of {@link BufferBindingChildrenBinding} to use as {@link BufferBinding.childrenBindings | childrenBindings}.
*/
setChildrenBindings(childrenBindings) {
this.childrenBindings = [];
if (childrenBindings && childrenBindings.length) {
const childrenArray = [];
childrenBindings.sort((a, b) => {
return (a.count ? Math.max(a.count) : a.forceArray ? 1 : 0) - (b.count ? Math.max(b.count) : b.forceArray ? 1 : 0);
}).forEach((child) => {
if (child.count && child.count > 1 || child.forceArray) childrenArray.push(child.binding);
});
if (childrenArray.length > 1) {
childrenArray.shift();
throwWarning(`BufferBinding: "${this.label}" contains multiple children bindings arrays. These children bindings cannot be added to the BufferBinding: "${childrenArray.map((child) => child.label).join(", ")}"`);
childrenArray.forEach((removedChildBinding) => {
childrenBindings = childrenBindings.filter((child) => child.binding.name !== removedChildBinding.name);
});
}
this.options.childrenBindings = childrenBindings;
childrenBindings.forEach((child) => {
const count = child.count ? Math.max(1, child.count) : 1;
this.cacheKey += `child(count:${count}):${child.binding.cacheKey}`;
this.childrenBindings = [...this.childrenBindings, Array.from(Array(count).keys()).map((i) => {
return child.binding.clone({
...child.binding.options,
struct: BufferBinding.cloneStruct(child.binding.options.struct)
});
})].flat();
});
}
}
/**
* Set the buffer alignments from {@link inputs}.
*/
setInputsAlignment() {
let orderedBindings = Object.keys(this.inputs);
const arrayBindings = orderedBindings.filter((bindingKey) => {
return this.inputs[bindingKey].type.includes("array");
});
if (arrayBindings.length) {
orderedBindings.sort((bindingKeyA, bindingKeyB) => {
return Math.min(0, this.inputs[bindingKeyA].type.indexOf("array")) - Math.min(0, this.inputs[bindingKeyB].type.indexOf("array"));
});
if (arrayBindings.length > 1) orderedBindings = orderedBindings.filter((bindingKey) => !arrayBindings.includes(bindingKey));
}
for (const bindingKey of orderedBindings) {
const binding = this.inputs[bindingKey];
const bufferElementOptions = {
name: toCamelCase(binding.name ?? bindingKey),
key: bindingKey,
type: binding.type
};
const isArray = binding.type.includes("array") && (Array.isArray(binding.value) || ArrayBuffer.isView(binding.value));
this.bufferElements.push(isArray ? new BufferArrayElement({
...bufferElementOptions,
arrayLength: binding.value.length
}) : new BufferElement(bufferElementOptions));
}
this.bufferElements.forEach((bufferElement, index) => {
const startOffset = index === 0 ? 0 : this.bufferElements[index - 1].endOffset + 1;
const minStride = "numElements" in bufferElement && this.bindingType === "uniform" ? 16 : 0;
bufferElement.setAlignment(startOffset, minStride);
});
if (arrayBindings.length > 1) if (arrayBindings.map((bindingKey) => {
const binding = this.inputs[bindingKey];
const bufferLayout = getBufferLayout(BufferElement.getBaseType(binding.type));
return Math.ceil(binding.value.length / bufferLayout.numElements);
}).every((size, i, array) => size === array[0])) {
const interleavedBufferElements = arrayBindings.map((bindingKey) => {
const binding = this.inputs[bindingKey];
return new BufferInterleavedArrayElement({
name: toCamelCase(binding.name ?? bindingKey),
key: bindingKey,
type: binding.type,
arrayLength: binding.value.length
});
});
const tempBufferElements = arrayBindings.map((bindingKey) => {
const binding = this.inputs[bindingKey];
return new BufferElement({
name: toCamelCase(binding.name ?? bindingKey),
key: bindingKey,
type: BufferElement.getType(binding.type)
});
});
tempBufferElements.forEach((bufferElement, index) => {
if (index === 0) if (this.bufferElements.length) bufferElement.setAlignmentFromPosition({
row: this.bufferElements[this.bufferElements.length - 1].alignment.end.row + 1,
byte: 0
});
else bufferElement.setAlignment(0);
else bufferElement.setAlignment(tempBufferElements[index - 1].endOffset + 1);
});
const totalStride = tempBufferElements[tempBufferElements.length - 1].endOffset + 1 - tempBufferElements[0].startOffset;
interleavedBufferElements.forEach((bufferElement, index) => {
bufferElement.setAlignment(tempBufferElements[index].startOffset, Math.ceil(totalStride / 16) * 16);
});
this.bufferElements = [...this.bufferElements, ...interleavedBufferElements];
} else throwWarning(`BufferBinding: "${this.label}" contains multiple array inputs that should use an interleaved array, but their sizes do not match. These inputs cannot be added to the BufferBinding: "${arrayBindings.join(", ")}"`);
}
/**
* Set our buffer attributes:
* Takes all the {@link inputs} and adds them to the {@link bufferElements} array with the correct start and end offsets (padded), then fill our {@link arrayBuffer} typed array accordingly.
*/
setBufferAttributes() {
const bufferElementsArrayBufferSize = this.bufferElements.length ? this.bufferElements[this.bufferElements.length - 1].paddedByteCount : 0;
this.arrayBufferSize = bufferElementsArrayBufferSize;
this.childrenBindings.forEach((binding) => {
this.arrayBufferSize += binding.arrayBufferSize;
});
this.arrayBuffer = new ArrayBuffer(this.arrayBufferSize);
this.arrayView = new DataView(this.arrayBuffer, 0, bufferElementsArrayBufferSize);
this.childrenBindings.forEach((binding, index) => {
let offset = bufferElementsArrayBufferSize;
for (let i = 0; i < index; i++) offset += this.childrenBindings[i].arrayBuffer.byteLength;
const bufferElLastRow = this.bufferElements.length ? this.bufferElements[this.bufferElements.length - 1].alignment.end.row + 1 : 0;
const bindingLastRow = index > 0 ? this.childrenBindings[index - 1].bufferElements.length ? this.childrenBindings[index - 1].bufferElements[this.childrenBindings[index - 1].bufferElements.length - 1].alignment.end.row + 1 : 0 : 0;
binding.bufferElements.forEach((bufferElement) => {
const rowOffset = index === 0 ? bufferElLastRow + bindingLastRow : bindingLastRow;
bufferElement.alignment.start.row += rowOffset;
bufferElement.alignment.end.row += rowOffset;
});
binding.arrayView = new DataView(this.arrayBuffer, offset, binding.arrayBuffer.byteLength);
for (const bufferElement of binding.bufferElements) bufferElement.setView(this.arrayBuffer, binding.arrayView);
});
if (!this.options.buffer) this.buffer.size = this.arrayBuffer.byteLength;
for (const bufferElement of this.bufferElements) bufferElement.setView(this.arrayBuffer, this.arrayView);
this.shouldUpdate = this.arrayBufferSize > 0;
}
/**
* Set the WGSL code snippet to append to the shaders code. It consists of variable (and Struct structures if needed) declarations.
*/
setWGSLFragment() {
if (!this.bufferElements.length && !this.childrenBindings.length) return;
const kebabCaseLabel = toKebabCase(this.label);
if (this.useStruct) {
const structs = {};
structs[kebabCaseLabel] = {};
const bufferElements = this.bufferElements.filter((bufferElement) => !(bufferElement instanceof BufferInterleavedArrayElement));
const interleavedBufferElements = this.bufferElements.filter((bufferElement) => bufferElement instanceof BufferInterleavedArrayElement);
if (interleavedBufferElements.length) {
const arrayLength = this.bindingType === "uniform" ? `, ${interleavedBufferElements[0].numElements}` : "";
if (bufferElements.length) {
structs[`${kebabCaseLabel}Element`] = {};
interleavedBufferElements.forEach((binding) => {
structs[`${kebabCaseLabel}Element`][binding.name] = BufferElement.getType(binding.type);
});
bufferElements.forEach((binding) => {
structs[kebabCaseLabel][binding.name] = binding.type;
});
const interleavedBufferName = this.bufferElements.find((bufferElement) => bufferElement.name === "elements") ? `${this.name}Elements` : "elements";
structs[kebabCaseLabel][interleavedBufferName] = `array<${kebabCaseLabel}Element${arrayLength}>`;
this.wgslGroupFragment = [`${getBindingWGSLVarType(this)} ${this.name}: ${kebabCaseLabel};`];
} else {
this.bufferElements.forEach((binding) => {
structs[kebabCaseLabel][binding.name] = BufferElement.getType(binding.type);
});
this.wgslGroupFragment = [`${getBindingWGSLVarType(this)} ${this.name}: array<${kebabCaseLabel}${arrayLength}>;`];
}
} else {
bufferElements.forEach((binding) => {
if (this.bindingType === "uniform" && "numElements" in binding) if (binding.bufferLayout.align < 16) {
const separateStructLabel = toKebabCase(binding.name) + "Elements";
structs[separateStructLabel] = {};
structs[separateStructLabel][` element`] = BufferElement.getType(binding.type);
structs[kebabCaseLabel][` ${binding.name}`] = `array<${separateStructLabel}, ${binding.numElements}>`;
} else structs[kebabCaseLabel][binding.name] = `array<${BufferElement.getType(binding.type)}, ${binding.numElements}>`;
else structs[kebabCaseLabel][binding.name] = binding.type;
});
this.wgslGroupFragment = [`${getBindingWGSLVarType(this)} ${this.name}: ${kebabCaseLabel};`];
}
if (this.childrenBindings.length) this.options.childrenBindings.forEach((child) => {
structs[kebabCaseLabel][child.binding.name] = child.count && child.count > 1 || child.forceArray ? this.bindingType === "uniform" && child.binding.bindingType === "uniform" ? `array<${toKebabCase(child.binding.label)}, ${child.count}>` : `array<${toKebabCase(child.binding.label)}>` : toKebabCase(child.binding.label);
});
this.wgslStructFragment = (this.childrenBindings.length ? this.options.childrenBindings.map((child) => child.binding.wgslStructFragment).join("\n\n") + "\n\n" : "") + Object.keys(structs).reverse().map((struct) => {
return `struct ${struct} {\n ${Object.keys(structs[struct]).map((binding) => `${binding}: ${structs[struct][binding]}`).join(",\n ")}\n};`;
}).join("\n\n");
} else {
this.wgslStructFragment = "";
this.wgslGroupFragment = this.bufferElements.map((binding) => {
return `${getBindingWGSLVarType(this)} ${binding.name}: ${binding.type};`;
});
}
}
/**
* Set a {@link BufferBinding#shouldUpdate | binding shouldUpdate} flag to `true` to update our {@link arrayBuffer} array during next render.
* @param bindingName - the binding name/key to update
*/
shouldUpdateBinding(bindingName = "") {
if (this.inputs[bindingName]) this.inputs[bindingName].shouldUpdate = true;
}
/**
* Executed at the beginning of a Material render call.
* If any of the {@link inputs} has changed, run its `onBeforeUpdate` callback then updates our {@link arrayBuffer} array.
* Also sets the {@link shouldUpdate} property to true so the {@link core/bindGroups/BindGroup.BindGroup | BindGroup} knows it will need to update the {@link GPUBuffer}.
*/
update() {
if (this.options.buffer) {
this.shouldUpdate = false;
return;
}
const inputs = Object.values(this.inputs);
for (const binding of inputs) {
const bufferElement = this.bufferElements.find((bufferEl) => bufferEl.key === binding.name);
if (binding.shouldUpdate && bufferElement) {
binding.onBeforeUpdate && binding.onBeforeUpdate();
bufferElement.update(binding.value);
this.shouldUpdate = true;
binding.shouldUpdate = false;
}
}
this.childrenBindings.forEach((binding) => {
binding.update();
if (binding.shouldUpdate) this.shouldUpdate = true;
binding.shouldUpdate = false;
});
if (this.shouldUpdate && this.parent && this.parentViewSetBufferEls) {
let index = 0;
this.parentViewSetBufferEls.forEach((viewSetBuffer, i) => {
const { bufferElement, viewSetFunction } = viewSetBuffer;
bufferElement.view.forEach((value) => {
viewSetFunction(index * bufferElement.view.BYTES_PER_ELEMENT, value, true);
index++;
});
});
this.parent.shouldUpdate = true;
this.shouldUpdate = false;
}
}
/**
* Extract the data corresponding to a specific {@link BufferElement} from a {@link Float32Array} holding the {@link BufferBinding#buffer | GPU buffer} data of this {@link BufferBinding}
* @param parameters - parameters used to extract the data
* @param parameters.result - {@link Float32Array} holding {@link GPUBuffer} data
* @param parameters.bufferElementName - name of the {@link BufferElement} to use to extract the data
* @returns - extracted data from the {@link Float32Array}
*/
extractBufferElementDataFromBufferResult({ result, bufferElementName }) {
const bufferElement = this.bufferElements.find((bufferElement) => bufferElement.name === bufferElementName);
if (bufferElement) return bufferElement.extractDataFromBufferResult(result);
else return result;
}
};
//#endregion
export { BufferBinding };