diff --git a/README.md b/README.md
index b58109b..44c9c91 100644
--- a/README.md
+++ b/README.md
@@ -18,14 +18,17 @@ The default visual mode is a front-facing technical/data-art view with:
- colored point-cloud silhouettes
- tracked bounding boxes and centroids
- velocity cues
-- motion trails
+- point-cloud-driven GPU fluid field and motion trails
- scene grid and frame
- zone markers
- glow and labels
- Punchy, Cool, and Monochrome palettes
The visual layer is implemented in `src/visuals.js` and does not replace the
-reference Three.js renderer in `src/viewer.js`.
+reference Three.js renderer in `src/viewer.js`. Its optional fluid field lives
+in `src/fluid.js`: current point-cloud samples continuously seed dye and velocity,
+so the recognizable point silhouette stays sharp while color flows around and
+behind it.
## Run
diff --git a/THIRD_PARTY_LICENSES b/THIRD_PARTY_LICENSES
index 5b2ac62..5377664 100644
--- a/THIRD_PARTY_LICENSES
+++ b/THIRD_PARTY_LICENSES
@@ -66,3 +66,35 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+
+
+4. WebGL Fluid Simulation
+-------------------------
+
+Project: https://github.com/PavelDoGreat/WebGL-Fluid-Simulation
+License: MIT
+
+The fluid solver in src/fluid.js adapts techniques and shader logic from
+Pavel Dobryakov's WebGL Fluid Simulation.
+
+MIT License
+
+Copyright (c) 2017 Pavel Dobryakov
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/index.html b/index.html
index 565a83e..6e650e1 100644
--- a/index.html
+++ b/index.html
@@ -182,15 +182,25 @@
Augmenta Web Demo
+
+ Fluid response
+
+ Soft
+ Balanced
+ Expressive
+
+
+
Palette
diff --git a/src/app-info.js b/src/app-info.js
index 49a6ced..60861a9 100644
--- a/src/app-info.js
+++ b/src/app-info.js
@@ -1 +1 @@
-export const APP_VERSION = '0.1.0';
+export const APP_VERSION = '0.2.0';
diff --git a/src/fluid.js b/src/fluid.js
new file mode 100644
index 0000000..98cd8d7
--- /dev/null
+++ b/src/fluid.js
@@ -0,0 +1,898 @@
+/*
+MIT License
+
+This module contains a compact WebGL fluid solver adapted from the techniques
+used by Pavel Dobryakov's WebGL Fluid Simulation:
+https://github.com/PavelDoGreat/WebGL-Fluid-Simulation
+
+Copyright (c) 2017 Pavel Dobryakov
+Copyright (c) 2026 Augmenta
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+*/
+
+const SIM_RESOLUTION = 112;
+const DYE_RESOLUTION = 448;
+const PRESSURE_ITERATIONS = 12;
+const PRESSURE_DISSIPATION = 0.82;
+const VELOCITY_DISSIPATION = 0.34;
+const DYE_DISSIPATION = 0.82;
+const CURL_STRENGTH = 22;
+const MAX_DT = 1 / 60;
+const MAX_SPLATS_PER_FRAME = 96;
+const MAX_DISPLAY_EDGE_PX = 1280;
+
+export function createFluidField() {
+ const canvas = document.createElement('canvas');
+ const state = createState(canvas);
+
+ if (!state) {
+ return {
+ canvas,
+ supported: false,
+ clear() {},
+ update() {}
+ };
+ }
+
+ const {
+ gl,
+ formatRGBA,
+ formatRG,
+ formatR,
+ textureType,
+ linearFiltering
+ } = state;
+
+ const quad = createFullscreenQuad(gl);
+ const programs = createPrograms(gl);
+ const splatBatch = createSplatBatch(gl, programs.inject);
+ let buffers;
+ let displayWidth = 1;
+ let displayHeight = 1;
+ let aspectRatio = 1;
+
+ function clear() {
+ if (!buffers) return;
+ for (const target of allTargets(buffers)) {
+ gl.bindFramebuffer(gl.FRAMEBUFFER, target.fbo);
+ gl.viewport(0, 0, target.width, target.height);
+ gl.clearColor(0, 0, 0, 0);
+ gl.clear(gl.COLOR_BUFFER_BIT);
+ }
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
+ }
+
+ function resize(width, height, pixelRatio) {
+ const sourceWidth = Math.max(1, Math.round(width * pixelRatio));
+ const sourceHeight = Math.max(1, Math.round(height * pixelRatio));
+ const scale = Math.min(1, MAX_DISPLAY_EDGE_PX / Math.max(sourceWidth, sourceHeight));
+ const nextWidth = Math.max(1, Math.round(sourceWidth * scale));
+ const nextHeight = Math.max(1, Math.round(sourceHeight * scale));
+
+ if (canvas.width === nextWidth && canvas.height === nextHeight && buffers) return;
+
+ canvas.width = nextWidth;
+ canvas.height = nextHeight;
+ displayWidth = nextWidth;
+ displayHeight = nextHeight;
+ aspectRatio = nextWidth / Math.max(nextHeight, 1);
+
+ destroyBuffers(gl, buffers);
+
+ const sim = resolutionForAspect(SIM_RESOLUTION, aspectRatio);
+ const dye = resolutionForAspect(DYE_RESOLUTION, aspectRatio);
+ const filtering = linearFiltering ? gl.LINEAR : gl.NEAREST;
+
+ buffers = {
+ velocity: createDoubleFBO(gl, sim.width, sim.height, formatRG, textureType, filtering),
+ dye: createDoubleFBO(gl, dye.width, dye.height, formatRGBA, textureType, filtering),
+ divergence: createFBO(gl, sim.width, sim.height, formatR, textureType, gl.NEAREST),
+ curl: createFBO(gl, sim.width, sim.height, formatR, textureType, gl.NEAREST),
+ pressure: createDoubleFBO(gl, sim.width, sim.height, formatR, textureType, gl.NEAREST)
+ };
+
+ clear();
+ }
+
+ function update(width, height, pixelRatio, dt, splats, options = {}) {
+ resize(width, height, pixelRatio);
+ if (!buffers) return;
+
+ const stepDt = Math.min(Math.max(Number(dt) || 0, 0), MAX_DT);
+ applySplats(splats?.slice(0, MAX_SPLATS_PER_FRAME) || []);
+ simulate(stepDt);
+ render(Boolean(options.glow));
+ }
+
+ function applySplats(splats) {
+ if (!splats.length) return;
+ splatBatch.draw(
+ splats.map((splat) => ({
+ x: clamp01(splat.x),
+ y: clamp01(splat.y),
+ dx: clamp(Number(splat.dx) || 0, -2000, 2000),
+ dy: clamp(Number(splat.dy) || 0, -2000, 2000),
+ color: normalizeColor(splat.color),
+ radius: Math.max(Number(splat.radius) || 0.0001, 0.00001),
+ amount: clamp(Number(splat.amount) || 1, 0, 3)
+ })),
+ buffers.velocity.read,
+ buffers.dye.read
+ );
+ }
+
+ function simulate(dt) {
+ const { velocity, dye, divergence, curl, pressure } = buffers;
+
+ gl.disable(gl.BLEND);
+
+ programs.curl.use();
+ gl.uniform2f(programs.curl.uniforms.texelSize, velocity.texelSizeX, velocity.texelSizeY);
+ gl.uniform1i(programs.curl.uniforms.uVelocity, velocity.read.attach(0));
+ quad.blit(curl);
+
+ programs.vorticity.use();
+ gl.uniform2f(
+ programs.vorticity.uniforms.texelSize,
+ velocity.texelSizeX,
+ velocity.texelSizeY
+ );
+ gl.uniform1i(programs.vorticity.uniforms.uVelocity, velocity.read.attach(0));
+ gl.uniform1i(programs.vorticity.uniforms.uCurl, curl.attach(1));
+ gl.uniform1f(programs.vorticity.uniforms.curl, CURL_STRENGTH);
+ gl.uniform1f(programs.vorticity.uniforms.dt, dt);
+ quad.blit(velocity.write);
+ velocity.swap();
+
+ programs.divergence.use();
+ gl.uniform2f(
+ programs.divergence.uniforms.texelSize,
+ velocity.texelSizeX,
+ velocity.texelSizeY
+ );
+ gl.uniform1i(programs.divergence.uniforms.uVelocity, velocity.read.attach(0));
+ quad.blit(divergence);
+
+ programs.clear.use();
+ gl.uniform1i(programs.clear.uniforms.uTexture, pressure.read.attach(0));
+ gl.uniform1f(programs.clear.uniforms.value, PRESSURE_DISSIPATION);
+ quad.blit(pressure.write);
+ pressure.swap();
+
+ programs.pressure.use();
+ gl.uniform2f(
+ programs.pressure.uniforms.texelSize,
+ pressure.texelSizeX,
+ pressure.texelSizeY
+ );
+ gl.uniform1i(programs.pressure.uniforms.uDivergence, divergence.attach(0));
+ for (let i = 0; i < PRESSURE_ITERATIONS; i++) {
+ gl.uniform1i(programs.pressure.uniforms.uPressure, pressure.read.attach(1));
+ quad.blit(pressure.write);
+ pressure.swap();
+ }
+
+ programs.gradient.use();
+ gl.uniform2f(
+ programs.gradient.uniforms.texelSize,
+ velocity.texelSizeX,
+ velocity.texelSizeY
+ );
+ gl.uniform1i(programs.gradient.uniforms.uPressure, pressure.read.attach(0));
+ gl.uniform1i(programs.gradient.uniforms.uVelocity, velocity.read.attach(1));
+ quad.blit(velocity.write);
+ velocity.swap();
+
+ programs.advection.use();
+ gl.uniform2f(
+ programs.advection.uniforms.texelSize,
+ velocity.texelSizeX,
+ velocity.texelSizeY
+ );
+ gl.uniform2f(
+ programs.advection.uniforms.sourceTexelSize,
+ velocity.texelSizeX,
+ velocity.texelSizeY
+ );
+ gl.uniform1i(programs.advection.uniforms.uVelocity, velocity.read.attach(0));
+ gl.uniform1i(programs.advection.uniforms.uSource, velocity.read.attach(1));
+ gl.uniform1f(programs.advection.uniforms.dt, dt);
+ gl.uniform1f(programs.advection.uniforms.dissipation, VELOCITY_DISSIPATION);
+ quad.blit(velocity.write);
+ velocity.swap();
+
+ gl.uniform2f(
+ programs.advection.uniforms.sourceTexelSize,
+ dye.texelSizeX,
+ dye.texelSizeY
+ );
+ gl.uniform1i(programs.advection.uniforms.uVelocity, velocity.read.attach(0));
+ gl.uniform1i(programs.advection.uniforms.uSource, dye.read.attach(1));
+ gl.uniform1f(programs.advection.uniforms.dissipation, DYE_DISSIPATION);
+ quad.blit(dye.write);
+ dye.swap();
+ }
+
+ function render(glow) {
+ gl.viewport(0, 0, displayWidth, displayHeight);
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
+ gl.clearColor(0, 0, 0, 0);
+ gl.clear(gl.COLOR_BUFFER_BIT);
+
+ programs.display.use();
+ gl.uniform1i(programs.display.uniforms.uTexture, buffers.dye.read.attach(0));
+ gl.uniform2f(
+ programs.display.uniforms.texelSize,
+ buffers.dye.texelSizeX,
+ buffers.dye.texelSizeY
+ );
+ gl.uniform1f(programs.display.uniforms.glow, glow ? 1 : 0);
+ quad.blit(null);
+ }
+
+ return {
+ canvas,
+ supported: true,
+ clear,
+ update
+ };
+}
+
+function createState(canvas) {
+ const gl = canvas.getContext('webgl2', {
+ alpha: true,
+ depth: false,
+ stencil: false,
+ antialias: false,
+ preserveDrawingBuffer: false,
+ premultipliedAlpha: false
+ });
+
+ if (!gl) return undefined;
+
+ gl.getExtension('EXT_color_buffer_float');
+ const linearFiltering = Boolean(gl.getExtension('OES_texture_float_linear'));
+ const textureType = gl.HALF_FLOAT;
+
+ const formatRGBA = supportedFormat(gl, gl.RGBA16F, gl.RGBA, textureType);
+ const formatRG = supportedFormat(gl, gl.RG16F, gl.RG, textureType)
+ || supportedFormat(gl, gl.RGBA16F, gl.RGBA, textureType);
+ const formatR = supportedFormat(gl, gl.R16F, gl.RED, textureType)
+ || supportedFormat(gl, gl.RG16F, gl.RG, textureType)
+ || supportedFormat(gl, gl.RGBA16F, gl.RGBA, textureType);
+
+ if (!formatRGBA || !formatRG || !formatR) return undefined;
+
+ return {
+ gl,
+ formatRGBA,
+ formatRG,
+ formatR,
+ textureType,
+ linearFiltering
+ };
+}
+
+function supportedFormat(gl, internalFormat, format, type) {
+ const texture = gl.createTexture();
+ const fbo = gl.createFramebuffer();
+
+ gl.bindTexture(gl.TEXTURE_2D, texture);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+ gl.texImage2D(gl.TEXTURE_2D, 0, internalFormat, 4, 4, 0, format, type, null);
+
+ gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
+ gl.framebufferTexture2D(
+ gl.FRAMEBUFFER,
+ gl.COLOR_ATTACHMENT0,
+ gl.TEXTURE_2D,
+ texture,
+ 0
+ );
+
+ const supported = gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE;
+
+ gl.deleteFramebuffer(fbo);
+ gl.deleteTexture(texture);
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
+
+ return supported ? { internalFormat, format } : undefined;
+}
+
+function createPrograms(gl) {
+ return {
+ clear: createProgram(gl, BASE_VERTEX, CLEAR_FRAGMENT),
+ inject: createProgram(gl, POINT_VERTEX, POINT_FRAGMENT),
+ advection: createProgram(gl, BASE_VERTEX, ADVECTION_FRAGMENT),
+ divergence: createProgram(gl, BASE_VERTEX, DIVERGENCE_FRAGMENT),
+ curl: createProgram(gl, BASE_VERTEX, CURL_FRAGMENT),
+ vorticity: createProgram(gl, BASE_VERTEX, VORTICITY_FRAGMENT),
+ pressure: createProgram(gl, BASE_VERTEX, PRESSURE_FRAGMENT),
+ gradient: createProgram(gl, BASE_VERTEX, GRADIENT_FRAGMENT),
+ display: createProgram(gl, BASE_VERTEX, DISPLAY_FRAGMENT)
+ };
+}
+
+function createProgram(gl, vertexSource, fragmentSource) {
+ const vertex = compileShader(gl, gl.VERTEX_SHADER, vertexSource);
+ const fragment = compileShader(gl, gl.FRAGMENT_SHADER, fragmentSource);
+ const program = gl.createProgram();
+
+ gl.attachShader(program, vertex);
+ gl.attachShader(program, fragment);
+ gl.bindAttribLocation(program, 0, 'aPosition');
+ gl.linkProgram(program);
+
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
+ const info = gl.getProgramInfoLog(program) || 'Unknown program link error';
+ gl.deleteProgram(program);
+ throw new Error(info);
+ }
+
+ gl.deleteShader(vertex);
+ gl.deleteShader(fragment);
+
+ const uniforms = {};
+ const uniformCount = gl.getProgramParameter(program, gl.ACTIVE_UNIFORMS);
+ for (let i = 0; i < uniformCount; i++) {
+ const name = gl.getActiveUniform(program, i).name;
+ uniforms[name] = gl.getUniformLocation(program, name);
+ }
+
+ const attributes = {};
+ const attributeCount = gl.getProgramParameter(program, gl.ACTIVE_ATTRIBUTES);
+ for (let i = 0; i < attributeCount; i++) {
+ const name = gl.getActiveAttrib(program, i).name;
+ attributes[name] = gl.getAttribLocation(program, name);
+ }
+
+ return {
+ uniforms,
+ attributes,
+ use() {
+ gl.useProgram(program);
+ }
+ };
+}
+
+function compileShader(gl, type, source) {
+ const shader = gl.createShader(type);
+ gl.shaderSource(shader, source);
+ gl.compileShader(shader);
+
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
+ const info = gl.getShaderInfoLog(shader) || 'Unknown shader compile error';
+ gl.deleteShader(shader);
+ throw new Error(info);
+ }
+
+ return shader;
+}
+
+function createFullscreenQuad(gl) {
+ const vertexBuffer = gl.createBuffer();
+ const indexBuffer = gl.createBuffer();
+
+ gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
+ gl.bufferData(
+ gl.ARRAY_BUFFER,
+ new Float32Array([-1, -1, -1, 1, 1, 1, 1, -1]),
+ gl.STATIC_DRAW
+ );
+
+ gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
+ gl.bufferData(
+ gl.ELEMENT_ARRAY_BUFFER,
+ new Uint16Array([0, 1, 2, 0, 2, 3]),
+ gl.STATIC_DRAW
+ );
+
+ gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
+ gl.enableVertexAttribArray(0);
+
+ return {
+ blit(target) {
+ gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
+ gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
+ gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
+ gl.enableVertexAttribArray(0);
+
+ if (target) {
+ gl.viewport(0, 0, target.width, target.height);
+ gl.bindFramebuffer(gl.FRAMEBUFFER, target.fbo);
+ } else {
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
+ }
+ gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0);
+ }
+ };
+}
+
+function createSplatBatch(gl, program) {
+ const buffer = gl.createBuffer();
+ const components = 10;
+ const stride = components * Float32Array.BYTES_PER_ELEMENT;
+
+ function attribute(name, size, offset) {
+ const location = program.attributes[name];
+ if (location === undefined || location < 0) return;
+ gl.enableVertexAttribArray(location);
+ gl.vertexAttribPointer(
+ location,
+ size,
+ gl.FLOAT,
+ false,
+ stride,
+ offset * Float32Array.BYTES_PER_ELEMENT
+ );
+ }
+
+ function draw(splats, velocityTarget, dyeTarget) {
+ if (!splats.length) return;
+
+ const data = new Float32Array(splats.length * components);
+ let cursor = 0;
+ for (const splat of splats) {
+ data[cursor++] = splat.x;
+ data[cursor++] = splat.y;
+ data[cursor++] = splat.color[0];
+ data[cursor++] = splat.color[1];
+ data[cursor++] = splat.color[2];
+ data[cursor++] = splat.dx;
+ data[cursor++] = splat.dy;
+ data[cursor++] = splat.radius;
+ data[cursor++] = splat.amount;
+ data[cursor++] = 0;
+ }
+
+ program.use();
+ gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
+ gl.bufferData(gl.ARRAY_BUFFER, data, gl.DYNAMIC_DRAW);
+
+ attribute('aPoint', 2, 0);
+ attribute('aColor', 3, 2);
+ attribute('aVelocity', 2, 5);
+ attribute('aRadius', 1, 7);
+ attribute('aAmount', 1, 8);
+
+ gl.enable(gl.BLEND);
+ gl.blendFunc(gl.ONE, gl.ONE);
+
+ gl.uniform1f(program.uniforms.pass, 0);
+ gl.uniform1f(program.uniforms.targetHeight, velocityTarget.height);
+ gl.viewport(0, 0, velocityTarget.width, velocityTarget.height);
+ gl.bindFramebuffer(gl.FRAMEBUFFER, velocityTarget.fbo);
+ gl.drawArrays(gl.POINTS, 0, splats.length);
+
+ gl.uniform1f(program.uniforms.pass, 1);
+ gl.uniform1f(program.uniforms.targetHeight, dyeTarget.height);
+ gl.viewport(0, 0, dyeTarget.width, dyeTarget.height);
+ gl.bindFramebuffer(gl.FRAMEBUFFER, dyeTarget.fbo);
+ gl.drawArrays(gl.POINTS, 0, splats.length);
+
+ gl.disable(gl.BLEND);
+ }
+
+ return { draw };
+}
+
+function createFBO(gl, width, height, textureFormat, type, filtering) {
+ const texture = gl.createTexture();
+ gl.bindTexture(gl.TEXTURE_2D, texture);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filtering);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filtering);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+ gl.texImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ textureFormat.internalFormat,
+ width,
+ height,
+ 0,
+ textureFormat.format,
+ type,
+ null
+ );
+
+ const fbo = gl.createFramebuffer();
+ gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
+ gl.framebufferTexture2D(
+ gl.FRAMEBUFFER,
+ gl.COLOR_ATTACHMENT0,
+ gl.TEXTURE_2D,
+ texture,
+ 0
+ );
+
+ gl.viewport(0, 0, width, height);
+ gl.clearColor(0, 0, 0, 0);
+ gl.clear(gl.COLOR_BUFFER_BIT);
+
+ return {
+ texture,
+ fbo,
+ width,
+ height,
+ texelSizeX: 1 / width,
+ texelSizeY: 1 / height,
+ attach(unit) {
+ gl.activeTexture(gl.TEXTURE0 + unit);
+ gl.bindTexture(gl.TEXTURE_2D, texture);
+ return unit;
+ }
+ };
+}
+
+function createDoubleFBO(gl, width, height, textureFormat, type, filtering) {
+ let read = createFBO(gl, width, height, textureFormat, type, filtering);
+ let write = createFBO(gl, width, height, textureFormat, type, filtering);
+
+ return {
+ width,
+ height,
+ texelSizeX: 1 / width,
+ texelSizeY: 1 / height,
+ get read() {
+ return read;
+ },
+ get write() {
+ return write;
+ },
+ swap() {
+ [read, write] = [write, read];
+ },
+ destroy() {
+ destroyFBO(gl, read);
+ destroyFBO(gl, write);
+ }
+ };
+}
+
+function destroyBuffers(gl, buffers) {
+ if (!buffers) return;
+
+ buffers.velocity.destroy();
+ buffers.dye.destroy();
+ buffers.pressure.destroy();
+ destroyFBO(gl, buffers.divergence);
+ destroyFBO(gl, buffers.curl);
+}
+
+function destroyFBO(gl, target) {
+ if (!target) return;
+ gl.deleteFramebuffer(target.fbo);
+ gl.deleteTexture(target.texture);
+}
+
+function allTargets(buffers) {
+ return [
+ buffers.velocity.read,
+ buffers.velocity.write,
+ buffers.dye.read,
+ buffers.dye.write,
+ buffers.divergence,
+ buffers.curl,
+ buffers.pressure.read,
+ buffers.pressure.write
+ ];
+}
+
+function resolutionForAspect(base, aspect) {
+ if (aspect >= 1) {
+ return {
+ width: Math.max(2, Math.round(base * aspect)),
+ height: base
+ };
+ }
+
+ return {
+ width: base,
+ height: Math.max(2, Math.round(base / Math.max(aspect, 0.001)))
+ };
+}
+
+function normalizeColor(color) {
+ if (!Array.isArray(color)) return [1, 1, 1];
+ return [0, 1, 2].map((index) => clamp(Number(color[index]) || 0, 0, 1));
+}
+
+function clamp01(value) {
+ return clamp(Number(value) || 0, 0, 1);
+}
+
+function clamp(value, min, max) {
+ return Math.min(max, Math.max(min, value));
+}
+
+const POINT_VERTEX = `
+precision highp float;
+
+attribute vec2 aPoint;
+attribute vec3 aColor;
+attribute vec2 aVelocity;
+attribute float aRadius;
+attribute float aAmount;
+uniform float targetHeight;
+varying vec3 vColor;
+varying vec2 vVelocity;
+varying float vAmount;
+
+void main() {
+ gl_Position = vec4(aPoint * 2.0 - 1.0, 0.0, 1.0);
+ gl_PointSize = clamp(sqrt(max(aRadius, 0.000001)) * targetHeight * 2.4, 2.0, 56.0);
+ vColor = aColor;
+ vVelocity = aVelocity;
+ vAmount = aAmount;
+}
+`;
+
+const POINT_FRAGMENT = `
+precision highp float;
+
+varying vec3 vColor;
+varying vec2 vVelocity;
+varying float vAmount;
+uniform float pass;
+
+void main() {
+ vec2 p = gl_PointCoord - 0.5;
+ float falloff = exp(-dot(p, p) * 13.0);
+ vec3 value = pass < 0.5
+ ? vec3(vVelocity * falloff, 0.0)
+ : vColor * vAmount * falloff;
+ gl_FragColor = vec4(value, 1.0);
+}
+`;
+
+const BASE_VERTEX = `
+precision highp float;
+
+attribute vec2 aPosition;
+varying vec2 vUv;
+varying vec2 vL;
+varying vec2 vR;
+varying vec2 vT;
+varying vec2 vB;
+uniform vec2 texelSize;
+
+void main() {
+ vUv = aPosition * 0.5 + 0.5;
+ vL = vUv - vec2(texelSize.x, 0.0);
+ vR = vUv + vec2(texelSize.x, 0.0);
+ vT = vUv + vec2(0.0, texelSize.y);
+ vB = vUv - vec2(0.0, texelSize.y);
+ gl_Position = vec4(aPosition, 0.0, 1.0);
+}
+`;
+
+const CLEAR_FRAGMENT = `
+precision mediump float;
+varying highp vec2 vUv;
+uniform sampler2D uTexture;
+uniform float value;
+
+void main() {
+ gl_FragColor = texture2D(uTexture, vUv) * value;
+}
+`;
+
+const SPLAT_FRAGMENT = `
+precision highp float;
+varying vec2 vUv;
+uniform sampler2D uTarget;
+uniform float aspectRatio;
+uniform vec3 color;
+uniform vec2 point;
+uniform float radius;
+
+void main() {
+ vec2 p = vUv - point;
+ p.x *= aspectRatio;
+ vec3 base = texture2D(uTarget, vUv).xyz;
+ vec3 splat = exp(-dot(p, p) / radius) * color;
+ gl_FragColor = vec4(base + splat, 1.0);
+}
+`;
+
+const ADVECTION_FRAGMENT = `
+precision highp float;
+varying vec2 vUv;
+uniform sampler2D uVelocity;
+uniform sampler2D uSource;
+uniform vec2 texelSize;
+uniform vec2 sourceTexelSize;
+uniform float dt;
+uniform float dissipation;
+
+vec4 bilerp(sampler2D source, vec2 uv, vec2 size) {
+ vec2 st = uv / size - 0.5;
+ vec2 iuv = floor(st);
+ vec2 fuv = fract(st);
+ vec4 a = texture2D(source, (iuv + vec2(0.5, 0.5)) * size);
+ vec4 b = texture2D(source, (iuv + vec2(1.5, 0.5)) * size);
+ vec4 c = texture2D(source, (iuv + vec2(0.5, 1.5)) * size);
+ vec4 d = texture2D(source, (iuv + vec2(1.5, 1.5)) * size);
+ return mix(mix(a, b, fuv.x), mix(c, d, fuv.x), fuv.y);
+}
+
+void main() {
+ vec2 velocity = texture2D(uVelocity, vUv).xy;
+ vec2 coord = vUv - dt * velocity * texelSize;
+ vec4 value = bilerp(uSource, coord, sourceTexelSize);
+ gl_FragColor = value / (1.0 + dissipation * dt);
+}
+`;
+
+const DIVERGENCE_FRAGMENT = `
+precision mediump float;
+varying highp vec2 vUv;
+varying highp vec2 vL;
+varying highp vec2 vR;
+varying highp vec2 vT;
+varying highp vec2 vB;
+uniform sampler2D uVelocity;
+
+void main() {
+ float L = texture2D(uVelocity, vL).x;
+ float R = texture2D(uVelocity, vR).x;
+ float T = texture2D(uVelocity, vT).y;
+ float B = texture2D(uVelocity, vB).y;
+ vec2 C = texture2D(uVelocity, vUv).xy;
+
+ if (vL.x < 0.0) L = -C.x;
+ if (vR.x > 1.0) R = -C.x;
+ if (vT.y > 1.0) T = -C.y;
+ if (vB.y < 0.0) B = -C.y;
+
+ gl_FragColor = vec4(0.5 * (R - L + T - B), 0.0, 0.0, 1.0);
+}
+`;
+
+const CURL_FRAGMENT = `
+precision mediump float;
+varying highp vec2 vL;
+varying highp vec2 vR;
+varying highp vec2 vT;
+varying highp vec2 vB;
+uniform sampler2D uVelocity;
+
+void main() {
+ float L = texture2D(uVelocity, vL).y;
+ float R = texture2D(uVelocity, vR).y;
+ float T = texture2D(uVelocity, vT).x;
+ float B = texture2D(uVelocity, vB).x;
+ gl_FragColor = vec4(0.5 * (R - L - T + B), 0.0, 0.0, 1.0);
+}
+`;
+
+const VORTICITY_FRAGMENT = `
+precision highp float;
+varying vec2 vUv;
+varying vec2 vL;
+varying vec2 vR;
+varying vec2 vT;
+varying vec2 vB;
+uniform sampler2D uVelocity;
+uniform sampler2D uCurl;
+uniform float curl;
+uniform float dt;
+
+void main() {
+ float L = texture2D(uCurl, vL).x;
+ float R = texture2D(uCurl, vR).x;
+ float T = texture2D(uCurl, vT).x;
+ float B = texture2D(uCurl, vB).x;
+ float C = texture2D(uCurl, vUv).x;
+
+ vec2 force = 0.5 * vec2(abs(T) - abs(B), abs(R) - abs(L));
+ force /= length(force) + 0.0001;
+ force *= curl * C;
+ force.y *= -1.0;
+
+ vec2 velocity = texture2D(uVelocity, vUv).xy + force * dt;
+ gl_FragColor = vec4(clamp(velocity, -1000.0, 1000.0), 0.0, 1.0);
+}
+`;
+
+const PRESSURE_FRAGMENT = `
+precision mediump float;
+varying highp vec2 vUv;
+varying highp vec2 vL;
+varying highp vec2 vR;
+varying highp vec2 vT;
+varying highp vec2 vB;
+uniform sampler2D uPressure;
+uniform sampler2D uDivergence;
+
+void main() {
+ float L = texture2D(uPressure, vL).x;
+ float R = texture2D(uPressure, vR).x;
+ float T = texture2D(uPressure, vT).x;
+ float B = texture2D(uPressure, vB).x;
+ float divergence = texture2D(uDivergence, vUv).x;
+ gl_FragColor = vec4((L + R + B + T - divergence) * 0.25, 0.0, 0.0, 1.0);
+}
+`;
+
+const GRADIENT_FRAGMENT = `
+precision mediump float;
+varying highp vec2 vUv;
+varying highp vec2 vL;
+varying highp vec2 vR;
+varying highp vec2 vT;
+varying highp vec2 vB;
+uniform sampler2D uPressure;
+uniform sampler2D uVelocity;
+
+void main() {
+ float L = texture2D(uPressure, vL).x;
+ float R = texture2D(uPressure, vR).x;
+ float T = texture2D(uPressure, vT).x;
+ float B = texture2D(uPressure, vB).x;
+ vec2 velocity = texture2D(uVelocity, vUv).xy;
+ velocity -= vec2(R - L, T - B);
+ gl_FragColor = vec4(velocity, 0.0, 1.0);
+}
+`;
+
+const DISPLAY_FRAGMENT = `
+precision highp float;
+varying vec2 vUv;
+varying vec2 vL;
+varying vec2 vR;
+varying vec2 vT;
+varying vec2 vB;
+uniform sampler2D uTexture;
+uniform vec2 texelSize;
+uniform float glow;
+
+void main() {
+ vec3 c = texture2D(uTexture, vUv).rgb;
+
+ vec3 neighbor =
+ texture2D(uTexture, vL).rgb +
+ texture2D(uTexture, vR).rgb +
+ texture2D(uTexture, vT).rgb +
+ texture2D(uTexture, vB).rgb;
+ neighbor *= 0.25;
+
+ c += neighbor * 0.34 * glow;
+
+ vec3 left = texture2D(uTexture, vL).rgb;
+ vec3 right = texture2D(uTexture, vR).rgb;
+ vec3 top = texture2D(uTexture, vT).rgb;
+ vec3 bottom = texture2D(uTexture, vB).rgb;
+ float dx = length(right) - length(left);
+ float dy = length(top) - length(bottom);
+ vec3 normal = normalize(vec3(dx, dy, max(length(texelSize), 0.0001)));
+ float shading = clamp(dot(normal, vec3(0.0, 0.0, 1.0)) + 0.78, 0.78, 1.08);
+ c *= shading;
+
+ c = c / (1.0 + c);
+ c = pow(max(c, 0.0), vec3(0.72));
+
+ float alpha = clamp(max(c.r, max(c.g, c.b)) * 0.82, 0.0, 0.86);
+ gl_FragColor = vec4(c, alpha);
+}
+`;
diff --git a/src/main.js b/src/main.js
index de8e834..45784c9 100644
--- a/src/main.js
+++ b/src/main.js
@@ -45,7 +45,7 @@ const ui = {
displaySection: $('#display-section'), displayAdvanced: $('#display-advanced'), displaySectionSummary: $('#display-section-summary'),
debugSection: $('#debug-section'), visualsSection: $('#visuals-section'), visualsSectionSummary: $('#visuals-section-summary'), appVersion: $('#app-version'),
showClusters: $('#show-clusters'), showPoints: $('#show-points'), showScene: $('#show-scene'), showZones: $('#show-zones'), showVectors: $('#show-vectors'),
- visualEnabled: $('#visual-enabled'), visualTrails: $('#visual-trails'), visualGrid: $('#visual-grid'), visualGlow: $('#visual-glow'), visualLabels: $('#visual-labels'), visualPalette: $('#visual-palette')
+ visualEnabled: $('#visual-enabled'), visualTrails: $('#visual-trails'), visualGrid: $('#visual-grid'), visualFluid: $('#visual-fluid'), visualGlow: $('#visual-glow'), visualLabels: $('#visual-labels'), visualFluidResponse: $('#visual-fluid-response'), visualPalette: $('#visual-palette')
};
ui.appVersion.textContent = `Version ${APP_VERSION}`;
@@ -147,8 +147,10 @@ function savePreferences() {
enabled: ui.visualEnabled.checked,
trails: ui.visualTrails.checked,
grid: ui.visualGrid.checked,
+ fluid: ui.visualFluid.checked,
glow: ui.visualGlow.checked,
labels: ui.visualLabels.checked,
+ fluidResponse: ui.visualFluidResponse.value,
palette: ui.visualPalette.value
},
ui: {
@@ -218,8 +220,12 @@ function restorePreferences() {
if (typeof visualPreferences.enabled === 'boolean') ui.visualEnabled.checked = visualPreferences.enabled;
if (typeof visualPreferences.trails === 'boolean') ui.visualTrails.checked = visualPreferences.trails;
if (typeof visualPreferences.grid === 'boolean') ui.visualGrid.checked = visualPreferences.grid;
+ if (typeof visualPreferences.fluid === 'boolean') ui.visualFluid.checked = visualPreferences.fluid;
if (typeof visualPreferences.glow === 'boolean') ui.visualGlow.checked = visualPreferences.glow;
if (typeof visualPreferences.labels === 'boolean') ui.visualLabels.checked = visualPreferences.labels;
+ if (typeof visualPreferences.fluidResponse === 'string') {
+ ui.visualFluidResponse.value = visualPreferences.fluidResponse;
+ }
if (typeof visualPreferences.palette === 'string') ui.visualPalette.value = visualPreferences.palette;
const sidebarWidth = Number(uiPreferences.sidebarWidth);
@@ -551,8 +557,10 @@ function applyVisualOptions() {
enabled: ui.visualEnabled.checked,
trails: ui.visualTrails.checked,
grid: ui.visualGrid.checked,
+ fluid: ui.visualFluid.checked,
glow: ui.visualGlow.checked,
labels: ui.visualLabels.checked,
+ fluidResponse: ui.visualFluidResponse.value,
palette: ui.visualPalette.value
});
ui.visualsSectionSummary.textContent =
@@ -773,7 +781,16 @@ ui.downsample.addEventListener('change', () => {
savePreferences();
}));
-[ui.visualEnabled, ui.visualTrails, ui.visualGrid, ui.visualGlow, ui.visualLabels, ui.visualPalette]
+[
+ ui.visualEnabled,
+ ui.visualTrails,
+ ui.visualGrid,
+ ui.visualFluid,
+ ui.visualGlow,
+ ui.visualLabels,
+ ui.visualFluidResponse,
+ ui.visualPalette
+]
.forEach((input) => input.addEventListener('change', () => {
applyVisualOptions();
savePreferences();
diff --git a/src/visuals.js b/src/visuals.js
index be83a30..5fa5c4a 100644
--- a/src/visuals.js
+++ b/src/visuals.js
@@ -1,7 +1,34 @@
+import { createFluidField } from './fluid.js';
+
const BACKGROUND = '#090b11';
const STALE_MS = 360;
const EASING_MS = 70;
const MAX_POINTS = 1400;
+const FLUID_POINTS_PER_OBJECT = 32;
+
+const FLUID_RESPONSES = {
+ soft: {
+ dye: 0.065,
+ radius: 0.00007,
+ motion: 720,
+ swirl: 180,
+ opacity: 0.58
+ },
+ balanced: {
+ dye: 0.095,
+ radius: 0.00011,
+ motion: 980,
+ swirl: 260,
+ opacity: 0.74
+ },
+ expressive: {
+ dye: 0.14,
+ radius: 0.00016,
+ motion: 1320,
+ swirl: 360,
+ opacity: 0.88
+ }
+};
const PALETTES = {
punchy: ['#38e8ff', '#ff4fd8', '#a4ff56', '#ffb02e', '#7f7cff', '#ff5c69'],
@@ -18,6 +45,7 @@ export function createVisuals(host) {
const trailCanvas = document.createElement('canvas');
const ctx = canvas.getContext('2d', { alpha: false });
const trail = trailCanvas.getContext('2d');
+ const fluid = safeCreateFluidField();
const objects = new Map();
let width = 1;
@@ -27,6 +55,7 @@ export function createVisuals(host) {
let lastDraw = performance.now();
let scene = defaultScene();
let zones = [];
+ let fluidSamplePhase = 0;
const visibility = {
clusters: true,
@@ -42,6 +71,8 @@ export function createVisuals(host) {
grid: true,
glow: true,
labels: true,
+ fluid: true,
+ fluidResponse: 'balanced',
palette: 'punchy'
};
@@ -158,17 +189,18 @@ export function createVisuals(host) {
}
canvas.hidden = !options.enabled;
- if (options.enabled) draw(now);
+ if (options.enabled) draw(now, dt / 1000);
requestAnimationFrame(animate);
}
- function draw(now) {
+ function draw(now, dt) {
const projection = makeProjection();
drawBackground(projection, now);
if (options.grid) drawGrid(projection, now);
if (visibility.scene) drawSceneFrame(projection);
+ drawFluid(projection, now, dt);
updateTrails(projection);
if (options.trails) ctx.drawImage(trailCanvas, 0, 0, width, height);
@@ -261,6 +293,89 @@ export function createVisuals(host) {
ctx.restore();
}
+ function drawFluid(p, now, dt) {
+ if (!options.fluid || !visibility.points || !fluid.supported) return;
+
+ const response = FLUID_RESPONSES[options.fluidResponse]
+ || FLUID_RESPONSES.balanced;
+ const splats = collectFluidSplats(p, now, response);
+
+ fluid.update(width, height, pixelRatio, dt, splats, { glow: options.glow });
+
+ ctx.save();
+ ctx.globalCompositeOperation = 'screen';
+ ctx.globalAlpha = response.opacity;
+ ctx.drawImage(fluid.canvas, 0, 0, width, height);
+ ctx.restore();
+ }
+
+ function collectFluidSplats(p, now, response) {
+ const splats = [];
+ const sceneWidth = Math.max(scene.maxX - scene.minX, 0.001);
+ const sceneHeight = Math.max(scene.maxY - scene.minY, 0.001);
+
+ for (const view of objects.values()) {
+ const data = view.points;
+ if (!data?.length) continue;
+
+ const color = hexToRgb01(objectColor(view));
+ const pointCount = Math.floor(data.length / 3);
+ const sampleCount = Math.min(FLUID_POINTS_PER_OBJECT, pointCount);
+ const step = pointCount / Math.max(sampleCount, 1);
+ const centroidX = p.x(view.centroid[0]) / Math.max(width, 1);
+ const centroidY = 1 - p.y(view.centroid[1]) / Math.max(height, 1);
+ const baseDx = (Number(view.velocity[0]) || 0) / sceneWidth * response.motion;
+ const baseDy = (Number(view.velocity[1]) || 0) / sceneHeight * response.motion;
+
+ for (let sample = 0; sample < sampleCount; sample++) {
+ const pointIndex = Math.floor(
+ (sample * step + fluidSamplePhase * 0.37) % pointCount
+ );
+ const offset = pointIndex * 3;
+ const screenX = p.x(data[offset]);
+ const screenY = p.y(data[offset + 1]);
+
+ if (
+ screenX < p.left
+ || screenX > p.right
+ || screenY < p.top
+ || screenY > p.bottom
+ ) {
+ continue;
+ }
+
+ const x = screenX / Math.max(width, 1);
+ const y = 1 - screenY / Math.max(height, 1);
+ const rx = x - centroidX;
+ const ry = y - centroidY;
+ const pulse = Math.sin(now * 0.0014 + pointIndex * 0.41) * 8;
+
+ splats.push({
+ x,
+ y,
+ dx: baseDx - ry * response.swirl + pulse,
+ dy: baseDy + rx * response.swirl - pulse * 0.35,
+ color,
+ radius: response.radius,
+ amount: response.dye
+ });
+ }
+
+ splats.push({
+ x: centroidX,
+ y: centroidY,
+ dx: baseDx * 1.35,
+ dy: baseDy * 1.35,
+ color,
+ radius: response.radius * 4.8,
+ amount: response.dye * 0.32
+ });
+ }
+
+ fluidSamplePhase = (fluidSamplePhase + 1) % 4096;
+ return splats;
+ }
+
function updateTrails(p) {
trail.save();
trail.globalCompositeOperation = 'destination-out';
@@ -470,22 +585,27 @@ export function createVisuals(host) {
}
function setOptions(next) {
+ const fluidWasEnabled = options.fluid;
Object.assign(options, next);
if (!options.trails) trail.clearRect(0, 0, width, height);
+ if (fluidWasEnabled && !options.fluid) fluid.clear();
}
function clearTracking() {
objects.clear();
trail.clearRect(0, 0, width, height);
+ fluid.clear();
}
function clearSetup() {
scene = defaultScene();
zones = [];
+ fluid.clear();
}
function reset() {
trail.clearRect(0, 0, width, height);
+ fluid.clear();
}
function setRightInset(value) {
@@ -576,3 +696,27 @@ function colorAlpha(hex, alpha) {
const b = Number.parseInt(value.slice(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${Math.max(0, Math.min(1, alpha))})`;
}
+
+
+function safeCreateFluidField() {
+ try {
+ return createFluidField();
+ } catch (error) {
+ console.warn('Fluid visuals unavailable:', error);
+ return {
+ canvas: document.createElement('canvas'),
+ supported: false,
+ clear() {},
+ update() {}
+ };
+ }
+}
+
+function hexToRgb01(hex) {
+ const value = String(hex).replace('#', '');
+ return [
+ Number.parseInt(value.slice(0, 2), 16) / 255,
+ Number.parseInt(value.slice(2, 4), 16) / 255,
+ Number.parseInt(value.slice(4, 6), 16) / 255
+ ];
+}