lib/src/simulator.ts
import updateVertexShaderSource from "./shaders/update-vert.glsl?raw";
import updateFragmentShaderSource from "./shaders/update-frag.glsl?raw";
import renderVertexShaderSource from "./shaders/render-vert.glsl?raw";
import renderFragmentShaderSource from "./shaders/render-frag.glsl?raw";
// constnats
const PI = Math.PI;
const random = Math.random;
const RANDOM_IMG_SIZE = 200;
/** shader names */
// Uniforms
const U_DT = "dt";
const U_EXTRA_RANDOM = "e";
const U_FORCE_FIELD = "g"; /** gravity */
const U_ORIGIN = "o";
const U_ANGLE_RANGE = "aR";
const U_SPEED_RANGE = "sR";
const U_LIFE_RANGE = "lR";
const U_PARTICLE_COLOR = "c";
const U_SCALE = "s";
// inputs
const IN_POSITION = "p";
const IN_LIFE = "l";
const IN_VELOCITY = "v";
// outputs
const OUT_POSITION = "oP";
const OUT_LIFE = "oL";
const OUT_VELOCITY = "oV";
type Vector2D = [number, number];
export interface ParticlesOptions {
/** Particle Color @defaultValue [1, 0, 0, 1] -> red */
rgba?: [number, number, number, number];
/** Maximum number of particles @defaultValue 100_000 */
maxParticles?: number;
/** Particle generation rate @defaultValue 0.5 */
generationRate?: number;
/** Overlay mode @defaultValue false */
overlay?: boolean;
/** Disable mouse interaction @defaultValue false */
mouseOff?: boolean;
/** Min and max angles in radians @defaultValue [-Math.PI, Math.PI] */
angleRange?: [number, number];
/** Min and max age of particles in seconds */
ageRange?: [number, number];
/** Speed range [minSpeed, maxSpeed] */
speedRange?: [number, number];
/** Initial origin, will update as per mouse position if mouseOff is not set @defaultValue [0, 0] */
origin?: [number, number];
/** Constant force [fx, fy] or a force field texture (Work In Progress) */
forceField?: Vector2D; //| Vector[][] | string;
}
const defaultOptions: ParticlesOptions = {
rgba: [1, 0, 0, 0.5],
maxParticles: 1000,
generationRate: 0.5,
// setting range from -PI to PI craetes some patches because of overflows
angleRange: [-2 * PI, 2 * PI],
origin: [-1, -1],
speedRange: [0.02, 0.2],
ageRange: [0.01, 0.6],
forceField: [0, 0.1],
};
/** generate initial data for the simulation */
const getInitialData = (maxParticles: number) => {
const data = [];
for (let i = 0; i < maxParticles; i++) data.push(0, 0, 0, 0, 0);
return data;
};
/** generate random RG data for source of randomness within the simulation */
const randomRGData = (): Uint8Array => {
const data = [];
for (let i = 0; i < 2 * RANDOM_IMG_SIZE ** 2; i++) data.push(random() * 255);
return new Uint8Array(data);
};
/** Particles simulator */
const simulate = (
canvas: HTMLCanvasElement,
gl: WebGL2RenderingContext,
options: ParticlesOptions,
) => {
/** todo Normalize options
* canvas positions are between -1 to 1 on all axes
*/
// skipcq: JS-0339 -- defined in default options
const angleRange = options.angleRange! as [number, number];
/** Create shader */
const createShader = (type: number, source: string): WebGLShader => {
const shader = gl.createShader(type);
if (!shader) throw new Error("Failed to create shader");
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
/* v8 ignore next */
const info = gl.getShaderInfoLog(shader);
/* v8 ignore next */
gl.deleteShader(shader);
/* v8 ignore next */
throw new Error("Could not compile WebGL shader. -->" + info); // skipcq: JS-0246
/* v8 ignore next */
}
return shader;
};
/** Create program */
const createProgram = (
vertexShaderSource: string,
fragmentShaderSource: string,
transformFeedbackVaryings?: string[],
): WebGLProgram => {
const vertexShader = createShader(gl.VERTEX_SHADER, vertexShaderSource);
const fragmentShader = createShader(gl.FRAGMENT_SHADER, fragmentShaderSource);
const program = gl.createProgram();
if (!program) throw new Error("Failed to create program");
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
// link transform feedback
transformFeedbackVaryings &&
gl.transformFeedbackVaryings(program, transformFeedbackVaryings, gl.INTERLEAVED_ATTRIBS);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
/* v8 ignore next */
// eslint-disable-next-line no-console -- error handling
console.error(gl.getProgramInfoLog(program));
/* v8 ignore next */
throw new Error("Failed to link program");
/* v8 ignore next */
}
return program;
};
const updateProgram = createProgram(updateVertexShaderSource, updateFragmentShaderSource, [
OUT_POSITION,
OUT_LIFE,
OUT_VELOCITY,
]);
const renderProgram = createProgram(renderVertexShaderSource, renderFragmentShaderSource);
// skipcq: JS-0339 --> It is never undefined
const initialData = new Float32Array(getInitialData(options.maxParticles!));
/** create buffer */
const createBuffer = (): WebGLBuffer | null => {
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, initialData, gl.STREAM_DRAW);
return buffer;
};
const buffers = [createBuffer(), createBuffer()];
const updateAttribLocations = [
{
location: gl.getAttribLocation(updateProgram, IN_POSITION),
nVect: 2,
},
{
location: gl.getAttribLocation(updateProgram, IN_LIFE),
nVect: 1,
},
{
location: gl.getAttribLocation(updateProgram, IN_VELOCITY),
nVect: 2,
},
];
const renderAttribLocations = [
{
location: gl.getAttribLocation(renderProgram, IN_POSITION),
nVect: 2,
},
];
const vertexArrayObjects = [
gl.createVertexArray(),
gl.createVertexArray(),
gl.createVertexArray(),
gl.createVertexArray(),
];
[
updateAttribLocations,
updateAttribLocations,
renderAttribLocations,
renderAttribLocations,
].forEach((attributes, index) => {
gl.bindVertexArray(vertexArrayObjects[index]);
gl.bindBuffer(gl.ARRAY_BUFFER, buffers[index % 2]);
let offset = 0;
for (const attribute of attributes) {
gl.enableVertexAttribArray(attribute.location);
gl.vertexAttribPointer(attribute.location, attribute.nVect, gl.FLOAT, false, 4 * 5, offset);
offset += 4 * attribute.nVect;
}
gl.bindVertexArray(null);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
});
gl.clearColor(0, 0, 0, 0);
gl.useProgram(updateProgram);
const rgNoiseTexture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, rgNoiseTexture);
gl.activeTexture(gl.TEXTURE0);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RG8,
RANDOM_IMG_SIZE,
RANDOM_IMG_SIZE,
0,
gl.RG,
gl.UNSIGNED_BYTE,
randomRGData(),
);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.MIRRORED_REPEAT);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.MIRRORED_REPEAT);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
/** set uniform value for updateProgram */
const setUpdateUniform = (name: string, x: number, y?: number): void => {
const location = gl.getUniformLocation(updateProgram, name);
y ? gl.uniform2f(location, x, y) : gl.uniform1f(location, x);
};
setUpdateUniform(U_ANGLE_RANGE, ...angleRange);
// skipcq: JS-0339 -- set in default options
setUpdateUniform(U_LIFE_RANGE, ...options.ageRange!);
// skipcq: JS-0339 -- set in default options
setUpdateUniform(U_SPEED_RANGE, ...options.speedRange!);
// skipcq: JS-0339 -- forcefield is always set by the default options
setUpdateUniform(U_FORCE_FIELD, ...options.forceField!);
let prevT = 0;
let bornParticles = 0;
let readIndex = 0;
let writeIndex = 1;
let mouseX = 0;
let mouseY = 0;
/** Set origin - from where all particles are generateds */
const setOrigin = (x: number, y: number): void => {
mouseX = x;
mouseY = y;
};
/** The render loop */
const render = (timeStamp: number): void => {
let dt = timeStamp - prevT;
prevT = timeStamp;
if (dt > 500) dt = 0;
// eslint-disable-next-line no-bitwise -- required
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.useProgram(updateProgram);
setUpdateUniform(U_DT, dt / 1000);
setUpdateUniform(U_EXTRA_RANDOM, random());
setUpdateUniform(U_ORIGIN, mouseX, mouseY);
gl.bindVertexArray(vertexArrayObjects[readIndex]);
gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 0, buffers[writeIndex]);
gl.enable(gl.RASTERIZER_DISCARD);
gl.beginTransformFeedback(gl.POINTS);
gl.drawArrays(gl.POINTS, 0, bornParticles);
gl.endTransformFeedback();
gl.disable(gl.RASTERIZER_DISCARD);
gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 0, null);
gl.bindVertexArray(vertexArrayObjects[readIndex + 2]);
gl.useProgram(renderProgram);
// skipcq: JS-0339 -- set in default options
gl.uniform4f(gl.getUniformLocation(renderProgram, U_PARTICLE_COLOR), ...options.rgba!);
const height = canvas.height;
const width = canvas.width;
gl.uniform2f(
gl.getUniformLocation(renderProgram, U_SCALE),
...((height > width ? [1, width / height] : [height / width, 1]) as [number, number]),
);
gl.drawArrays(gl.POINTS, 0, bornParticles);
[readIndex, writeIndex] = [writeIndex, readIndex];
bornParticles = Math.min(
// skipcq: JS-0339
options.maxParticles!,
// skipcq: JS-0339
Math.floor(bornParticles + dt * options.generationRate!),
);
requestAnimationFrame(ts => {
render(ts);
});
};
requestAnimationFrame(ts => {
render(ts);
});
return setOrigin;
};
/**
* Creates and renders webgl generative particle system based simulations.
*
* Please handle canvas size as required by your application.
* @param canvas
* @returns (()=>void)
*/
export const renderParticles = (canvas: HTMLCanvasElement, options?: ParticlesOptions) => {
const gl = canvas.getContext("webgl2");
if (!gl) return undefined;
const setOrigin = simulate(canvas, gl, { ...defaultOptions, ...options });
options?.origin && setOrigin(...options.origin);
/** Set up observer to observe size changes */
const observer = new ResizeObserver(entries => {
const { width, height } = entries[0].contentRect;
canvas.width = width;
canvas.height = height;
gl.viewport(0, 0, canvas.width, canvas.height);
});
observer.observe(canvas);
const target = options?.overlay ? window : canvas;
/** update mouse position */
const onMouseMove = (e: MouseEvent) => {
const height = canvas.height;
const width = canvas.width;
const boundingRect = canvas.getBoundingClientRect();
const xPos = e.pageX - boundingRect.left - scrollX;
const yPos = e.pageY - boundingRect.top - scrollY;
const scale = height > width ? [1, height / width] : [width / height, 1];
setOrigin(
((xPos / canvas.width) * 2 - 1) * scale[0],
(1 - (yPos / canvas.height) * 2) * scale[1],
);
};
// @ts-expect-error -- strange type-error
!options?.mouseOff && target.addEventListener("mousemove", onMouseMove);
/** Clean up function */
return () => {
observer.disconnect();
// @ts-expect-error -- strange type-error
!options?.mouseOff && target.removeEventListener("mousemove", onMouseMove);
};
};