UNPKG

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
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`); } }