UNPKG

@absulit/points

Version:

A Generative Art library made in WebGPU

1,332 lines (1,243 loc) 127 kB
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`;