@absulit/points
Version:
A Generative Art library made in WebGPU
1,332 lines (1,243 loc) • 127 kB
JavaScript
import UniformKeys from './UniformKeys.js';
import VertexBufferInfo from './VertexBufferInfo.js';
import RenderPass, { PrimitiveTopology, LoadOp, CullMode, FrontFace } from './RenderPass.js';
import RenderPasses from './RenderPasses.js';
import Coordinate from './coordinate.js';
import RGBAColor from './color.js';
import Clock from './clock.js';
import defaultStructs from './core/defaultStructs.js';
import { defaultVertexBody } from './core/defaultFunctions.js';
import { dataSize, getArrayTypeData, isArray, typeSizes } from './data-size.js';
import { loadImage, strToImage } from './texture-string.js';
import LayersArray from './LayersArray.js';
import UniformsArray from './UniformsArray.js';
import getStorageAccessMode, { bindingModes, entriesModes } from './storage-accessmode.js';
import { cross, dot, normalize, sub } from './matrix.js';
import { clearCache, elToImage, getCSS } from './texture-element.js';
import PresentationFormat from './PresentationFormat.js';
import ScaleMode from './ScaleMode.js';
import Uniform from './Uniform.js';
import Storage from './Storage.js';
import Constant from './Constant.js';
import Uniforms from './Uniforms.js';
import Storages from './Storages.js';
import Constants from './Constants.js';
/**
* Main class Points, this is the entry point of an application with this library.
* @example
* import Points from 'points';
* const points = new Points('canvas');
*
* let renderPasses = [
* new RenderPass(vert1, frag1, compute1),
* new RenderPass(vert2, frag2, compute2)
* ];
*
* await points.init(renderPasses);
* points.update(update);
*
* function update() {
* // update uniforms and other animation variables
* }
*
*/
class Points {
#canvasId = null;
#canvas = null;
/** @type {GPUAdapter} */
#adapter = null;
/** @type {GPUDevice} */
#device = null;
#context = null;
#presentationFormat = null;
/** @type {Array<RenderPass>} */
#renderPasses = null;
#postRenderPasses = [];
#presentationSize = null;
#numColumns = 1;
#numRows = 1;
#commandsFinished = [];
#uniforms = new Uniforms();
#meshUniforms = new UniformsArray();
#cameraUniforms = new UniformsArray();
#constants = new Constants();
#storages = new Storages();
#samplers = [];
#textures2d = [];
#texturesDepth2d = [];
#texturesToCopy = [];
#textures2dArray = [];
#texturesExternal = [];
#texturesStorage2d = [];
#bindingTextures = [];
#layers = new LayersArray();
#clock = new Clock();
#time = 0;
#delta = 0;
#epoch = 0;
#mouse = [0, 0];
#mouseNormalized = [0, 0];
#mouseDown = false;
#mouseClick = false;
#mouseWheel = false;
#mouseDelta = [0, 0];
#screen = [0, 0];
#ratio = [0, 0];
#fullscreen = false;
#fitWindow = false;
#lastFitWindow = false;
#sounds = []; // audio
#events = new Map();
#events_ids = 0;
#dataSize = null;
#screenResized = false;
#textureUpdated = false;
#animationFrameId = null;
#updateCallback = null;
#imports = [];
#initialized = false;
#debug = true;
#scaleMode = ScaleMode.HEIGHT;
#abortController = null;
#canvasWidth = null;
#canvasHeight = null;
/**
* Constructor of `Points`.
* Set a width and height to be used if no `fitWindow` is called, and also
* to be used by the `ScaleMode` to decide how to resize the screen content.
* @param {String} canvasId id of an existing canvas
* @param {Number} width default width
* @param {Number} height default height
*/
constructor(canvasId, width = 800, height = 800) {
this.#canvasWidth = width;
this.#canvasHeight = height;
this.#canvasId = canvasId;
this.#canvas = document.getElementById(this.#canvasId);
this.#baseInit();
}
#baseInit() {
this.#scaleMode = ScaleMode.HEIGHT;
this.#presentationFormat = null;
this.#abortController = new AbortController();
const { signal } = this.#abortController;
const listenerOptions = { signal };
if (this.#canvasId) {
this.#canvas.addEventListener('click', e => {
this.#mouseClick = true;
this.setUniform(UniformKeys.MOUSE_CLICK, this.#mouseClick);
}, listenerOptions);
this.#canvas.addEventListener('mousemove', this.#onMouseMove, {
passive: true, signal
});
this.#canvas.addEventListener('mousedown', e => {
this.#mouseDown = true;
this.setUniform(UniformKeys.MOUSE_DOWN, this.#mouseDown);
}, listenerOptions);
this.#canvas.addEventListener('mouseup', e => {
this.#mouseDown = false;
this.setUniform(UniformKeys.MOUSE_DOWN, this.#mouseDown);
}, listenerOptions);
this.#canvas.addEventListener('wheel', e => {
this.#mouseWheel = true;
this.#mouseDelta[0] = e.deltaX;
this.#mouseDelta[1] = e.deltaY;
this.setUniform(UniformKeys.MOUSE_WHEEL, this.#mouseWheel);
this.setUniform(UniformKeys.MOUSE_DELTA, this.#mouseDelta);
}, { passive: true, signal });
window.addEventListener('resize', this.#resizeCanvasToFitWindow, {
signal,
capture: false
});
document.addEventListener('fullscreenchange', e => {
this.#fullscreen = !!document.fullscreenElement;
if (!this.#fullscreen && !this.#fitWindow) {
this.#resizeCanvasToDefault();
}
if (!this.#fullscreen) {
this.fitWindow = this.#lastFitWindow;
}
}, listenerOptions);
}
// initializing internal uniforms
this.setUniform(UniformKeys.TIME, this.#time);
this.setUniform(UniformKeys.DELTA, this.#delta);
this.setUniform(UniformKeys.EPOCH, this.#epoch);
this.setUniform(UniformKeys.MOUSE_CLICK, this.#mouseClick);
this.setUniform(UniformKeys.MOUSE_DOWN, this.#mouseDown);
this.setUniform(UniformKeys.MOUSE_WHEEL, this.#mouseWheel);
this.setUniform(UniformKeys.SCREEN, this.#screen, 'vec2f');
this.setUniform(UniformKeys.MOUSE, this.#mouse, 'vec2f');
this.setUniform(UniformKeys.MOUSE_DELTA, this.#mouseDelta, 'vec2f');
this.setUniform(UniformKeys.RATIO, this.#ratio, 'vec2f');
this.setUniform('_mouse_normalized', [0, 0], 'vec2f');
}
#resizeCanvasToFitWindow = () => {
this.#screenResized = true;
if (this.#fitWindow) {
const { offsetWidth, offsetHeight } = this.#canvas.parentNode;
this.#canvas.width = offsetWidth;
this.#canvas.height = offsetHeight;
this.#setScreenSize();
}
}
#resizeCanvasToDefault = () => {
this.#screenResized = true;
this.#canvas.width = this.#canvasWidth;
this.#canvas.height = this.#canvasHeight;
this.#setScreenSize();
}
#setScreenSize = () => {
// assigning size here because both sizes must match for the full screen
// this was not happening before the speed up refactor
this.#canvas.width = this.#canvas.clientWidth;
this.#canvas.height = this.#canvas.clientHeight;
this.#screen[0] = this.#canvas.width;
this.#screen[1] = this.#canvas.height;
this.#uniforms.screen = this.#screen;
this.#presentationSize = [
this.#canvas.clientWidth,
this.#canvas.clientHeight,
];
this.#context.configure({
label: '_context',
device: this.#device,
format: this.#presentationFormat,
//size: this.#presentationSize,
width: this.#presentationSize[0],
height: this.#presentationSize[1],
alphaMode: 'premultiplied',
// Specify we want both RENDER_ATTACHMENT and COPY_SRC since we
// will copy out of the swapchain texture.
usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
});
this.#renderPasses.forEach(renderPass => {
renderPass.textureDepth = this.#createTextureDepth('_depthTexture');
})
// this is to solve an issue that requires the texture to be resized
// if the screen dimensions change, this for a `setTexture2d` with
// `copyCurrentTexture` parameter set to `true`.
this.#textures2d.forEach(texture2d => {
if (!texture2d.imageTexture && texture2d.texture) {
this.#createTextureBindingToCopy(texture2d);
}
})
this.#setRatio();
}
/**
* Calculates the ratio that the screen should have depending on the
* `ScaleMode`
*/
#setRatio = () => {
// https://github.com/Absulit/points/blob/ca942574c8d72176d7ef5f4d738419aa54c555ab/src/core/defaultFunctions.js
const ratio_from_x = this.#canvas.width / this.#canvas.height;
const ratio_from_y = 1 / ratio_from_x; // this.#canvas.height / this.#canvas.width;
const ratio_landscape = [ratio_from_x, 1];
const ratio_portrait = [1, ratio_from_y];
const is_landscape = this.#canvas.height < this.#canvas.width;
let ratio;
if (this.#scaleMode === ScaleMode.FIT) {
ratio = is_landscape ? ratio_landscape : ratio_portrait;
} else if (this.#scaleMode === ScaleMode.COVER) {
ratio = is_landscape ? ratio_portrait : ratio_landscape;
} else if (this.#scaleMode === ScaleMode.HEIGHT) {
ratio = ratio_landscape;
} else {
ratio = ratio_portrait;
}
// to avoid creating new object, we just overwrite/copy the data.
// meaning we use the same reference of #ratio
this.#ratio[0] = ratio[0];
this.#ratio[1] = ratio[1];
this.#uniforms.ratio = this.#ratio;
}
#onMouseMove = e => {
// get position relative to canvas
const rect = this.#canvas.getBoundingClientRect();
this.#mouse[0] = e.clientX - rect.left;
this.#mouse[1] = e.clientY - rect.top;
// result.mouse = vec2(params.mouse.x / params.screen.x, params.mouse.y / params.screen.y);
// result.mouse = result.mouse * vec2(1., -1.) - vec2(0., -1.); // flip and move up
this.#mouseNormalized[0] = this.#mouse[0] / this.#screen[0];
this.#mouseNormalized[1] = this.#mouse[1] / this.#screen[1];
this.#mouseNormalized[1] = (this.#mouseNormalized[1] * - 1) - -1; // flip and move up
const { mouse, _mouse_normalized } = this.#uniforms;
mouse.value = this.#mouse;
_mouse_normalized.value = this.#mouseNormalized;
}
/**
* Sets a `param` (predefined struct already in all shaders)
* as uniform to send to all shaders.
* A Uniform is a value that can only be changed
* from the outside (js side, not the wgsl side),
* and unless changed it remains consistent.
* @param {string} name name of the Param, you can invoke it later in shaders as `Params.[name]`
* @param {Number|Boolean|Array<Number>} value Single number or a list of numbers. Boolean is converted to Number.
* @param {string} type type as `f32` or a custom struct. Default `f32`.
* @return {Uniform}
*
* @example
* // js
* points.setUniform('color0', options.color0, 'vec3f');
* points.setUniform('color1', options.color1, 'vec3f');
* points.setUniform('scale', options.scale, 'f32');
*
* // wgsl string
* let color0 = vec4(params.color0/255, 1.);
* let color1 = vec4(params.color1/255, 1.);
* let finalColor:vec4f = mix(color0, color1, params.scale);
*/
setUniform(name, value, type = null) {
const uniformToUpdate = this.#uniforms.find(name);
if (!uniformToUpdate && this.#initialized) {
console.error(`'${name}' uniform needs to be declared before the init() prior to call it in update().`);
}
if (uniformToUpdate && type) {
// if name exists is an update
this.#debug && console.warn(`setUniform(${name}, [${value}], ${type}) can't set the type of an already defined uniform.`);
}
if (uniformToUpdate) {
uniformToUpdate.value = value;
return uniformToUpdate;
}
const uniform = new Uniform({
name,
value,
type
})
this.#uniforms.add(uniform);
return uniform;
}
#setMeshUniform(name, value, type = null) {
const uniformToUpdate = this.#nameExists(this.#meshUniforms, name);
if (uniformToUpdate && type) {
// if name exists is an update
this.#debug && console.warn(`#setMeshUniform(${name}, [${value}], ${type}) can't set the type of an already defined uniform.`);
}
if (uniformToUpdate) {
uniformToUpdate.value = value;
return uniformToUpdate;
}
const uniform = new Uniform({
name,
value,
type,
});
this.#meshUniforms.push(uniform);
return uniform;
}
#setCameraUniform(name, value, type = null) {
const uniformToUpdate = this.#nameExists(this.#cameraUniforms, name);
if (uniformToUpdate && type) {
// if name exists is an update
// console.warn(`#setCameraUniform(${name}, [${value}], ${type}) can't set the type of an already defined uniform.`);
}
if (uniformToUpdate) {
uniformToUpdate.value = value;
return uniformToUpdate;
}
const uniform = new Uniform({
name,
value,
type,
});
this.#cameraUniforms.push(uniform);
return uniform;
}
/**
* Updates a list of uniforms
* @param {Array<{name:String, value:Number}>} arr object array of the type: `{name, value}`
*/
updateUniforms(arr) {
arr.forEach(uniform => {
const variable = this.#uniforms[uniform.name];
if (!variable) {
throw '`updateUniform()` can\'t be called without first `setUniform()`.';
}
variable.value = uniform.value;
})
}
/**
* Create a WGSL `const` initialized from JS.
* Useful to set a value you can't initialize in WGSL because you don't have
* the value yet.
* The constant will be ready to use on the WGSL shder string.
* @param {String} name
* @param {string|Number} value
* @param {String} type
* @returns {Object}
*
* @example
*
* // js side
* points.setConstant('NUMPARTICLES', 64, 'f32')
*
* // wgsl string
* // this should print `NUMPARTICLES` and be ready to use.
* const NUMPARTICLES:f32 = 64; // this will be hidden to the developer
*
* // your code:
* const particles = array<Particle, NUMPARTICLES>();
*/
setConstant(name, value, type = null) {
const constantToUpdate = this.#nameExists(this.#constants, name);
if (constantToUpdate) {
// if name exists is an update
throw '`setConstant()` can\'t update a const after it has been set.';
}
const constant = new Constant({
name,
value,
type,
})
this.#constants.add(constant);
return constant;
}
/**
* Creates a persistent memory buffer across every frame call. See [GPUBuffer](https://www.w3.org/TR/webgpu/#gpubuffer)
* <br>
* Meaning it can be updated in the shaders across the execution of every frame.
* <br>
* It can have almost any type, like `f32` or `vec2f` or even array<f32>.
* <br>
* It can also be initialized with data with the {@link Storage#setValue} method
* @param {string} name Name that the Storage will have in the shader
* @param {string} type Name of the struct already existing on the
* @param {Uint8Array<ArrayBuffer>|Array<Number>|Number} value array with the data that must match the struct.
* shader. This will be the type of the Storage.
* @returns {Storage}
*
* @example
* // js
* points.setStorage('result', 'f32');
*
* // wgsl string
* result[index] = 128.;
*
* @example
* // js
* points.setStorage('colors', 'array<vec3f, 6>');
*
* // wgsl string
* colors[index] = vec3f(248, 208, 146) / 255;
*
* @example
* // add data from initialization
* // js
* points.setStorage('vertex_data', `array<vec4f, ${vertex_data.length}>`)
.setValue(vertex_data.flat());
*/
setStorage(name, type = null, value = null) {
const storageToUpdate = this.#storages.find(name);
if (storageToUpdate) {
storageToUpdate.value = value;
return storageToUpdate;
}
const storage = new Storage({
name,
value,
type,
});
this.#storages.add(storage);
return storage;
}
/**
* @deprecated Since v0.8.0 use {@link setStorage}
* Creates a persistent memory buffer across every frame call that can be updated.
* See [GPUBuffer](https://www.w3.org/TR/webgpu/#gpubuffer)
* <br>
* Meaning it can be updated in the shaders across the execution of every frame.
* <br>
* It can have almost any type, like `f32` or `vec2f` or even array<f32>.
* <br>
* The difference with {@link Points#setStorage|setStorage} is that this can be initialized
* with data.
* @param {string} name Name that the Storage will have in the shader.
* @param {Uint8Array<ArrayBuffer>|Array<Number>|Number} value array with the data that must match the struct.
* @param {string} type Name of the struct already existing on the
* shader. This will be the type of the Storage.
* @param {boolean} readable if this is going to be used to read data back.
* @param {GPUShaderStage} shaderStage this tells to what shader the storage is bound
*
* @example
* // js examples/data1
* const firstMatrix = [
* 2, 4 , // 2 rows 4 columns
* 1, 2, 3, 4,
* 5, 6, 7, 8
* ];
* const secondMatrix = [
* 4, 2, // 4 rows 2 columns
* 1, 2,
* 3, 4,
* 5, 6,
* 7, 8
* ];
*
* // Matrix should exist as a struct in the wgsl shader
* points.setStorageMap('firstMatrix', firstMatrix, 'Matrix');
* points.setStorageMap('secondMatrix', secondMatrix, 'Matrix');
* points.setStorage('resultMatrix', 'Matrix', true); // this reads data back
*
* // wgsl string
* struct Matrix {
* size : vec2f,
* numbers: array<f32>,
* }
*
* resultMatrix.size = vec2(firstMatrix.size.x, secondMatrix.size.y);
*/
setStorageMap(name, value, type, readable = false, shaderStage = GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT | GPUShaderStage.COMPUTE) {
const storageToUpdate = this.#storages.find(name);
if (!Array.isArray(value) && value.constructor !== Uint8Array) {
value = new Uint8Array([value]);
}
if (storageToUpdate) {
storageToUpdate.value = value;
storageToUpdate.updated = true;
return storageToUpdate;
}
// const storage = new Storage({
// updated: true,
// mapped: true,
// name,
// type,
// shaderStage,
// value,
// read,
// });
this.#storages[name] = value;
const storage = this.#storages[name];
storage.updated = true;
storage.mapped = true;
storage.type = type;
storage.shaderStage = shaderStage;
storage.readable = readable;
return storage;
}
/**
* To read data back from a `setStorage` with `readable` param `true`
* @param {String} name name of the Storage to read data from
* @warning If there's en error or warning here
* `[Buffer "name"] used in submit while mapped.`
* the update (or function that calls this method) needs an `await`
* @returns {Float32Array} Array with the result
*/
async readStorage(name) {
const storageItem = this.#storages.find(name);
let arrayBufferCopy = null;
if (storageItem?.readable) {
try {
await storageItem.bufferRead.mapAsync(GPUMapMode.READ);
const arrayBuffer = storageItem.bufferRead.getMappedRange();
arrayBufferCopy = new Float32Array(arrayBuffer.slice(0));
storageItem.bufferRead.unmap();
} catch (error) {
// if we switch projects mapasync fails
// we ignore it
}
}
return arrayBufferCopy;
}
/**
* Layers of data made of `vec4f`.
* This creates a storage array named `layers` of the size
* of the screen in pixels;
* @param {Number} numLayers
* @param {GPUShaderStage} shaderStage
*
* @example
* // js
* points.setLayers(2);
*
* // wgsl string
* var point = textureLoad(image, vec2<i32>(ix,iy), 0);
* layers[0][pointIndex] = point;
* layers[1][pointIndex] = point;
*/
setLayers(numLayers, shaderStage) {
// TODO: check what data to return
// TODO: improve jsdoc because the array definition is confusing
for (let layerIndex = 0; layerIndex < numLayers; layerIndex++) {
this.#layers.shaderStage = shaderStage;
this.#layers.push({
name: `layer${layerIndex}`,
size: this.#canvas.width * this.#canvas.height,
structName: 'vec4f',
structSize: 16,
array: null,
buffer: null
});
}
}
#nameExists(arrayOfObjects, name) {
return arrayOfObjects.find(obj => obj.name === name);
}
/**
* Creates a `sampler` to be sent to the shaders. Internally it will be a {@link GPUSampler}
* @param {string} name Name of the `sampler` to be called in the shaders.
* @param {GPUSamplerDescriptor} descriptor `Object` with properties that affect the image. See example below.
* @returns {Object}
*
* @example
* // js
* const descriptor = {
* addressModeU: 'repeat',
* addressModeV: 'repeat',
* magFilter: 'nearest',
* minFilter: 'nearest',
* mipmapFilter: 'nearest',
* //maxAnisotropy: 10,
* }
*
* points.setSampler('imageSampler', descriptor);
*
* // wgsl string
* let value = texturePosition(image, imageSampler, position, in.uvr, true);
*/
setSampler(name, descriptor, shaderStage) {
if ('sampler' == name) {
throw 'setSampler: `name` can not be sampler since is a WebGPU keyword.';
}
const exists = this.#nameExists(this.#samplers, name)
if (exists) {
this.#debug && console.warn(`setSampler: \`${name}\` already exists.`);
return exists;
}
// Create a sampler with linear filtering for smooth interpolation.
descriptor = descriptor || {
addressModeU: 'clamp-to-edge',
addressModeV: 'clamp-to-edge',
magFilter: 'linear',
minFilter: 'linear',
mipmapFilter: 'linear',
//maxAnisotropy: 10,
};
const sampler = {
name: name,
descriptor: descriptor,
shaderStage: shaderStage,
resource: null,
internal: false
};
this.#samplers.push(sampler);
return sampler;
}
/**
* Creates a `texture_2d` in the shaders.<br>
* Used to write data and then print to screen.<br>
* It can also be used for write the current render pass (what you see on the screen)
* to this texture, to be used in the next cycle of this render pass; meaning
* you effectively have the previous frame data before printing the next one.
*
* @param {String} name Name to call the texture in the shaders.
* @param {boolean} copyCurrentTexture If you want the fragment output to be copied here.
* @param {GPUShaderStage} shaderStage To what {@link GPUShaderStage} you want to exclusively use this variable.
* @param {Number} renderPassIndex If using `copyCurrentTexture`
* this tells which RenderPass it should get the data from. If not set then it will grab the last pass.
* @returns {Object}
*
* @example
* // js
* points.setTexture2d('feedbackTexture', true);
*
* // wgsl string
* var rgba = textureSampleLevel(
* feedbackTexture, feedbackSampler,
* vec2f(f32(GlobalId.x), f32(GlobalId.y)),
* 0.0
* );
*
*/
setTexture2d(name, copyCurrentTexture, shaderStage, renderPassIndex) {
const exists = this.#nameExists(this.#textures2d, name);
if (exists) {
this.#debug && console.warn(`setTexture2d: \`${name}\` already exists.`);
return exists;
}
const texture2d = {
name,
copyCurrentTexture,
shaderStage,
texture: null,
renderPassIndex,
internal: false
}
this.#textures2d.push(texture2d);
return texture2d;
}
/**
* Creates a depth map from the selected `renderPassIndex`
* @param {String} name
* @param {GPUShaderStage} shaderStage
* @param {Number} renderPassIndex
* @returns {Object}
*/
setTextureDepth2d(name, shaderStage, renderPassIndex) {
const exists = this.#nameExists(this.#texturesDepth2d, name);
if (exists) {
this.#debug && console.warn(`setTextureDepth2d: \`${name}\` already exists.`);
return exists;
}
renderPassIndex ||= 0;
const textureDepth2d = {
name,
shaderStage,
texture: null,
renderPassIndex,
internal: false,
}
this.#texturesDepth2d.push(textureDepth2d);
return textureDepth2d;
}
copyTexture(nameTextureA, nameTextureB) {
const texture2d_A = this.#nameExists(this.#textures2d, nameTextureA);
const texture2d_B = this.#nameExists(this.#textures2d, nameTextureB);
if (!(texture2d_A && texture2d_B)) {
console.error('One of the textures does not exist.');
}
const a = texture2d_A.texture;
const cubeTexture = this.#device.createTexture({
label: '_cubeTexture',
size: [a.width, a.height, 1],
format: 'rgba8unorm',
usage:
GPUTextureUsage.TEXTURE_BINDING |
GPUTextureUsage.COPY_DST |
GPUTextureUsage.RENDER_ATTACHMENT,
});
texture2d_B.texture = cubeTexture;
this.#texturesToCopy.push({ a, b: texture2d_B.texture });
}
/**
* Loads an image as `texture_2d` and then it will be available to read
* data from in the shaders.<br>
* Supports web formats like JPG, PNG.
* @param {string} name identifier it will have in the shaders
* @param {string} path image address in a web server
* @param {GPUShaderStage} shaderStage in what shader type it will exist only
* @returns {Object}
*
* @example
* // js
* await points.setTextureImage('image', './../myimage.jpg');
*
* // wgsl string
* let rgba = texturePosition(image, imageSampler, position, in.uvr, true);
*/
async setTextureImage(name, path, shaderStage = null) {
const texture2dToUpdate = this.#nameExists(this.#textures2d, name);
const response = await fetch(path);
const blob = await response.blob();
const imageBitmap = await createImageBitmap(blob);
if (texture2dToUpdate) {
if (shaderStage) {
throw '`setTextureImage()` the param `shaderStage` should not be updated after its creation.';
}
this.#textureUpdated = true;
texture2dToUpdate.imageTexture.bitmap = imageBitmap;
const cubeTexture = this.#device.createTexture({
label: '_cubeTexture setTextureImage',
size: [imageBitmap.width, imageBitmap.height, 1],
format: 'rgba8unorm',
usage:
GPUTextureUsage.TEXTURE_BINDING |
GPUTextureUsage.COPY_SRC |
GPUTextureUsage.COPY_DST |
GPUTextureUsage.RENDER_ATTACHMENT,
});
this.#device.queue.copyExternalImageToTexture(
{ source: imageBitmap },
{ texture: cubeTexture },
[imageBitmap.width, imageBitmap.height]
);
texture2dToUpdate.texture = cubeTexture;
return texture2dToUpdate;
}
const texture2d = {
name: name,
copyCurrentTexture: false,
shaderStage: shaderStage,
texture: null,
renderPassIndex: null,
imageTexture: {
bitmap: imageBitmap
},
internal: false
}
this.#textures2d.push(texture2d);
return texture2d;
}
/**
* Loads a `HTMLElement` as `texture_2d`. It will automatically interpret
* the CSS associated with the element to render it.
* `@font-family` needs to be explicitly described in the element's css.
* This will only generate an image, so animations will not work.
* @param {String} name identifier it will have in the shaders
* @param {HTMLElement} element element loaded in the DOM or dynamically
* @param {GPUShaderStage} shaderStage in what shader type it will exist only
* @returns {Object}
*
* @example
* // js
* const element = document.getElementById('my_element')
* await points.setTextureElement('image', element);
*
* // wgsl string
* let color = texture(image, imageSampler, in.uvr, true);
*/
async setTextureElement(name, element, shaderStage = null) {
const styles = getCSS(element);
const cssText = styles.map(style => style.cssText).join('\n');
const path = await elToImage(element, cssText);
return await this.setTextureImage(name, path, shaderStage);
}
/**
* Loads a text string as a texture.<br>
* Using an Atlas or a Spritesheet with UTF-16 chars (`path`) it will create a new texture
* that contains only the `text` characters.<br>
* Characters in the atlas `path` must be in order of the UTF-16 chars.<br>
* It can have missing characters at the end or at the start, so the `offset` is added to take account for those chars.<br>
* For example, `A` is 65, but if one character is removed before the letter `A`, then offset is `-1`
* @param {String} name id of the wgsl variable in the shader
* @param {String} text text you want to load as texture
* @param {String} path atlas to grab characters from, image address in a web server
* @param {{x: number, y: number}} size size of a individual character e.g.: `{x:10, y:20}`
* @param {Number} offset how many characters back or forward it must move to start
* @param {GPUShaderStage} shaderStage To what {@link GPUShaderStage} you want to exclusively use this variable.
* @returns {Object}
*
* @example
* // js
* await points.setTextureString(
* 'textImg',
* 'Custom Text',
* './../img/inconsolata_regular_8x22.png',
* size,
* -32
* );
*
* // wgsl string
* let textColors = texture(textImg, imageSampler, in.uvr, true);
*
*/
async setTextureString(name, text, path, size, offset = 0, shaderStage = null) {
const atlas = await loadImage(path);
const textImg = strToImage(text, atlas, size, offset);
return this.setTextureImage(name, textImg, shaderStage);
}
/**
* Load images as texture_2d_array
* @param {string} name id of the wgsl variable in the shader
* @param {Array} paths image addresses in a web server
* @param {GPUShaderStage} shaderStage
*/
// TODO: verify if this can be updated after creation
// TODO: return texture2dArray object
async setTextureImageArray(name, paths, shaderStage) {
if (this.#nameExists(this.#textures2dArray, name)) {
// TODO: throw exception here
return;
}
const imageBitmaps = [];
for await (const path of paths) {
const response = await fetch(path);
const blob = await response.blob();
imageBitmaps.push(await createImageBitmap(blob));
}
const texture2dArrayItem = {
name: name,
copyCurrentTexture: false,
shaderStage: shaderStage,
texture: null,
imageTextures: {
bitmaps: imageBitmaps
},
internal: false
}
this.#textures2dArray.push(texture2dArrayItem);
return texture2dArrayItem;
}
/**
* Loads a video as `texture_external`and then
* it will be available to read data from in the shaders.
* Supports web formats like mp4 and webm.
* @param {string} name id of the wgsl variable in the shader
* @param {string} path video address in a web server
* @param {GPUShaderStage} shaderStage
* @returns {Object}
*
* @example
* // js
* await points.setTextureVideo('video', './../myvideo.mp4');
*
* // wgsl string
* let rgba = textureExternalPosition(video, imageSampler, position, in.uvr, true);
*/
async setTextureVideo(name, path, shaderStage) {
if (this.#nameExists(this.#texturesExternal, name)) {
throw `setTextureVideo: ${name} already exists.`;
}
const video = document.createElement('video');
video.loop = true;
video.autoplay = true;
video.muted = true;
video.src = new URL(path, import.meta.url).toString();
await video.play();
const textureExternal = {
name: name,
shaderStage: shaderStage,
video: video,
internal: false
};
this.#texturesExternal.push(textureExternal);
return textureExternal;
}
/**
* Loads webcam as `texture_external`and then
* it will be available to read data from in the shaders.
* @param {String} name id of the wgsl variable in the shader
* @param {{width:Number, height:Number}} size to crop the video. WebGPU might throw an error if size does not match.
* @param {GPUShaderStage} shaderStage
* @returns {Object}
* @throws a WGSL error if the size doesn't match possible crop size
* @example
* // js
* await points.setTextureWebcam('video');
*
* // wgsl string
* et rgba = textureExternalPosition(video, imageSampler, position, in.uvr, true);
*/
async setTextureWebcam(name, size = { width: 1080, height: 1080 }, shaderStage) {
if (this.#nameExists(this.#texturesExternal, name)) {
throw `setTextureWebcam: ${name} already exists.`;
}
const video = document.createElement('video');
video.muted = true;
if (navigator.mediaDevices.getUserMedia) {
await navigator.mediaDevices.getUserMedia({ video: { width: { ideal: size.width }, height: { ideal: size.height } } })
.then(async stream => {
video.srcObject = stream;
await video.play();
})
.catch(err => { throw err });
}
const textureExternal = {
name,
shaderStage,
video,
size,
internal: false
};
this.#texturesExternal.push(textureExternal);
return await new Promise(resolve => resolve(textureExternal));
}
/**
* Assigns an audio FrequencyData to a StorageMap.<br>
* Calling setAudio creates a Storage with `name` in the wgsl shaders.<br>
* From this storage you can read the audio data sent to the shader as numeric values.<br>
* Values in `audio.data` are composed of integers on a scale from 0..255
* @param {string} name name of the Storage and prefix of the length variable e.g. `[name]Length`.
* @param {string} path audio file address in a web server
* @param {Number} volume
* @param {boolean} loop
* @param {boolean} autoplay
* @returns {HTMLAudioElement}
* @example
* // js
* const audio = points.setAudio('audio', 'audiofile.ogg', volume, loop, autoplay);
*
* // wgsl
* let audioX = audio.data[ u32(in.uvr.x * params.audioLength)] / 256;
*/
setAudio(name, path, volume, loop, autoplay) {
const audio = new Audio(path);
audio.volume = volume;
audio.autoplay = autoplay;
audio.loop = loop;
const { signal } = this.#abortController;
const listenerOptions = {
signal,
capture: false
}
const sound = {
name: name,
path: path,
audio: audio,
analyser: null,
data: null
}
// this.#audio.play();
// audio
const audioContext = new AudioContext();
const resume = _ => { audioContext.resume() }
if (audioContext.state === 'suspended') {
document.body.addEventListener('touchend', resume, listenerOptions);
document.body.addEventListener('click', resume, listenerOptions);
}
const source = audioContext.createMediaElementSource(audio);
// // audioContext.createMediaStreamSource()
const analyser = audioContext.createAnalyser();
analyser.fftSize = 2048;
source.connect(analyser);
analyser.connect(audioContext.destination);
const bufferLength = analyser.fftSize;//analyser.frequencyBinCount;
// const bufferLength = analyser.frequencyBinCount;
const data = new Uint8Array(bufferLength);
// analyser.getByteTimeDomainData(data);
analyser.getByteFrequencyData(data);
// storage that will have the data on WGSL
this.setStorage(name,
// `array<f32, ${bufferLength}>`
'Sound' // custom struct in defaultStructs.js
).setValue(data).stream = true;
// uniform that will have the data length as a quick reference
this.setUniform(`${name}Length`, analyser.frequencyBinCount);
sound.analyser = analyser;
sound.data = data;
this.#sounds.push(sound);
return audio;
}
// TODO: verify this method
setTextureStorage2d(name, shaderStage) {
if (this.#nameExists(this.#texturesStorage2d, name)) {
throw `setTextureStorage2d: ${name} already exists.`
}
const texturesStorage2d = {
name: name,
shaderStage: shaderStage,
texture: null,
internal: false
};
this.#texturesStorage2d.push(texturesStorage2d);
return texturesStorage2d;
}
/**
* Special texture where data can be written to it in the Compute Shader and
* read from in the Fragment Shader OR from a {@link RenderPass} to another.
* If you use writeIndex and readIndex it will share data between `RenderPasse`s
* Is a one way communication method.
* Ideal to store data to it in the Compute Shader and later visualize it in
* the Fragment Shader.
* @param {string} writeName name of the variable in the compute shader
* @param {string} readName name of the variable in the fragment shader
* @param {number} writeIndex RenderPass allowed to write into `outputTex`
* @param {number} readIndex RenderPass allowed to read from `computeTexture`
* @param {Array<number, 2>} size dimensions of the texture, by default screen
* size
* @returns {Object}
*
* @example
*
* // js
* points.setBindingTexture('outputTex', 'computeTexture');
*
* // wgsl string
* //// compute
* textureStore(outputTex, GlobalId.xy, rgba);
* //// fragment
* let value = texturePosition(computeTexture, imageSampler, position, uv, false);
*/
setBindingTexture(writeName, readName, writeIndex, readIndex, size) {
if ((Number.isInteger(writeIndex) && !Number.isInteger(readIndex)) || (!Number.isInteger(writeIndex) && Number.isInteger(readIndex))) {
throw 'The parameters writeIndex and readIndex must both be declared.';
}
const usesRenderPass = Number.isInteger(writeIndex) && Number.isInteger(readIndex);
// TODO: validate that names don't exist already
const bindingTexture = {
write: {
name: writeName,
shaderStage: GPUShaderStage.COMPUTE,
renderPassIndex: writeIndex
},
read: {
name: readName,
shaderStage: GPUShaderStage.FRAGMENT,
renderPassIndex: readIndex
},
texture: null,
size: size,
usesRenderPass,
internal: false,
name: `${writeName}::${readName}`
}
this.#bindingTextures.push(bindingTexture);
return bindingTexture;
}
/**
* Creates a Perspective camera with a given name to be used in the shaders.
* The name is used as identifier in the shaders for the Projection and View matrices.
*
* The name will be inside the `camera` uniform and composed with the
* projection and view identifiers: e.g.:
* name: mycamera
* uniform buffers:
* camera.mycamera_projection;
* camera.mycamera_view
*
* The camera must be called on the update method so the aspect is updated by default
* with the canvas width and height.
* @param {String} name camera name in the shader for the projection and view
* @param {vec3f} position
* @param {Number} fov field of view angle
* @param {Number} near clipping near
* @param {Number} far clipping far
* @param {Number} aspect ratio of the camera, by default it choses the canvas aspect ratio
*
* @example
* // js
* points.setCameraPerspective('camera', [0, 0, -5]);
*
* // wgsl string
* let clip = camera.camera_projection * camera.camera_view * vec4f(world, 1.);
*/
setCameraPerspective(name, position = [0, 0, -5], lookAt = [0, 0, 0], fov = 45, near = .1, far = 100, aspect = null) {
const fov_radians = fov * (Math.PI / 180);
const f = 1.0 / Math.tan(fov_radians / 2); // ≈ 2.414
const nf = 1 / (near - far);
aspect ??= this.#canvas.width / this.#canvas.height;
const perspectiveMatrix = [
f / aspect, 0, 0, 0,
0, f, 0, 0,
0, 0, (far + near) * nf, -1,
0, 0, (2 * far * near) * nf, 0
]
this.#setCameraUniform(
`${name}_projection`,
perspectiveMatrix,
'mat4x4<f32>'
);
const up = [0, 1, 0];
const ff = normalize(sub(lookAt, position));
const r = normalize(cross(ff, up));
const u = cross(r, ff);
const viewMatrix = [
r[0], u[0], -ff[0], 0,
r[1], u[1], -ff[1], 0,
r[2], u[2], -ff[2], 0,
-dot(r, position), -dot(u, position), dot(ff, position), 1
]
this.#setCameraUniform(`${name}_view`, viewMatrix, 'mat4x4<f32>');
}
/**
* Creates an Orthographic camera with a given name to be used in the shaders.
* The name is used as identifier in the shaders for the Projection matrix.
*
* The name will be inside the `camera` uniform and composed with the
* projection identifier: e.g.:
* name: mycamera
* uniform buffer:
* camera.mycamera_projection;
*
* @param {String} name
* @param {Number} left
* @param {Number} right
* @param {Number} top
* @param {Number} bottom
* @param {Number} near
* @param {Number} far
*
* @example
* // js
* points.setCameraOrthographic('camera');
*
* // wgsl string
* let clip = camera.camera_projection * vec4f(world, 0.0, 1.0);
*
*/
setCameraOrthographic(name, left = -1, right = 1, top = 1, bottom = -1, near = -1, far = 1, position = [0, 0, -5], lookAt = [0, 0, 0]) {
// const aspect = canvas.width / canvas.height; // alternative to aspect in shader
const lr = 1 / (right - left);
const bt = 1 / (top - bottom);
const nf = 1 / (near - far);
const orthoMatrix = [
2 * lr, 0, 0, 0,
0, 2 * bt, 0, 0,
0, 0, nf, 0,
-(right + left) * lr,
-(top + bottom) * bt,
near * nf,
1
];
this.#setCameraUniform(`${name}_projection`, orthoMatrix, 'mat4x4<f32>');
const up = [0, 1, 0];
const ff = normalize(sub(lookAt, position));
const r = normalize(cross(ff, up));
const u = cross(r, ff);
const viewMatrix = [
r[0], u[0], -ff[0], 0,
r[1], u[1], -ff[1], 0,
r[2], u[2], -ff[2], 0,
-dot(r, position), -dot(u, position), dot(ff, position), 1
]
this.#setCameraUniform(`${name}_view`, viewMatrix, 'mat4x4<f32>');
}
/**
* Listens for an event dispatched from WGSL code
* @param {String} name Number that represents an event Id
* @param {Function} callback function to be called when the event occurs
* @param {Number} structSize size of the array data to be returned
*
* @example
* // js
* // the event name will be reflected as a variable name in the shader
* // and a data variable that starts with the name
* points.addEventListener('click_event', data => {
* // response action in JS
* const [a, b, c, d] = data;
* console.log({a, b, c, d});
* }, 4); // data will have 4 items
*
* // wgsl string
* if(params.mouseClick == 1.){
* // we update our event response data with something we need
* // on the js side
* // click_event_data has 4 items to fill
* click_event_data[0] = params.time;
* // Same name of the Event
* // we fire the event with a 1
* // it will be set to 0 in the next frame
* click_event.updated = 1;
* }
*
*/
addEventListener(name, callback, structSize = 1) {
const { COMPUTE, FRAGMENT } = GPUShaderStage;
// TODO: remove structSize
// this extra 1 is for the boolean flag in the Event struct
this.#storages.add(new Storage({
name,
type: 'Event',
readable: true,
shaderStage: COMPUTE | FRAGMENT,
value: Array(4).fill(0)
}));
this.#storages.add(new Storage({
name: `${name}_data`,
type: `array<f32, ${structSize}>`,
readable: true,
shaderStage: COMPUTE | FRAGMENT
}));
this.#events.set(this.#events_ids,
{
id: this.#events_ids,
name,
callback,
}
);
++this.#events_ids;
}
/**
* @param {GPUShaderStage} shaderStage
* @param {RenderPass} renderPass
* @returns {String} string with bindings
*/
#createDynamicGroupBindings(shaderStage, { index: renderPassIndex, internal }, groupId = 0) {
if (!shaderStage) {
throw '`GPUShaderStage` is required';
}
// const groupId = 0;
let dynamicGroupBindings = '';
let bindingIndex = 0;
if (this.#uniforms.list.length) {
dynamicGroupBindings += /*wgsl*/`@group(${groupId}) @binding(${bindingIndex}) var <uniform> params: Params;\n`;