fluid-pointer-react
Version:
A dependency-free fluid simulation component with WebGL-based physics - supports both vanilla web components and React
570 lines (569 loc) • 26.6 kB
JavaScript
import { getWebGLContext } from "./webgl-context.js";
import { createBasicProgram, createMaterial } from "./program.js";
import { createDoubleFBO, createFBO, getResolution } from "./fbo.js";
import { PointerTracker } from "../utils/pointer-tracker.js";
import { ColorManager, parseCSSColor } from "../utils/color-utils.js";
import * as shaders from "./shaders.js";
export class FluidSimulation {
constructor(canvas, config) {
this.isDestroyed = false;
this.isInitializing = false;
// Timing
this.lastTime = 0;
this.canvas = canvas;
this.config = { ...config };
// Generate unique context ID for debugging and validation
this.contextId = `fluid-${Date.now()}-${Math.random()
.toString(36)
.substr(2, 9)}`;
}
async initialize() {
// Prevent concurrent initialization
if (this.isInitializing) {
throw new Error(`[${this.contextId}] Already initializing`);
}
if (this.isDestroyed) {
throw new Error(`[${this.contextId}] Cannot initialize destroyed simulation`);
}
this.isInitializing = true;
try {
// Initialize WebGL context
const context = getWebGLContext(this.canvas);
if (!context) {
throw new Error(`[${this.contextId}] Failed to initialize WebGL context`);
}
this.gl = context.gl;
this.ext = context.ext;
console.log(`[${this.contextId}] FluidSimulation initialized with WebGL context:`, {
canvas: this.canvas,
contextId: this.contextId,
contextMatch: this.gl.canvas === this.canvas ? "MATCH" : "MISMATCH",
isWebGL2: this.gl instanceof WebGL2RenderingContext,
drawingBufferSize: `${this.gl.drawingBufferWidth}x${this.gl.drawingBufferHeight}`,
});
// Validate WebGL context is not lost
if (this.gl.isContextLost()) {
throw new Error(`[${this.contextId}] WebGL context is lost`);
}
// Initialize instance-specific vertex buffers
this.initializeBlitBuffers();
// Compile shaders
this.initializeShaders();
// Initialize framebuffers
this.initializeFBOs();
// Initialize utilities
this.initializeUtilities();
// Add initial splats
this.addRandomSplats(Math.floor(Math.random() * 20) + 5);
console.log(`[${this.contextId}] FluidSimulation initialization completed successfully`);
}
catch (error) {
console.error(`[${this.contextId}] FluidSimulation initialization failed:`, error);
this.isInitializing = false;
throw error;
}
this.isInitializing = false;
}
/**
* Validates that WebGL resources are valid and belong to this context
*/
validateWebGLResources() {
if (this.isDestroyed || !this.gl) {
return false;
}
// Check if context is lost
if (this.gl.isContextLost()) {
console.error(`[${this.contextId}] WebGL context is lost`);
return false;
}
// Validate buffers exist and are valid
if (!this.blitVertexBuffer || !this.blitIndexBuffer) {
console.error(`[${this.contextId}] Blit buffers not initialized`);
return false;
}
if (!this.gl.isBuffer(this.blitVertexBuffer) ||
!this.gl.isBuffer(this.blitIndexBuffer)) {
console.error(`[${this.contextId}] Blit buffers are invalid or deleted`);
return false;
}
return true;
}
/**
* Checks for WebGL errors and logs them with context information
*/
checkWebGLError(operation) {
if (!this.gl)
return false;
const error = this.gl.getError();
if (error !== this.gl.NO_ERROR) {
let errorString = "Unknown error";
switch (error) {
case this.gl.INVALID_ENUM:
errorString = "INVALID_ENUM";
break;
case this.gl.INVALID_VALUE:
errorString = "INVALID_VALUE";
break;
case this.gl.INVALID_OPERATION:
errorString = "INVALID_OPERATION";
break;
case this.gl.OUT_OF_MEMORY:
errorString = "OUT_OF_MEMORY";
break;
}
console.error(`[${this.contextId}] WebGL error in ${operation}: ${errorString} (${error})`);
return false;
}
return true;
}
initializeBlitBuffers() {
const gl = this.gl;
console.log(`[${this.contextId}] Initializing blit buffers`);
// Clean up any existing buffers first
if (this.blitVertexBuffer && gl.isBuffer(this.blitVertexBuffer)) {
gl.deleteBuffer(this.blitVertexBuffer);
}
if (this.blitIndexBuffer && gl.isBuffer(this.blitIndexBuffer)) {
gl.deleteBuffer(this.blitIndexBuffer);
}
// Create vertex buffer for a quad
this.blitVertexBuffer = gl.createBuffer();
if (!this.blitVertexBuffer) {
throw new Error(`[${this.contextId}] Failed to create vertex buffer`);
}
gl.bindBuffer(gl.ARRAY_BUFFER, this.blitVertexBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, -1, 1, 1, 1, 1, -1]), gl.STATIC_DRAW);
this.checkWebGLError("vertex buffer creation");
// Create index buffer for the quad
this.blitIndexBuffer = gl.createBuffer();
if (!this.blitIndexBuffer) {
throw new Error(`[${this.contextId}] Failed to create index buffer`);
}
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.blitIndexBuffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array([0, 1, 2, 0, 2, 3]), gl.STATIC_DRAW);
this.checkWebGLError("index buffer creation");
console.log(`[${this.contextId}] Blit buffers initialized successfully`);
}
initializeShaders() {
const gl = this.gl;
// Create base vertex shader
const baseVertexShader = shaders.baseVertexShader;
// Create all shader programs
this.clearProgram = createBasicProgram(gl, baseVertexShader, shaders.clearShader);
this.splatProgram = createBasicProgram(gl, baseVertexShader, shaders.splatShader);
this.divergenceProgram = createBasicProgram(gl, baseVertexShader, shaders.divergenceShader);
this.curlProgram = createBasicProgram(gl, baseVertexShader, shaders.curlShader);
this.vorticityProgram = createBasicProgram(gl, baseVertexShader, shaders.vorticityShader);
this.pressureProgram = createBasicProgram(gl, baseVertexShader, shaders.pressureShader);
this.gradientSubtractProgram = createBasicProgram(gl, baseVertexShader, shaders.gradientSubtractShader);
// Create advection program with manual filtering support
const advectionKeywords = this.ext.supportLinearFiltering
? []
: ["MANUAL_FILTERING"];
this.advectionProgram = createMaterial(gl, baseVertexShader, shaders.advectionShader);
this.advectionProgram.setKeywords(advectionKeywords);
// Create display material
this.displayMaterial = createMaterial(gl, baseVertexShader, shaders.displayShaderSource);
}
/**
* Draws a full-screen quad using instance-specific buffers
*/
blit(target) {
// Validate WebGL resources before attempting to use them
if (!this.validateWebGLResources()) {
console.error(`[${this.contextId}] Cannot blit: WebGL resources invalid`);
return;
}
const gl = this.gl;
try {
// Bind buffers with error checking
gl.bindBuffer(gl.ARRAY_BUFFER, this.blitVertexBuffer);
if (!this.checkWebGLError("bind vertex buffer"))
return;
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.blitIndexBuffer);
if (!this.checkWebGLError("bind index buffer"))
return;
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
if (!this.checkWebGLError("vertex attrib pointer"))
return;
gl.enableVertexAttribArray(0);
if (!this.checkWebGLError("enable vertex attrib array"))
return;
// Set viewport and framebuffer
if (target == null) {
gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
}
else {
gl.viewport(0, 0, target.width, target.height);
gl.bindFramebuffer(gl.FRAMEBUFFER, target.fbo);
}
if (!this.checkWebGLError("set viewport/framebuffer"))
return;
// Draw the quad
gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0);
if (!this.checkWebGLError("draw elements")) {
console.error(`[${this.contextId}] Failed to draw elements - this is likely a context conflict`);
return;
}
}
catch (error) {
console.error(`[${this.contextId}] WebGL error in blit:`, error);
}
}
initializeFBOs() {
const gl = this.gl;
const simRes = getResolution(gl, this.config.SIM_RESOLUTION);
const dyeRes = getResolution(gl, this.config.DYE_RESOLUTION);
const texType = this.ext.halfFloatTexType;
const rgba = this.ext.formatRGBA;
const rg = this.ext.formatRG;
const r = this.ext.formatR;
const filtering = this.ext.supportLinearFiltering ? gl.LINEAR : gl.NEAREST;
if (!rgba || !rg || !r) {
throw new Error("Required texture formats not supported");
}
this.velocityFBO = createDoubleFBO(gl, simRes.width, simRes.height, rg.internalFormat, rg.format, texType, filtering);
this.dyeFBO = createDoubleFBO(gl, dyeRes.width, dyeRes.height, rgba.internalFormat, rgba.format, texType, filtering);
this.pressureFBO = createDoubleFBO(gl, simRes.width, simRes.height, r.internalFormat, r.format, texType, gl.NEAREST);
this.divergenceFBO = createFBO(gl, simRes.width, simRes.height, r.internalFormat, r.format, texType, gl.NEAREST);
this.curlFBO = createFBO(gl, simRes.width, simRes.height, r.internalFormat, r.format, texType, gl.NEAREST);
}
initializeUtilities() {
this.colorManager = new ColorManager(this.config.COLOR_MODE);
// Set fixed color if provided
if (this.config.COLOR) {
const parsedColor = parseCSSColor(this.config.COLOR);
if (parsedColor) {
this.colorManager.setFixedColor(parsedColor);
}
}
try {
this.pointerTracker = new PointerTracker(this.canvas, {
onSplat: (pointer) => {
this.splat(pointer.texcoordX, pointer.texcoordY, pointer.deltaX * this.config.SPLAT_FORCE, pointer.deltaY * this.config.SPLAT_FORCE, pointer.color);
},
mouseInteractionEnabled: this.config.MOUSE_INTERACTION,
});
}
catch (error) {
throw error;
}
}
step() {
const dt = this.calcDeltaTime();
if (this.config.PAUSED)
return;
// Ensure utilities are initialized
if (!this.colorManager || !this.pointerTracker) {
return;
}
// Update colors
const currentColor = this.colorManager.updateColors(dt, this.config.COLORFUL, this.config.COLOR_UPDATE_SPEED, this.config.COLOR_TRANSITION_SPEED);
// Update pointer colors
this.pointerTracker.setPointerColor(currentColor);
// Update pointers and create splats
this.pointerTracker.updatePointers(0.001);
// Fluid simulation steps
this.stepFluid(dt);
// Render the result to the canvas
this.render();
}
stepFluid(dt) {
const gl = this.gl;
// Disable blending for simulation steps
gl.disable(gl.BLEND);
// Curl step
this.curlProgram.bind();
gl.uniform2f(this.curlProgram.uniforms.texelSize, this.velocityFBO.read.texelSizeX, this.velocityFBO.read.texelSizeY);
gl.uniform1i(this.curlProgram.uniforms.uVelocity, this.velocityFBO.read.attach(0));
this.blit(this.curlFBO);
// Vorticity step
this.vorticityProgram.bind();
gl.uniform2f(this.vorticityProgram.uniforms.texelSize, this.velocityFBO.read.texelSizeX, this.velocityFBO.read.texelSizeY);
gl.uniform1i(this.vorticityProgram.uniforms.uVelocity, this.velocityFBO.read.attach(0));
gl.uniform1i(this.vorticityProgram.uniforms.uCurl, this.curlFBO.attach(1));
gl.uniform1f(this.vorticityProgram.uniforms.curl, this.config.CURL);
gl.uniform1f(this.vorticityProgram.uniforms.dt, dt);
this.blit(this.velocityFBO.write);
this.velocityFBO.swap();
// Divergence step
this.divergenceProgram.bind();
gl.uniform2f(this.divergenceProgram.uniforms.texelSize, this.velocityFBO.read.texelSizeX, this.velocityFBO.read.texelSizeY);
gl.uniform1i(this.divergenceProgram.uniforms.uVelocity, this.velocityFBO.read.attach(0));
this.blit(this.divergenceFBO);
// Clear pressure
this.clearProgram.bind();
gl.uniform1i(this.clearProgram.uniforms.uTexture, this.pressureFBO.read.attach(0));
gl.uniform1f(this.clearProgram.uniforms.value, this.config.PRESSURE);
this.blit(this.pressureFBO.write);
this.pressureFBO.swap();
// Pressure solve
this.pressureProgram.bind();
gl.uniform2f(this.pressureProgram.uniforms.texelSize, this.velocityFBO.read.texelSizeX, this.velocityFBO.read.texelSizeY);
gl.uniform1i(this.pressureProgram.uniforms.uDivergence, this.divergenceFBO.attach(0));
for (let i = 0; i < this.config.PRESSURE_ITERATIONS; i++) {
gl.uniform1i(this.pressureProgram.uniforms.uPressure, this.pressureFBO.read.attach(1));
this.blit(this.pressureFBO.write);
this.pressureFBO.swap();
}
// Gradient subtract
this.gradientSubtractProgram.bind();
gl.uniform2f(this.gradientSubtractProgram.uniforms.texelSize, this.velocityFBO.read.texelSizeX, this.velocityFBO.read.texelSizeY);
gl.uniform1i(this.gradientSubtractProgram.uniforms.uPressure, this.pressureFBO.read.attach(0));
gl.uniform1i(this.gradientSubtractProgram.uniforms.uVelocity, this.velocityFBO.read.attach(1));
this.blit(this.velocityFBO.write);
this.velocityFBO.swap();
// Advect velocity
this.advectionProgram.bind();
gl.uniform2f(this.advectionProgram.uniforms.texelSize, this.velocityFBO.read.texelSizeX, this.velocityFBO.read.texelSizeY);
if (!this.ext.supportLinearFiltering) {
gl.uniform2f(this.advectionProgram.uniforms.dyeTexelSize, this.velocityFBO.read.texelSizeX, this.velocityFBO.read.texelSizeY);
}
gl.uniform1i(this.advectionProgram.uniforms.uVelocity, this.velocityFBO.read.attach(0));
gl.uniform1i(this.advectionProgram.uniforms.uSource, this.velocityFBO.read.attach(0));
gl.uniform1f(this.advectionProgram.uniforms.dt, dt);
gl.uniform1f(this.advectionProgram.uniforms.dissipation, this.config.VELOCITY_DISSIPATION);
this.blit(this.velocityFBO.write);
this.velocityFBO.swap();
// Advect dye
gl.uniform2f(this.advectionProgram.uniforms.texelSize, this.velocityFBO.read.texelSizeX, this.velocityFBO.read.texelSizeY);
if (!this.ext.supportLinearFiltering) {
gl.uniform2f(this.advectionProgram.uniforms.dyeTexelSize, this.dyeFBO.read.texelSizeX, this.dyeFBO.read.texelSizeY);
}
gl.uniform1i(this.advectionProgram.uniforms.uVelocity, this.velocityFBO.read.attach(0));
gl.uniform1i(this.advectionProgram.uniforms.uSource, this.dyeFBO.read.attach(1));
gl.uniform1f(this.advectionProgram.uniforms.dt, dt);
gl.uniform1f(this.advectionProgram.uniforms.dissipation, this.config.DENSITY_DISSIPATION);
this.blit(this.dyeFBO.write);
this.dyeFBO.swap();
}
render() {
const gl = this.gl;
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
gl.enable(gl.BLEND);
const width = gl.drawingBufferWidth;
const height = gl.drawingBufferHeight;
this.displayMaterial.bind();
if (this.config.SHADING) {
this.displayMaterial.setKeywords(["SHADING"]);
}
else {
this.displayMaterial.setKeywords([]);
}
this.displayMaterial.bind();
if (this.config.SHADING) {
gl.uniform2f(this.displayMaterial.uniforms.texelSize, 1.0 / width, 1.0 / height);
}
gl.uniform1i(this.displayMaterial.uniforms.uTexture, this.dyeFBO.read.attach(0));
this.blit(null);
}
splat(x, y, dx, dy, color) {
// Validate WebGL resources before attempting splat operation
if (!this.validateWebGLResources()) {
console.error(`[${this.contextId}] Cannot splat: WebGL resources invalid`);
return;
}
const gl = this.gl;
const splatColor = color || this.colorManager.generateRandomColor();
try {
this.splatProgram.bind();
if (!this.checkWebGLError("bind splat program"))
return;
gl.uniform1i(this.splatProgram.uniforms.uTarget, this.velocityFBO.read.attach(0));
gl.uniform1f(this.splatProgram.uniforms.aspectRatio, this.canvas.width / this.canvas.height);
gl.uniform2f(this.splatProgram.uniforms.point, x, y);
gl.uniform3f(this.splatProgram.uniforms.color, dx, dy, 0.0);
gl.uniform1f(this.splatProgram.uniforms.radius, this.config.SPLAT_RADIUS / 100.0);
if (!this.checkWebGLError("set splat uniforms (velocity)"))
return;
this.blit(this.velocityFBO.write);
this.velocityFBO.swap();
gl.uniform1i(this.splatProgram.uniforms.uTarget, this.dyeFBO.read.attach(0));
gl.uniform3f(this.splatProgram.uniforms.color, splatColor[0], splatColor[1], splatColor[2]);
if (!this.checkWebGLError("set splat uniforms (dye)"))
return;
this.blit(this.dyeFBO.write);
this.dyeFBO.swap();
}
catch (error) {
console.error(`[${this.contextId}] Error in splat operation:`, error);
}
}
addRandomSplats(count) {
// Validate WebGL resources before adding splats
if (!this.validateWebGLResources()) {
console.error(`[${this.contextId}] Cannot add random splats: WebGL resources invalid`);
return;
}
console.log(`[${this.contextId}] Adding ${count} random splats`);
for (let i = 0; i < count; i++) {
const color = this.colorManager.generateRandomColor();
const x = Math.random();
const y = Math.random();
const dx = this.config.SPLAT_FORCE * 0.1 * (Math.random() - 0.5);
const dy = this.config.SPLAT_FORCE * 0.1 * (Math.random() - 0.5);
this.splat(x, y, dx, dy, color);
}
}
updateConfig(newConfig) {
this.config = { ...this.config, ...newConfig };
// Update color manager if color mode changed
if (newConfig.COLOR_MODE && this.colorManager) {
this.colorManager.setColorMode(newConfig.COLOR_MODE);
}
// Update fixed color if color property changed
if (newConfig.COLOR !== undefined && this.colorManager) {
if (newConfig.COLOR) {
const parsedColor = parseCSSColor(newConfig.COLOR);
if (parsedColor) {
this.colorManager.setFixedColor(parsedColor);
}
else {
this.colorManager.clearFixedColor();
}
}
else {
this.colorManager.clearFixedColor();
}
}
// Update mouse interaction if changed
if (newConfig.MOUSE_INTERACTION !== undefined && this.pointerTracker) {
this.pointerTracker.setMouseInteraction(newConfig.MOUSE_INTERACTION);
}
}
handleResize(_width, _height) {
// Reinitialize FBOs with new dimensions
this.initializeFBOs();
// Reinitialize pointer tracker with updated canvas dimensions
if (this.pointerTracker) {
// Destroy the old pointer tracker first
this.pointerTracker.destroy();
// Create new pointer tracker with the updated canvas
this.pointerTracker = new PointerTracker(this.canvas, {
onSplat: (pointer) => {
this.splat(pointer.texcoordX, pointer.texcoordY, pointer.deltaX * this.config.SPLAT_FORCE, pointer.deltaY * this.config.SPLAT_FORCE, pointer.color);
},
mouseInteractionEnabled: this.config.MOUSE_INTERACTION,
});
}
}
calcDeltaTime() {
const now = Date.now();
let dt = (now - this.lastTime) / 1000;
dt = Math.min(dt, 0.016666); // Clamp to 60 FPS
this.lastTime = now;
return dt;
}
destroy() {
console.log(`[${this.contextId}] FluidSimulation destroy() called`);
// Mark as destroyed to prevent further operations
this.isDestroyed = true;
// Stop any ongoing animation
if (this.pointerTracker) {
try {
this.pointerTracker.destroy();
}
catch (error) {
console.error(`[${this.contextId}] Error destroying pointer tracker:`, error);
}
}
// Clean up WebGL resources
if (this.gl && !this.gl.isContextLost()) {
console.log(`[${this.contextId}] Cleaning up WebGL resources...`);
try {
// Delete framebuffers and textures
if (this.velocityFBO) {
this.gl.deleteFramebuffer(this.velocityFBO.read.fbo);
this.gl.deleteFramebuffer(this.velocityFBO.write.fbo);
this.gl.deleteTexture(this.velocityFBO.read.texture);
this.gl.deleteTexture(this.velocityFBO.write.texture);
}
if (this.dyeFBO) {
this.gl.deleteFramebuffer(this.dyeFBO.read.fbo);
this.gl.deleteFramebuffer(this.dyeFBO.write.fbo);
this.gl.deleteTexture(this.dyeFBO.read.texture);
this.gl.deleteTexture(this.dyeFBO.write.texture);
}
if (this.pressureFBO) {
this.gl.deleteFramebuffer(this.pressureFBO.read.fbo);
this.gl.deleteFramebuffer(this.pressureFBO.write.fbo);
this.gl.deleteTexture(this.pressureFBO.read.texture);
this.gl.deleteTexture(this.pressureFBO.write.texture);
}
if (this.divergenceFBO) {
this.gl.deleteFramebuffer(this.divergenceFBO.fbo);
this.gl.deleteTexture(this.divergenceFBO.texture);
}
if (this.curlFBO) {
this.gl.deleteFramebuffer(this.curlFBO.fbo);
this.gl.deleteTexture(this.curlFBO.texture);
}
// Delete shader programs
if (this.clearProgram?.program) {
this.gl.deleteProgram(this.clearProgram.program);
}
if (this.splatProgram?.program) {
this.gl.deleteProgram(this.splatProgram.program);
}
if (this.advectionProgram?.program) {
this.gl.deleteProgram(this.advectionProgram.program);
}
if (this.divergenceProgram?.program) {
this.gl.deleteProgram(this.divergenceProgram.program);
}
if (this.curlProgram?.program) {
this.gl.deleteProgram(this.curlProgram.program);
}
if (this.vorticityProgram?.program) {
this.gl.deleteProgram(this.vorticityProgram.program);
}
if (this.pressureProgram?.program) {
this.gl.deleteProgram(this.pressureProgram.program);
}
if (this.gradientSubtractProgram?.program) {
this.gl.deleteProgram(this.gradientSubtractProgram.program);
}
if (this.displayMaterial?.program) {
this.gl.deleteProgram(this.displayMaterial.program);
}
// Delete instance buffers
if (this.blitVertexBuffer) {
this.gl.deleteBuffer(this.blitVertexBuffer);
}
if (this.blitIndexBuffer) {
this.gl.deleteBuffer(this.blitIndexBuffer);
}
console.log(`[${this.contextId}] WebGL resources cleaned up successfully`);
}
catch (error) {
console.error(`[${this.contextId}] Error during WebGL cleanup:`, error);
}
}
else if (this.gl?.isContextLost()) {
console.log(`[${this.contextId}] WebGL context was lost, skipping resource cleanup`);
}
// Clear references
this.velocityFBO = null;
this.dyeFBO = null;
this.pressureFBO = null;
this.divergenceFBO = null;
this.curlFBO = null;
this.blitVertexBuffer = null;
this.blitIndexBuffer = null;
this.clearProgram = null;
this.splatProgram = null;
this.advectionProgram = null;
this.divergenceProgram = null;
this.curlProgram = null;
this.vorticityProgram = null;
this.pressureProgram = null;
this.gradientSubtractProgram = null;
this.displayMaterial = null;
this.pointerTracker = null;
this.colorManager = null;
this.gl = null;
this.ext = null;
console.log(`[${this.contextId}] FluidSimulation destruction completed`);
}
}