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.
567 lines (564 loc) • 21.3 kB
JavaScript
import { isRenderer } from '../renderers/utils.mjs';
import { BindGroup } from '../bindGroups/BindGroup.mjs';
import { TextureBindGroup } from '../bindGroups/TextureBindGroup.mjs';
import { Sampler } from '../samplers/Sampler.mjs';
import { MediaTexture } from '../textures/MediaTexture.mjs';
import { generateUUID } from '../../utils/utils.mjs';
class Material {
/**
* Material constructor
* @param renderer - {@link Renderer} class object or {@link GPUCurtains} class object used to create this {@link Material}.
* @param parameters - {@link types/Materials.MaterialParams | parameters} used to create our Material.
*/
constructor(renderer, parameters) {
this.type = "Material";
renderer = isRenderer(renderer, this.type);
this.renderer = renderer;
this.uuid = generateUUID();
const { shaders, label, useAsyncPipeline, uniforms, storages, bindings, bindGroups, samplers, textures } = parameters;
this.options = {
shaders,
label: label || this.constructor.name,
useAsyncPipeline: useAsyncPipeline === void 0 ? true : useAsyncPipeline,
...uniforms !== void 0 && { uniforms },
...storages !== void 0 && { storages },
...bindings !== void 0 && { bindings },
...bindGroups !== void 0 && { bindGroups },
...samplers !== void 0 && { samplers },
...textures !== void 0 && { textures }
};
this.bindGroups = [];
this.texturesBindGroups = [];
this.clonedBindGroups = [];
this.setBindGroups();
this.setTextures();
this.setSamplers();
}
/**
* Set or reset this {@link Material} {@link Material.renderer | renderer}. Also reset the {@link bindGroups} renderer.
* @param renderer - New {@link Renderer} or {@link GPUCurtains} instance to use.
*/
setRenderer(renderer) {
renderer = isRenderer(renderer, this.type);
this.renderer = renderer;
this.bindGroups.forEach((bindGroup) => {
bindGroup.setRenderer(this.renderer);
});
if (this.pipelineEntry) {
this.pipelineEntry.setRenderer(this.renderer);
}
}
/**
* Set the {@link pipelineEntry}.
*/
setPipelineEntry() {
}
/**
* Check if all bind groups are ready, and create them if needed.
*/
async compileMaterial() {
const createBindGroups = () => {
const texturesBindGroupLength = this.texturesBindGroup.bindings.length ? 1 : 0;
const bindGroupsReady = this.bindGroups.length >= this.inputsBindGroups.length + texturesBindGroupLength;
if (!bindGroupsReady) {
this.createBindGroups();
}
};
if (this.renderer.ready) {
createBindGroups();
} else {
await new Promise((resolve) => {
const taskId = this.renderer.onBeforeCommandEncoderCreation.add(
() => {
if (this.renderer.device) {
this.renderer.onBeforeCommandEncoderCreation.remove(taskId);
createBindGroups();
resolve();
}
},
{ once: false }
);
});
}
}
/**
* Get whether the renderer is ready, our pipeline entry and pipeline have been created and successfully compiled.
* @readonly
*/
get ready() {
return !!(this.renderer.ready && this.pipelineEntry && this.pipelineEntry.pipeline && this.pipelineEntry.ready);
}
/**
* Get the {@link Material} pipeline buffers cache key based on its {@link BindGroup} cache keys.
* @returns - Current cache key.
* @readonly
*/
get cacheKey() {
let cacheKey = "";
this.bindGroups.forEach((bindGroup) => {
bindGroup.bindings.forEach((binding) => {
cacheKey += binding.name + ",";
});
cacheKey += bindGroup.pipelineCacheKey;
});
return cacheKey;
}
/**
* Called when the {@link core/renderers/GPUDeviceManager.GPUDeviceManager#device | device} has been lost to prepare everything for restoration.
* Basically set all the {@link GPUBuffer} to `null`, so they will be reset next time we try to render.
*/
loseContext() {
for (const texture of this.textures) {
texture.texture = null;
if (texture instanceof MediaTexture) {
texture.sources.forEach((source) => source.sourceUploaded = false);
texture.sourcesUploaded = false;
}
}
[...this.bindGroups, ...this.clonedBindGroups, ...this.inputsBindGroups].forEach(
(bindGroup) => bindGroup.loseContext()
);
this.pipelineEntry.pipeline = null;
}
/**
* Called when the {@link core/renderers/GPUDeviceManager.GPUDeviceManager#device | device} has been restored to recreate our samplers, textures and bind groups.
*/
restoreContext() {
for (const sampler of this.samplers) {
sampler.createSampler();
sampler.binding.resource = sampler.sampler;
}
for (const texture of this.textures) {
if (texture instanceof MediaTexture) {
texture.sources.forEach((source) => {
if (source.sourceLoaded) {
source.shouldUpdate = true;
}
});
}
texture.resize(texture.size);
}
[...this.bindGroups, ...this.clonedBindGroups, ...this.inputsBindGroups].forEach((bindGroup) => {
bindGroup.restoreContext();
});
}
/**
* Get the complete code of a given shader including all the WGSL fragment code snippets added by the pipeline. Can wait for the {@link pipelineEntry} to be compiled if that's not already the case.
* @param [shaderType="full"] - Shader to get the code from.
* @returns - The corresponding shader code.
*/
async getShaderCode(shaderType = "full") {
shaderType = (() => {
switch (shaderType) {
case "vertex":
case "fragment":
case "compute":
case "full":
return shaderType;
default:
return "full";
}
})();
if (this.pipelineEntry) {
return this.pipelineEntry.shaders[shaderType].code;
} else {
return new Promise((resolve) => {
const taskId = this.renderer.onBeforeRenderScene.add(
() => {
if (this.pipelineEntry) {
this.renderer.onBeforeRenderScene.remove(taskId);
resolve(this.pipelineEntry.shaders[shaderType].code);
}
},
{ once: false }
);
});
}
}
/**
* Get the added code of a given shader, i.e. all the WGSL fragment code snippets added by the pipeline. Can wait for the {@link pipelineEntry} to be compiled if that's not already the case.
* @param [shaderType="vertex"] - Shader to get the code from.
* @returns - The corresponding shader code.
*/
async getAddedShaderCode(shaderType = "vertex") {
shaderType = (() => {
switch (shaderType) {
case "vertex":
case "fragment":
case "compute":
return shaderType;
default:
return "vertex";
}
})();
if (this.pipelineEntry) {
return this.pipelineEntry.shaders[shaderType].head;
} else {
return new Promise((resolve) => {
const taskId = this.renderer.onBeforeRenderScene.add(
() => {
if (this.pipelineEntry) {
this.renderer.onBeforeRenderScene.remove(taskId);
resolve(this.pipelineEntry.shaders[shaderType].head);
}
},
{ once: false }
);
});
}
}
/* BIND GROUPS */
/**
* Prepare and set our bind groups based on inputs and bindGroups Material parameters.
*/
setBindGroups() {
this.uniforms = {};
this.storages = {};
this.inputsBindGroups = [];
this.inputsBindings = /* @__PURE__ */ new Map();
if (this.options.uniforms || this.options.storages || this.options.bindings) {
const inputsBindGroup = new BindGroup(this.renderer, {
label: this.options.label + ": Bindings bind group",
uniforms: this.options.uniforms,
storages: this.options.storages,
bindings: this.options.bindings
});
this.processBindGroupBindings(inputsBindGroup);
this.inputsBindGroups.push(inputsBindGroup);
inputsBindGroup.consumers.add(this.uuid);
}
this.options.bindGroups?.forEach((bindGroup) => {
this.processBindGroupBindings(bindGroup);
this.inputsBindGroups.push(bindGroup);
bindGroup.consumers.add(this.uuid);
});
}
/**
* Get the main {@link TextureBindGroup | texture bind group} created by this {@link Material} to manage all textures related struct.
* @readonly
*/
get texturesBindGroup() {
return this.texturesBindGroups[0];
}
/**
* Process all {@link BindGroup} struct and add them to the corresponding objects based on their binding types. Also store them in a inputsBindings array to facilitate further access to struct.
* @param bindGroup - The {@link BindGroup} to process.
*/
processBindGroupBindings(bindGroup) {
for (const inputBinding of bindGroup.bindings) {
this.inputsBindings.set(inputBinding.name, inputBinding);
}
this.uniforms = {
...this.uniforms,
...bindGroup.uniforms
};
this.storages = {
...this.storages,
...bindGroup.storages
};
}
/**
* Create the bind groups if they need to be created.
*/
createBindGroups() {
if (this.texturesBindGroup.shouldCreateBindGroup) {
this.texturesBindGroup.setIndex(this.bindGroups.length);
this.texturesBindGroup.createBindGroup();
this.bindGroups.push(this.texturesBindGroup);
}
for (const bindGroup of this.inputsBindGroups) {
if (bindGroup.shouldCreateBindGroup) {
bindGroup.setIndex(this.bindGroups.length);
bindGroup.createBindGroup();
this.bindGroups.push(bindGroup);
}
}
this.options.bindGroups?.forEach((bindGroup) => {
if (!bindGroup.shouldCreateBindGroup && !this.bindGroups.find((bG) => bG.uuid === bindGroup.uuid)) {
bindGroup.setIndex(this.bindGroups.length);
this.bindGroups.push(bindGroup);
}
if (bindGroup instanceof TextureBindGroup && !this.texturesBindGroups.find((bG) => bG.uuid === bindGroup.uuid)) {
this.texturesBindGroups.push(bindGroup);
for (const texture of bindGroup.textures) {
if (!this.textures.find((t) => t.uuid !== texture.uuid)) {
this.textures.push(texture);
}
}
}
});
}
/**
* Clones a {@link BindGroup} from a list of buffers.
* Useful to create a new{@link BindGroup} with already created buffers, but swapped.
* @param parameters - parameters used to clone the {@link BindGroup}.
* @param parameters.bindGroup - the {@link BindGroup} to clone.
* @param parameters.bindings - our input binding buffers.
* @param parameters.keepLayout - whether we should keep original bind group layout or not.
* @returns - the cloned {@link BindGroup}.
*/
cloneBindGroup({
bindGroup,
bindings = [],
keepLayout = true
}) {
if (!bindGroup) return null;
const clone = bindGroup.clone({ bindings, keepLayout });
this.clonedBindGroups.push(clone);
return clone;
}
/**
* Get a corresponding {@link BindGroup} or {@link TextureBindGroup} from one of its binding name/key
* @param bindingName - the binding name/key to look for.
* @returns - {@link BindGroup} found or null if not found.
*/
getBindGroupByBindingName(bindingName = "") {
return (this.ready ? this.bindGroups : this.inputsBindGroups).find((bindGroup) => {
return bindGroup.bindings.find((binding) => binding.name === bindingName);
});
}
/**
* Destroy a {@link BindGroup}, only if it is not used by another object.
* @param bindGroup - {@link BindGroup} to eventually destroy.
*/
destroyBindGroup(bindGroup) {
bindGroup.consumers.delete(this.uuid);
if (!bindGroup.consumers.size) {
bindGroup.destroy();
}
}
/**
* Destroy all {@link BindGroup}.
*/
destroyBindGroups() {
this.bindGroups.forEach((bindGroup) => this.destroyBindGroup(bindGroup));
this.clonedBindGroups.forEach((bindGroup) => this.destroyBindGroup(bindGroup));
this.texturesBindGroups.forEach((bindGroup) => this.destroyBindGroup(bindGroup));
this.texturesBindGroups = [];
this.inputsBindGroups = [];
this.bindGroups = [];
this.clonedBindGroups = [];
}
/**
* Update all {@link BindGroup}.
*/
updateBindGroups() {
for (const bindGroup of this.bindGroups) {
this.updateBindGroup(bindGroup);
}
}
/**
* {@link BindGroup#update | Update a BindGroup}:
* - Update the textures if it's a {@link texturesBindGroups | textures bind group}.
* - Update its {@link BindGroup#bufferBindings | buffer bindings}.
* - Check if it eventually needs a {@link BindGroup#resetBindGroup | reset}.
* - Check if we need to flush the pipeline.
* @param bindGroup - {@link BindGroup} to update.
*/
updateBindGroup(bindGroup) {
bindGroup.update();
if (bindGroup.needsPipelineFlush && this.pipelineEntry?.ready) {
this.setPipelineEntry();
bindGroup.needsPipelineFlush = false;
}
}
/* INPUTS */
/**
* Look for a {@link BindGroupBindingElement | binding} by name in all {@link inputsBindings | input bindings}
* @param bindingName - the binding name or key.
* @returns - The found binding, or null if not found.
*/
getBindingByName(bindingName = "") {
return this.inputsBindings.get(bindingName);
}
/**
* Look for a {@link BindGroupBufferBindingElement | buffer binding} by name in all {@link inputsBindings | input bindings}.
* @param bindingName - The binding name or key.
* @returns - The found binding, or null if not found.
*/
getBufferBindingByName(bindingName = "") {
const bufferBinding = this.getBindingByName(bindingName);
return bufferBinding && "buffer" in bufferBinding ? bufferBinding : void 0;
}
/**
* Force setting a given {@link BufferBindingInput | buffer binding} shouldUpdate flag to `true` to update it at next render.
* @param bufferBindingName - The buffer binding name.
* @param bindingName - The binding name.
*/
shouldUpdateInputsBindings(bufferBindingName, bindingName) {
if (!bufferBindingName) return;
const bufferBinding = this.getBindingByName(bufferBindingName);
if (bufferBinding) {
if (!bindingName) {
Object.keys(bufferBinding.inputs).forEach(
(bindingKey) => bufferBinding.shouldUpdateBinding(bindingKey)
);
} else {
bufferBinding.shouldUpdateBinding(bindingName);
}
}
}
/* SAMPLERS & TEXTURES */
/**
* Prepare our {@link Material.textures | textures} array and set the {@link TextureBindGroup}.
*/
setTextures() {
this.textures = [];
this.texturesBindGroups.push(
new TextureBindGroup(this.renderer, {
label: this.options.label + ": Textures bind group"
})
);
this.texturesBindGroup.consumers.add(this.uuid);
this.options.textures?.forEach((texture) => {
this.addTexture(texture);
});
}
/**
* Add a {@link MediaTexture} or {@link Texture} to our {@link textures} array, and add it to the textures bind group only if used in the shaders (avoid binding useless data).
* @param texture - {@link MediaTexture} or {@link Texture} to add.
*/
addTexture(texture) {
this.textures.push(texture);
if (this.options.shaders.vertex && this.options.shaders.vertex.code.indexOf(texture.options.name) !== -1 || this.options.shaders.fragment && this.options.shaders.fragment.code.indexOf(texture.options.name) !== -1 || this.options.shaders.compute && this.options.shaders.compute.code.indexOf(texture.options.name) !== -1) {
this.texturesBindGroup.addTexture(texture);
}
if (texture instanceof MediaTexture && texture.options.useTransform) {
texture.transformBinding.inputs.matrix.shouldUpdate = true;
}
}
/**
* Destroy a {@link MediaTexture} or {@link Texture}, only if it is not used by another object or cached.
* @param texture - {@link MediaTexture} or {@link Texture} to eventually destroy.
*/
destroyTexture(texture) {
if (texture.options.cache) return;
if (!texture.options.autoDestroy) return;
const objectsUsingTexture = this.renderer.getObjectsByTexture(texture);
const shouldDestroy = !objectsUsingTexture || !objectsUsingTexture.some((object) => object.material.uuid !== this.uuid);
if (shouldDestroy) {
texture.destroy();
}
}
/**
* Destroy all the Material {@link textures}.
*/
destroyTextures() {
this.textures?.forEach((texture) => this.destroyTexture(texture));
this.textures = [];
}
/**
* Prepare our {@link Material.samplers | samplers} array and always add a default {@link Sampler} if not already passed as parameter.
*/
setSamplers() {
this.samplers = [];
this.options.samplers?.forEach((sampler) => {
this.addSampler(sampler);
});
const hasDefaultSampler = this.samplers.find((sampler) => sampler.name === "defaultSampler");
if (!hasDefaultSampler) {
const sampler = new Sampler(this.renderer, { label: "Default sampler", name: "defaultSampler" });
this.addSampler(sampler);
}
}
/**
* Add a {@link Sampler} to our {@link samplers} array, and add it to the textures bind group only if used in the shaders (avoid binding useless data).
* @param sampler - {@link Sampler} to add.
*/
addSampler(sampler) {
this.samplers.push(sampler);
if (this.options.shaders && this.options.shaders.vertex && this.options.shaders.vertex.code.indexOf(sampler.name) !== -1 || this.options.shaders && this.options.shaders.fragment && this.options.shaders.fragment.code.indexOf(sampler.name) !== -1 || this.options.shaders && this.options.shaders.compute && this.options.shaders.compute.code.indexOf(sampler.name) !== -1) {
this.texturesBindGroup.addSampler(sampler);
}
}
/* BUFFER RESULTS */
/**
* Map a {@link Buffer#GPUBuffer | Buffer's GPU buffer} and put a copy of the data into a {@link Float32Array}
* @param buffer - {@link Buffer} to use for mapping.
* @returns - {@link Float32Array} holding the {@link GPUBuffer} data.
*/
async getBufferResult(buffer) {
return await buffer.mapBufferAsync();
}
/**
* Map the content of a {@link BufferBinding} {@link Buffer#GPUBuffer | GPU buffer} and put a copy of the data into a {@link Float32Array}.
* @param bindingName - The name of the {@link inputsBindings | input bindings} from which to map the {@link Buffer#GPUBuffer | GPU buffer}.
* @returns - {@link Float32Array} holding the {@link GPUBuffer} data.
*/
async getBufferBindingResultByBindingName(bindingName = "") {
const binding = this.getBufferBindingByName(bindingName);
if (binding && "buffer" in binding) {
const dstBuffer = this.renderer.copyBufferToBuffer({
srcBuffer: binding.buffer
});
return await this.getBufferResult(dstBuffer);
} else {
return new Float32Array(0);
}
}
/**
* Map the content of a specific {@link BufferElement | buffer element} belonging to a {@link BufferBinding} {@link Buffer#GPUBuffer | GPU buffer} and put a copy of the data into a {@link Float32Array}.
* @param parameters - Parameters used to get the result.
* @param parameters.bindingName - The name of the {@link inputsBindings | input bindings} from which to map the {@link Buffer#GPUBuffer | GPU buffer}.
* @param parameters.bufferElementName - The name of the {@link BufferElement | buffer element} from which to extract the data afterwards.
* @returns - {@link Float32Array} holding {@link GPUBuffer} data.
*/
async getBufferElementResultByNames({
bindingName,
bufferElementName
}) {
const result = await this.getBufferBindingResultByBindingName(bindingName);
if (!bufferElementName || result.length) {
return result;
} else {
const binding = this.getBufferBindingByName(bindingName);
if (binding) {
return binding.extractBufferElementDataFromBufferResult({ result, bufferElementName });
} else {
return result;
}
}
}
/* RENDER */
/**
* Called before rendering the Material.
* First, check if we need to create our bind groups or pipeline.
* Finally, updates all the {@link bindGroups | bind groups}.
*/
onBeforeRender() {
this.compileMaterial();
this.updateBindGroups();
}
/**
* Set the current pipeline.
* @param pass - Current pass encoder.
*/
setPipeline(pass) {
this.renderer.pipelineManager.setCurrentPipeline(pass, this.pipelineEntry);
}
/**
* Use the {@link Renderer#pipelineManager | renderer pipelineManager} to only set the bind groups that are not already set.
* @param pass - Current pass encoder.
*/
setActiveBindGroups(pass) {
this.renderer.pipelineManager.setActiveBindGroups(pass, this.bindGroups);
}
/**
* Render the material if it is ready:
* Set the current pipeline and set the bind groups.
* @param pass - Current pass encoder.
*/
render(pass) {
if (!this.ready) return;
this.setPipeline(pass);
this.setActiveBindGroups(pass);
}
/**
* Destroy the Material.
*/
destroy() {
this.destroyBindGroups();
this.destroyTextures();
}
}
export { Material };