diff --git a/README.md b/README.md
index 44c9c91..9395dbf 100644
--- a/README.md
+++ b/README.md
@@ -2,33 +2,37 @@
Browser-based Augmenta demo for real-time spatial interaction.
-This repository is intentionally based on the current
-[Augmenta Three.js example](https://github.com/Augmenta-tech/Augmenta-ThreeJS-example)
-so connection handling, setup parsing, scene selection, debug data, QR sharing,
-sidebar behavior, persistence, mobile behavior, and GitHub Pages deployment stay
-aligned with the reference implementation.
-
-The Web Demo adds an independent 2D data-visual layer on top of that base. Disable
-**Visuals → Data visuals** at any time to reveal the original Three.js viewer.
+The application shell stays aligned with
+[Augmenta Three.js example](https://github.com/Augmenta-tech/Augmenta-ThreeJS-example):
+connection handling, setup parsing, scene selection, debug data, QR sharing,
+sidebar behavior, persistence, mobile behavior, and GitHub Pages deployment are
+kept intact.
## Visuals
-The default visual mode is a front-facing technical/data-art view with:
+The default visual mode is now a direct adaptation of
+[PavelDoGreat/WebGL-Fluid-Simulation](https://github.com/PavelDoGreat/WebGL-Fluid-Simulation) at upstream commit `a2d292931f19d9b3b9f564e23e6c32729d2121c3`.
+
+The simulation keeps the reference project's default visual parameters and
+rendering pipeline: 128 simulation resolution, 1024 dye resolution, density and
+velocity dissipation, pressure solve, vorticity, shading, bloom, sunrays, and the
+original dithering texture.
+
+The only intentional input change is the effector source:
+
+- mouse and multitouch input are removed
+- Augmenta point clouds are projected to the fluid canvas
+- each cloud is evenly subsampled to a bounded number of emitters
+- sampled point motion drives the same splat force path used by pointer motion
+- emitter colors follow the reference simulation's colorful pointer behavior
+- emitter count is shared across simultaneous point clouds to keep the effect
+ responsive and visually close to the original
-- colored point-cloud silhouettes
-- tracked bounding boxes and centroids
-- velocity cues
-- point-cloud-driven GPU fluid field and motion trails
-- scene grid and frame
-- zone markers
-- glow and labels
-- Punchy, Cool, and Monochrome palettes
+The fluid canvas is the primary visual when **Visuals → Fluid simulation** is
+enabled. Disable it to reveal the original Three.js debug viewer underneath.
-The visual layer is implemented in `src/visuals.js` and does not replace the
-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.
+The adapted fluid engine lives in `src/fluid.js`; the point-cloud-to-emitter
+bridge is isolated in `src/visuals.js`.
## Run
@@ -47,5 +51,5 @@ an Augmenta server.
## Deployment
-GitHub Pages is deployed from `main` by the workflow in
+GitHub Pages is deployed from `main` by
`.github/workflows/pages.yml`.
diff --git a/THIRD_PARTY_LICENSES b/THIRD_PARTY_LICENSES
index 5377664..2407a34 100644
--- a/THIRD_PARTY_LICENSES
+++ b/THIRD_PARTY_LICENSES
@@ -74,8 +74,11 @@ SOFTWARE.
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.
+The fluid engine in src/fluid.js is a reusable adaptation of Pavel Dobryakov's
+WebGL Fluid Simulation. It preserves the reference rendering pipeline and
+default visual parameters while replacing mouse/touch input with externally
+supplied Augmenta point-cloud emitters. The original dithering texture is kept
+at src/LDR_LLL1_0.png.
MIT License
diff --git a/index.html b/index.html
index 6e650e1..141e563 100644
--- a/index.html
+++ b/index.html
@@ -175,40 +175,15 @@
Augmenta Web Demo
Visuals
- Punchy
+ Fluid
-
-
-
- Fluid response
-
- Soft
- Balanced
- Expressive
-
-
-
-
- Palette
-
- Punchy
- Cool
- Monochrome
-
-
+
+
+ Fluid simulation
+
+
PavelDoGreat defaults, driven by subsampled Augmenta point clouds.
diff --git a/scripts/assemble-site.mjs b/scripts/assemble-site.mjs
index 15d83ae..5fa1643 100644
--- a/scripts/assemble-site.mjs
+++ b/scripts/assemble-site.mjs
@@ -32,6 +32,7 @@ function replaceRequired(source, search, replacement) {
requirePath(join(root, 'vendor', 'AugmentaClientSDK-JS', 'dist', 'esm'), 'built Augmenta SDK');
requirePath(join(root, 'vendor', 'qrcode-generator', 'qrcode.js'), 'vendored QR generator');
requirePath(join(threeRoot, 'build', 'three.module.js'), 'Three.js runtime');
+requirePath(join(root, 'src', 'LDR_LLL1_0.png'), 'fluid dithering texture');
rmSync(out, { recursive: true, force: true });
mkdirSync(join(out, 'src'), { recursive: true });
@@ -42,7 +43,7 @@ copyFileSync(join(root, 'index.html'), join(out, 'index.html'));
copyFileSync(join(root, 'augmenta-favicon.png'), join(out, 'augmenta-favicon.png'));
for (const name of readdirSync(join(root, 'src'))) {
- if (!name.endsWith('.js') && name !== 'styles.css') continue;
+ if (!name.endsWith('.js') && name !== 'styles.css' && !name.endsWith('.png')) continue;
copyFileSync(join(root, 'src', name), join(out, 'src', name));
}
diff --git a/src/LDR_LLL1_0.png b/src/LDR_LLL1_0.png
new file mode 100644
index 0000000..d1920c6
Binary files /dev/null and b/src/LDR_LLL1_0.png differ
diff --git a/src/app-info.js b/src/app-info.js
index 60861a9..a41a82e 100644
--- a/src/app-info.js
+++ b/src/app-info.js
@@ -1 +1 @@
-export const APP_VERSION = '0.2.0';
+export const APP_VERSION = '0.3.0';
diff --git a/src/fluid.js b/src/fluid.js
index 6d02aef..30e0691 100644
--- a/src/fluid.js
+++ b/src/fluid.js
@@ -1,12 +1,7 @@
/*
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
@@ -27,909 +22,1322 @@ 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_FALLBACK_SPLATS_PER_FRAME = 24;
-const MAX_DISPLAY_EDGE_PX = 1280;
-
-export function createFluidField() {
- const canvas = document.createElement('canvas');
- const state = createState(canvas);
-
- if (!state) {
+'use strict';
+
+// Adapted from upstream commit a2d292931f19d9b3b9f564e23e6c32729d2121c3.
+// Rendering, shaders, and default visual parameters are intentionally preserved;
+// only page UI/pointer input were replaced by an external emitter API.
+
+// Simulation section
+
+export function createFluidSimulation(canvas, { ditheringUrl } = {}) {
+if (!(canvas instanceof HTMLCanvasElement))
+ throw new TypeError('Fluid simulation requires a canvas element.');
+
+resizeCanvas();
+
+let config = {
+ SIM_RESOLUTION: 128,
+ DYE_RESOLUTION: 1024,
+ CAPTURE_RESOLUTION: 512,
+ DENSITY_DISSIPATION: 1,
+ VELOCITY_DISSIPATION: 0.2,
+ PRESSURE: 0.8,
+ PRESSURE_ITERATIONS: 20,
+ CURL: 30,
+ SPLAT_RADIUS: 0.25,
+ SPLAT_FORCE: 6000,
+ SHADING: true,
+ COLORFUL: true,
+ COLOR_UPDATE_SPEED: 10,
+ PAUSED: false,
+ BACK_COLOR: { r: 0, g: 0, b: 0 },
+ TRANSPARENT: false,
+ BLOOM: true,
+ BLOOM_ITERATIONS: 8,
+ BLOOM_RESOLUTION: 256,
+ BLOOM_INTENSITY: 0.8,
+ BLOOM_THRESHOLD: 0.6,
+ BLOOM_SOFT_KNEE: 0.7,
+ SUNRAYS: true,
+ SUNRAYS_RESOLUTION: 196,
+ SUNRAYS_WEIGHT: 1.0,
+}
+
+const { gl, ext } = getWebGLContext(canvas);
+if (!gl || !ext.formatRGBA || !ext.formatRG || !ext.formatR) {
return {
- canvas,
- supported: false,
- clear() {},
- update() {}
+ canvas,
+ supported: false,
+ clear () {},
+ update () {}
};
- }
-
- const {
- gl,
- formatRGBA,
- formatRG,
- formatR,
- textureType,
- linearFiltering,
- floatBlend
- } = 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);
+}
+
+if (isMobile()) {
+ config.DYE_RESOLUTION = 512;
+}
+if (!ext.supportLinearFiltering) {
+ config.DYE_RESOLUTION = 512;
+ config.SHADING = false;
+ config.BLOOM = false;
+ config.SUNRAYS = false;
+}
+
+
+function getWebGLContext (canvas) {
+ const params = { alpha: true, depth: false, stencil: false, antialias: false, preserveDrawingBuffer: false };
+
+ let gl = canvas.getContext('webgl2', params);
+ const isWebGL2 = !!gl;
+ if (!isWebGL2)
+ gl = canvas.getContext('webgl', params) || canvas.getContext('experimental-webgl', params);
+ if (!gl) return { gl: null, ext: {} };
+
+ let halfFloat;
+ let supportLinearFiltering;
+ if (isWebGL2) {
+ gl.getExtension('EXT_color_buffer_float');
+ supportLinearFiltering = gl.getExtension('OES_texture_float_linear');
+ } else {
+ halfFloat = gl.getExtension('OES_texture_half_float');
+ supportLinearFiltering = gl.getExtension('OES_texture_half_float_linear');
}
- 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)
+
+ gl.clearColor(0.0, 0.0, 0.0, 1.0);
+
+ if (!isWebGL2 && !halfFloat) return { gl: null, ext: {} };
+ const halfFloatTexType = isWebGL2 ? gl.HALF_FLOAT : halfFloat.HALF_FLOAT_OES;
+ let formatRGBA;
+ let formatRG;
+ let formatR;
+
+ if (isWebGL2)
+ {
+ formatRGBA = getSupportedFormat(gl, gl.RGBA16F, gl.RGBA, halfFloatTexType);
+ formatRG = getSupportedFormat(gl, gl.RG16F, gl.RG, halfFloatTexType);
+ formatR = getSupportedFormat(gl, gl.R16F, gl.RED, halfFloatTexType);
+ }
+ else
+ {
+ formatRGBA = getSupportedFormat(gl, gl.RGBA, gl.RGBA, halfFloatTexType);
+ formatRG = getSupportedFormat(gl, gl.RGBA, gl.RGBA, halfFloatTexType);
+ formatR = getSupportedFormat(gl, gl.RGBA, gl.RGBA, halfFloatTexType);
+ }
+
+ return {
+ gl,
+ ext: {
+ formatRGBA,
+ formatRG,
+ formatR,
+ halfFloatTexType,
+ supportLinearFiltering
+ }
};
+}
- 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;
-
- const normalized = 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)
- }));
-
- if (floatBlend) {
- splatBatch.draw(normalized, buffers.velocity.read, buffers.dye.read);
- return;
- }
-
- const stride = Math.max(
- 1,
- Math.ceil(normalized.length / MAX_FALLBACK_SPLATS_PER_FRAME)
- );
- for (let i = 0; i < normalized.length; i += stride) {
- applySequentialSplat(normalized[i]);
- }
- }
-
- function applySequentialSplat(splat) {
- programs.splat.use();
- gl.uniform1f(programs.splat.uniforms.aspectRatio, aspectRatio);
- gl.uniform2f(programs.splat.uniforms.point, splat.x, splat.y);
- gl.uniform1f(programs.splat.uniforms.radius, splat.radius);
-
- gl.uniform1i(programs.splat.uniforms.uTarget, buffers.velocity.read.attach(0));
- gl.uniform3f(programs.splat.uniforms.color, splat.dx, splat.dy, 0);
- quad.blit(buffers.velocity.write);
- buffers.velocity.swap();
-
- gl.uniform1i(programs.splat.uniforms.uTarget, buffers.dye.read.attach(0));
- gl.uniform3f(
- programs.splat.uniforms.color,
- splat.color[0] * splat.amount,
- splat.color[1] * splat.amount,
- splat.color[2] * splat.amount
- );
- quad.blit(buffers.dye.write);
- buffers.dye.swap();
- }
-
- function simulate(dt) {
- const { velocity, dye, divergence, curl, pressure } = buffers;
+function getSupportedFormat (gl, internalFormat, format, type)
+{
+ if (!supportRenderTextureFormat(gl, internalFormat, format, type))
+ {
+ switch (internalFormat)
+ {
+ case gl.R16F:
+ return getSupportedFormat(gl, gl.RG16F, gl.RG, type);
+ case gl.RG16F:
+ return getSupportedFormat(gl, gl.RGBA16F, gl.RGBA, type);
+ default:
+ return null;
+ }
+ }
- gl.disable(gl.BLEND);
+ return {
+ internalFormat,
+ format
+ }
+}
- 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();
+function supportRenderTextureFormat (gl, internalFormat, format, type) {
+ let texture = gl.createTexture();
+ 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);
+
+ let fbo = gl.createFramebuffer();
+ gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
+
+ let status = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
+ return status == gl.FRAMEBUFFER_COMPLETE;
+}
- 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();
+function isMobile () {
+ return /Mobi|Android/i.test(navigator.userAgent);
+}
- 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();
+class Material {
+ constructor (vertexShader, fragmentShaderSource) {
+ this.vertexShader = vertexShader;
+ this.fragmentShaderSource = fragmentShaderSource;
+ this.programs = [];
+ this.activeProgram = null;
+ this.uniforms = [];
+ }
- 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();
+ setKeywords (keywords) {
+ let hash = 0;
+ for (let i = 0; i < keywords.length; i++)
+ hash += hashCode(keywords[i]);
- 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();
- }
+ let program = this.programs[hash];
+ if (program == null)
+ {
+ let fragmentShader = compileShader(gl.FRAGMENT_SHADER, this.fragmentShaderSource, keywords);
+ program = createProgram(this.vertexShader, fragmentShader);
+ this.programs[hash] = program;
+ }
- 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);
+ if (program == this.activeProgram) return;
- 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 floatBlend = Boolean(gl.getExtension('EXT_float_blend'));
- 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,
- floatBlend
- };
-}
-
-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),
- splat: createProgram(gl, BASE_VERTEX, SPLAT_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);
+ this.uniforms = getUniforms(program);
+ this.activeProgram = program;
+ }
- gl.enable(gl.BLEND);
- gl.blendFunc(gl.ONE, gl.ONE);
+ bind () {
+ gl.useProgram(this.activeProgram);
+ }
+}
- 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);
+class Program {
+ constructor (vertexShader, fragmentShader) {
+ this.uniforms = {};
+ this.program = createProgram(vertexShader, fragmentShader);
+ this.uniforms = getUniforms(this.program);
+ }
+
+ bind () {
+ gl.useProgram(this.program);
+ }
+}
- 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);
+function createProgram (vertexShader, fragmentShader) {
+ let program = gl.createProgram();
+ gl.attachShader(program, vertexShader);
+ gl.attachShader(program, fragmentShader);
+ gl.linkProgram(program);
+
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS))
+ console.trace(gl.getProgramInfoLog(program));
+
+ return program;
+}
+
+function getUniforms (program) {
+ let uniforms = [];
+ let uniformCount = gl.getProgramParameter(program, gl.ACTIVE_UNIFORMS);
+ for (let i = 0; i < uniformCount; i++) {
+ let uniformName = gl.getActiveUniform(program, i).name;
+ uniforms[uniformName] = gl.getUniformLocation(program, uniformName);
+ }
+ return uniforms;
+}
+
+function compileShader (type, source, keywords) {
+ source = addKeywords(source, keywords);
+
+ const shader = gl.createShader(type);
+ gl.shaderSource(shader, source);
+ gl.compileShader(shader);
+
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS))
+ console.trace(gl.getShaderInfoLog(shader));
+
+ return shader;
+};
+
+function addKeywords (source, keywords) {
+ if (keywords == null) return source;
+ let keywordsString = '';
+ keywords.forEach(keyword => {
+ keywordsString += '#define ' + keyword + '\n';
+ });
+ return keywordsString + source;
+}
+
+const baseVertexShader = compileShader(gl.VERTEX_SHADER, `
+ 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 blurVertexShader = compileShader(gl.VERTEX_SHADER, `
+ precision highp float;
+
+ attribute vec2 aPosition;
+ varying vec2 vUv;
+ varying vec2 vL;
+ varying vec2 vR;
+ uniform vec2 texelSize;
+
+ void main () {
+ vUv = aPosition * 0.5 + 0.5;
+ float offset = 1.33333333;
+ vL = vUv - texelSize * offset;
+ vR = vUv + texelSize * offset;
+ gl_Position = vec4(aPosition, 0.0, 1.0);
+ }
+`);
+
+const blurShader = compileShader(gl.FRAGMENT_SHADER, `
+ precision mediump float;
+ precision mediump sampler2D;
+
+ varying vec2 vUv;
+ varying vec2 vL;
+ varying vec2 vR;
+ uniform sampler2D uTexture;
+
+ void main () {
+ vec4 sum = texture2D(uTexture, vUv) * 0.29411764;
+ sum += texture2D(uTexture, vL) * 0.35294117;
+ sum += texture2D(uTexture, vR) * 0.35294117;
+ gl_FragColor = sum;
+ }
+`);
+
+const copyShader = compileShader(gl.FRAGMENT_SHADER, `
+ precision mediump float;
+ precision mediump sampler2D;
+
+ varying highp vec2 vUv;
+ uniform sampler2D uTexture;
+
+ void main () {
+ gl_FragColor = texture2D(uTexture, vUv);
+ }
+`);
+
+const clearShader = compileShader(gl.FRAGMENT_SHADER, `
+ precision mediump float;
+ precision mediump sampler2D;
+
+ varying highp vec2 vUv;
+ uniform sampler2D uTexture;
+ uniform float value;
+
+ void main () {
+ gl_FragColor = value * texture2D(uTexture, vUv);
+ }
+`);
+
+const colorShader = compileShader(gl.FRAGMENT_SHADER, `
+ precision mediump float;
+
+ uniform vec4 color;
+
+ void main () {
+ gl_FragColor = color;
+ }
+`);
+
+const checkerboardShader = compileShader(gl.FRAGMENT_SHADER, `
+ precision highp float;
+ precision highp sampler2D;
+
+ varying vec2 vUv;
+ uniform sampler2D uTexture;
+ uniform float aspectRatio;
+
+ #define SCALE 25.0
+
+ void main () {
+ vec2 uv = floor(vUv * SCALE * vec2(aspectRatio, 1.0));
+ float v = mod(uv.x + uv.y, 2.0);
+ v = v * 0.1 + 0.8;
+ gl_FragColor = vec4(vec3(v), 1.0);
+ }
+`);
+
+const displayShaderSource = `
+ precision highp float;
+ precision highp sampler2D;
+
+ varying vec2 vUv;
+ varying vec2 vL;
+ varying vec2 vR;
+ varying vec2 vT;
+ varying vec2 vB;
+ uniform sampler2D uTexture;
+ uniform sampler2D uBloom;
+ uniform sampler2D uSunrays;
+ uniform sampler2D uDithering;
+ uniform vec2 ditherScale;
+ uniform vec2 texelSize;
+
+ vec3 linearToGamma (vec3 color) {
+ color = max(color, vec3(0));
+ return max(1.055 * pow(color, vec3(0.416666667)) - 0.055, vec3(0));
+ }
+
+ void main () {
+ vec3 c = texture2D(uTexture, vUv).rgb;
+
+ #ifdef SHADING
+ vec3 lc = texture2D(uTexture, vL).rgb;
+ vec3 rc = texture2D(uTexture, vR).rgb;
+ vec3 tc = texture2D(uTexture, vT).rgb;
+ vec3 bc = texture2D(uTexture, vB).rgb;
+
+ float dx = length(rc) - length(lc);
+ float dy = length(tc) - length(bc);
+
+ vec3 n = normalize(vec3(dx, dy, length(texelSize)));
+ vec3 l = vec3(0.0, 0.0, 1.0);
+
+ float diffuse = clamp(dot(n, l) + 0.7, 0.7, 1.0);
+ c *= diffuse;
+ #endif
+
+ #ifdef BLOOM
+ vec3 bloom = texture2D(uBloom, vUv).rgb;
+ #endif
+
+ #ifdef SUNRAYS
+ float sunrays = texture2D(uSunrays, vUv).r;
+ c *= sunrays;
+ #ifdef BLOOM
+ bloom *= sunrays;
+ #endif
+ #endif
+
+ #ifdef BLOOM
+ float noise = texture2D(uDithering, vUv * ditherScale).r;
+ noise = noise * 2.0 - 1.0;
+ bloom += noise / 255.0;
+ bloom = linearToGamma(bloom);
+ c += bloom;
+ #endif
+
+ float a = max(c.r, max(c.g, c.b));
+ gl_FragColor = vec4(c, a);
+ }
+`;
+
+const bloomPrefilterShader = compileShader(gl.FRAGMENT_SHADER, `
+ precision mediump float;
+ precision mediump sampler2D;
+
+ varying vec2 vUv;
+ uniform sampler2D uTexture;
+ uniform vec3 curve;
+ uniform float threshold;
+
+ void main () {
+ vec3 c = texture2D(uTexture, vUv).rgb;
+ float br = max(c.r, max(c.g, c.b));
+ float rq = clamp(br - curve.x, 0.0, curve.y);
+ rq = curve.z * rq * rq;
+ c *= max(rq, br - threshold) / max(br, 0.0001);
+ gl_FragColor = vec4(c, 0.0);
+ }
+`);
+
+const bloomBlurShader = compileShader(gl.FRAGMENT_SHADER, `
+ precision mediump float;
+ precision mediump sampler2D;
+
+ varying vec2 vL;
+ varying vec2 vR;
+ varying vec2 vT;
+ varying vec2 vB;
+ uniform sampler2D uTexture;
+
+ void main () {
+ vec4 sum = vec4(0.0);
+ sum += texture2D(uTexture, vL);
+ sum += texture2D(uTexture, vR);
+ sum += texture2D(uTexture, vT);
+ sum += texture2D(uTexture, vB);
+ sum *= 0.25;
+ gl_FragColor = sum;
+ }
+`);
+
+const bloomFinalShader = compileShader(gl.FRAGMENT_SHADER, `
+ precision mediump float;
+ precision mediump sampler2D;
+
+ varying vec2 vL;
+ varying vec2 vR;
+ varying vec2 vT;
+ varying vec2 vB;
+ uniform sampler2D uTexture;
+ uniform float intensity;
+
+ void main () {
+ vec4 sum = vec4(0.0);
+ sum += texture2D(uTexture, vL);
+ sum += texture2D(uTexture, vR);
+ sum += texture2D(uTexture, vT);
+ sum += texture2D(uTexture, vB);
+ sum *= 0.25;
+ gl_FragColor = sum * intensity;
+ }
+`);
+
+const sunraysMaskShader = compileShader(gl.FRAGMENT_SHADER, `
+ precision highp float;
+ precision highp sampler2D;
+
+ varying vec2 vUv;
+ uniform sampler2D uTexture;
+
+ void main () {
+ vec4 c = texture2D(uTexture, vUv);
+ float br = max(c.r, max(c.g, c.b));
+ c.a = 1.0 - min(max(br * 20.0, 0.0), 0.8);
+ gl_FragColor = c;
+ }
+`);
+
+const sunraysShader = compileShader(gl.FRAGMENT_SHADER, `
+ precision highp float;
+ precision highp sampler2D;
+
+ varying vec2 vUv;
+ uniform sampler2D uTexture;
+ uniform float weight;
+
+ #define ITERATIONS 16
+
+ void main () {
+ float Density = 0.3;
+ float Decay = 0.95;
+ float Exposure = 0.7;
+
+ vec2 coord = vUv;
+ vec2 dir = vUv - 0.5;
+
+ dir *= 1.0 / float(ITERATIONS) * Density;
+ float illuminationDecay = 1.0;
+
+ float color = texture2D(uTexture, vUv).a;
+
+ for (int i = 0; i < ITERATIONS; i++)
+ {
+ coord -= dir;
+ float col = texture2D(uTexture, coord).a;
+ color += col * illuminationDecay * weight;
+ illuminationDecay *= Decay;
+ }
+
+ gl_FragColor = vec4(color * Exposure, 0.0, 0.0, 1.0);
+ }
+`);
+
+const splatShader = compileShader(gl.FRAGMENT_SHADER, `
+ precision highp float;
+ precision highp sampler2D;
+
+ varying vec2 vUv;
+ uniform sampler2D uTarget;
+ uniform float aspectRatio;
+ uniform vec3 color;
+ uniform vec2 point;
+ uniform float radius;
+
+ void main () {
+ vec2 p = vUv - point.xy;
+ p.x *= aspectRatio;
+ vec3 splat = exp(-dot(p, p) / radius) * color;
+ vec3 base = texture2D(uTarget, vUv).xyz;
+ gl_FragColor = vec4(base + splat, 1.0);
+ }
+`);
+
+const advectionShader = compileShader(gl.FRAGMENT_SHADER, `
+ precision highp float;
+ precision highp sampler2D;
+
+ varying vec2 vUv;
+ uniform sampler2D uVelocity;
+ uniform sampler2D uSource;
+ uniform vec2 texelSize;
+ uniform vec2 dyeTexelSize;
+ uniform float dt;
+ uniform float dissipation;
+
+ vec4 bilerp (sampler2D sam, vec2 uv, vec2 tsize) {
+ vec2 st = uv / tsize - 0.5;
+
+ vec2 iuv = floor(st);
+ vec2 fuv = fract(st);
+
+ vec4 a = texture2D(sam, (iuv + vec2(0.5, 0.5)) * tsize);
+ vec4 b = texture2D(sam, (iuv + vec2(1.5, 0.5)) * tsize);
+ vec4 c = texture2D(sam, (iuv + vec2(0.5, 1.5)) * tsize);
+ vec4 d = texture2D(sam, (iuv + vec2(1.5, 1.5)) * tsize);
+
+ return mix(mix(a, b, fuv.x), mix(c, d, fuv.x), fuv.y);
+ }
+
+ void main () {
+ #ifdef MANUAL_FILTERING
+ vec2 coord = vUv - dt * bilerp(uVelocity, vUv, texelSize).xy * texelSize;
+ vec4 result = bilerp(uSource, coord, dyeTexelSize);
+ #else
+ vec2 coord = vUv - dt * texture2D(uVelocity, vUv).xy * texelSize;
+ vec4 result = texture2D(uSource, coord);
+ #endif
+ float decay = 1.0 + dissipation * dt;
+ gl_FragColor = result / decay;
+ }`,
+ ext.supportLinearFiltering ? null : ['MANUAL_FILTERING']
+);
+
+const divergenceShader = compileShader(gl.FRAGMENT_SHADER, `
+ precision mediump float;
+ precision mediump sampler2D;
+
+ 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; }
+
+ float div = 0.5 * (R - L + T - B);
+ gl_FragColor = vec4(div, 0.0, 0.0, 1.0);
+ }
+`);
+
+const curlShader = compileShader(gl.FRAGMENT_SHADER, `
+ precision mediump float;
+ precision mediump sampler2D;
+
+ 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).y;
+ float R = texture2D(uVelocity, vR).y;
+ float T = texture2D(uVelocity, vT).x;
+ float B = texture2D(uVelocity, vB).x;
+ float vorticity = R - L - T + B;
+ gl_FragColor = vec4(0.5 * vorticity, 0.0, 0.0, 1.0);
+ }
+`);
+
+const vorticityShader = compileShader(gl.FRAGMENT_SHADER, `
+ precision highp float;
+ precision highp sampler2D;
+
+ 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;
+ velocity += force * dt;
+ velocity = min(max(velocity, -1000.0), 1000.0);
+ gl_FragColor = vec4(velocity, 0.0, 1.0);
+ }
+`);
+
+const pressureShader = compileShader(gl.FRAGMENT_SHADER, `
+ precision mediump float;
+ precision mediump sampler2D;
+
+ 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 C = texture2D(uPressure, vUv).x;
+ float divergence = texture2D(uDivergence, vUv).x;
+ float pressure = (L + R + B + T - divergence) * 0.25;
+ gl_FragColor = vec4(pressure, 0.0, 0.0, 1.0);
+ }
+`);
+
+const gradientSubtractShader = compileShader(gl.FRAGMENT_SHADER, `
+ precision mediump float;
+ precision mediump sampler2D;
+
+ 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.xy -= vec2(R - L, T - B);
+ gl_FragColor = vec4(velocity, 0.0, 1.0);
+ }
+`);
+
+const blit = (() => {
+ gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, -1, 1, 1, 1, 1, -1]), gl.STATIC_DRAW);
+ gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, gl.createBuffer());
+ 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 (target, clear = false) => {
+ 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 (clear)
+ {
+ gl.clearColor(0.0, 0.0, 0.0, 1.0);
+ gl.clear(gl.COLOR_BUFFER_BIT);
+ }
+ // CHECK_FRAMEBUFFER_STATUS();
+ gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0);
+ }
+})();
+
+function CHECK_FRAMEBUFFER_STATUS () {
+ let status = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
+ if (status != gl.FRAMEBUFFER_COMPLETE)
+ console.trace("Framebuffer error: " + status);
+}
+
+let dye;
+let velocity;
+let divergence;
+let curl;
+let pressure;
+let bloom;
+let bloomFramebuffers = [];
+let sunrays;
+let sunraysTemp;
+
+let ditheringTexture = createTextureAsync(ditheringUrl || new URL('./LDR_LLL1_0.png', import.meta.url).href);
+
+const blurProgram = new Program(blurVertexShader, blurShader);
+const copyProgram = new Program(baseVertexShader, copyShader);
+const clearProgram = new Program(baseVertexShader, clearShader);
+const colorProgram = new Program(baseVertexShader, colorShader);
+const checkerboardProgram = new Program(baseVertexShader, checkerboardShader);
+const bloomPrefilterProgram = new Program(baseVertexShader, bloomPrefilterShader);
+const bloomBlurProgram = new Program(baseVertexShader, bloomBlurShader);
+const bloomFinalProgram = new Program(baseVertexShader, bloomFinalShader);
+const sunraysMaskProgram = new Program(baseVertexShader, sunraysMaskShader);
+const sunraysProgram = new Program(baseVertexShader, sunraysShader);
+const splatProgram = new Program(baseVertexShader, splatShader);
+const advectionProgram = new Program(baseVertexShader, advectionShader);
+const divergenceProgram = new Program(baseVertexShader, divergenceShader);
+const curlProgram = new Program(baseVertexShader, curlShader);
+const vorticityProgram = new Program(baseVertexShader, vorticityShader);
+const pressureProgram = new Program(baseVertexShader, pressureShader);
+const gradienSubtractProgram = new Program(baseVertexShader, gradientSubtractShader);
+
+const displayMaterial = new Material(baseVertexShader, displayShaderSource);
+
+function initFramebuffers () {
+ let simRes = getResolution(config.SIM_RESOLUTION);
+ let dyeRes = getResolution(config.DYE_RESOLUTION);
+
+ const texType = ext.halfFloatTexType;
+ const rgba = ext.formatRGBA;
+ const rg = ext.formatRG;
+ const r = ext.formatR;
+ const filtering = ext.supportLinearFiltering ? gl.LINEAR : gl.NEAREST;
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) {
+
+ if (dye == null)
+ dye = createDoubleFBO(dyeRes.width, dyeRes.height, rgba.internalFormat, rgba.format, texType, filtering);
+ else
+ dye = resizeDoubleFBO(dye, dyeRes.width, dyeRes.height, rgba.internalFormat, rgba.format, texType, filtering);
+
+ if (velocity == null)
+ velocity = createDoubleFBO(simRes.width, simRes.height, rg.internalFormat, rg.format, texType, filtering);
+ else
+ velocity = resizeDoubleFBO(velocity, simRes.width, simRes.height, rg.internalFormat, rg.format, texType, filtering);
+
+ divergence = createFBO (simRes.width, simRes.height, r.internalFormat, r.format, texType, gl.NEAREST);
+ curl = createFBO (simRes.width, simRes.height, r.internalFormat, r.format, texType, gl.NEAREST);
+ pressure = createDoubleFBO(simRes.width, simRes.height, r.internalFormat, r.format, texType, gl.NEAREST);
+
+ initBloomFramebuffers();
+ initSunraysFramebuffers();
+}
+
+function initBloomFramebuffers () {
+ let res = getResolution(config.BLOOM_RESOLUTION);
+
+ const texType = ext.halfFloatTexType;
+ const rgba = ext.formatRGBA;
+ const filtering = ext.supportLinearFiltering ? gl.LINEAR : gl.NEAREST;
+
+ bloom = createFBO(res.width, res.height, rgba.internalFormat, rgba.format, texType, filtering);
+
+ bloomFramebuffers.length = 0;
+ for (let i = 0; i < config.BLOOM_ITERATIONS; i++)
+ {
+ let width = res.width >> (i + 1);
+ let height = res.height >> (i + 1);
+
+ if (width < 2 || height < 2) break;
+
+ let fbo = createFBO(width, height, rgba.internalFormat, rgba.format, texType, filtering);
+ bloomFramebuffers.push(fbo);
+ }
+}
+
+function initSunraysFramebuffers () {
+ let res = getResolution(config.SUNRAYS_RESOLUTION);
+
+ const texType = ext.halfFloatTexType;
+ const r = ext.formatR;
+ const filtering = ext.supportLinearFiltering ? gl.LINEAR : gl.NEAREST;
+
+ sunrays = createFBO(res.width, res.height, r.internalFormat, r.format, texType, filtering);
+ sunraysTemp = createFBO(res.width, res.height, r.internalFormat, r.format, texType, filtering);
+}
+
+function createFBO (w, h, internalFormat, format, type, param) {
+ gl.activeTexture(gl.TEXTURE0);
+ let texture = gl.createTexture();
+ gl.bindTexture(gl.TEXTURE_2D, texture);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, param);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, param);
+ 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, w, h, 0, format, type, null);
+
+ let fbo = gl.createFramebuffer();
+ gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
+ gl.viewport(0, 0, w, h);
+ gl.clear(gl.COLOR_BUFFER_BIT);
+
+ let texelSizeX = 1.0 / w;
+ let texelSizeY = 1.0 / h;
+
return {
- width: Math.max(2, Math.round(base * aspect)),
- height: base
+ texture,
+ fbo,
+ width: w,
+ height: h,
+ texelSizeX,
+ texelSizeY,
+ attach (id) {
+ gl.activeTexture(gl.TEXTURE0 + id);
+ gl.bindTexture(gl.TEXTURE_2D, texture);
+ return id;
+ }
};
- }
+}
+
+function createDoubleFBO (w, h, internalFormat, format, type, param) {
+ let fbo1 = createFBO(w, h, internalFormat, format, type, param);
+ let fbo2 = createFBO(w, h, internalFormat, format, type, param);
- return {
- width: base,
- height: Math.max(2, Math.round(base / Math.max(aspect, 0.001)))
- };
+ return {
+ width: w,
+ height: h,
+ texelSizeX: fbo1.texelSizeX,
+ texelSizeY: fbo1.texelSizeY,
+ get read () {
+ return fbo1;
+ },
+ set read (value) {
+ fbo1 = value;
+ },
+ get write () {
+ return fbo2;
+ },
+ set write (value) {
+ fbo2 = value;
+ },
+ swap () {
+ let temp = fbo1;
+ fbo1 = fbo2;
+ fbo2 = temp;
+ }
+ }
}
-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 resizeFBO (target, w, h, internalFormat, format, type, param) {
+ let newFBO = createFBO(w, h, internalFormat, format, type, param);
+ copyProgram.bind();
+ gl.uniform1i(copyProgram.uniforms.uTexture, target.attach(0));
+ blit(newFBO);
+ return newFBO;
}
-function clamp01(value) {
- return clamp(Number(value) || 0, 0, 1);
+function resizeDoubleFBO (target, w, h, internalFormat, format, type, param) {
+ if (target.width == w && target.height == h)
+ return target;
+ target.read = resizeFBO(target.read, w, h, internalFormat, format, type, param);
+ target.write = createFBO(w, h, internalFormat, format, type, param);
+ target.width = w;
+ target.height = h;
+ target.texelSizeX = 1.0 / w;
+ target.texelSizeY = 1.0 / h;
+ return target;
+}
+
+function createTextureAsync (url) {
+ let texture = gl.createTexture();
+ gl.bindTexture(gl.TEXTURE_2D, texture);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, 1, 1, 0, gl.RGB, gl.UNSIGNED_BYTE, new Uint8Array([255, 255, 255]));
+
+ let obj = {
+ texture,
+ width: 1,
+ height: 1,
+ attach (id) {
+ gl.activeTexture(gl.TEXTURE0 + id);
+ gl.bindTexture(gl.TEXTURE_2D, texture);
+ return id;
+ }
+ };
+
+ let image = new Image();
+ image.onload = () => {
+ obj.width = image.width;
+ obj.height = image.height;
+ gl.bindTexture(gl.TEXTURE_2D, texture);
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, image);
+ };
+ image.src = url;
+
+ return obj;
}
-function clamp(value, min, max) {
- return Math.min(max, Math.max(min, value));
+function updateKeywords () {
+ let displayKeywords = [];
+ if (config.SHADING) displayKeywords.push("SHADING");
+ if (config.BLOOM) displayKeywords.push("BLOOM");
+ if (config.SUNRAYS) displayKeywords.push("SUNRAYS");
+ displayMaterial.setKeywords(displayKeywords);
}
-const POINT_VERTEX = `
-precision highp float;
+updateKeywords();
+initFramebuffers();
-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;
+function update (dt, emitters = []) {
+ if (resizeCanvas())
+ initFramebuffers();
-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 clampedDt = Math.min(Math.max(Number(dt) || 0, 0), 0.016666);
+ applyEmitters(emitters);
+ if (!config.PAUSED)
+ step(clampedDt);
+ render(null);
}
-`;
-const POINT_FRAGMENT = `
-precision highp float;
+function clamp01 (value) {
+ return Math.min(Math.max(value, 0), 1);
+}
-varying vec3 vColor;
-varying vec2 vVelocity;
-varying float vAmount;
-uniform float pass;
+function applyEmitters (emitters) {
+ for (const emitter of emitters) {
+ const x = clamp01(Number(emitter.x) || 0);
+ const y = clamp01(Number(emitter.y) || 0);
+ const dx = correctDeltaX(Number(emitter.dx) || 0) * config.SPLAT_FORCE;
+ const dy = correctDeltaY(Number(emitter.dy) || 0) * config.SPLAT_FORCE;
+ const color = emitter.color || { r: 0, g: 0, b: 0 };
+ splat(x, y, dx, dy, color);
+ }
+}
-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);
+function resizeCanvas () {
+ const width = Math.max(1, scaleByPixelRatio(canvas.clientWidth));
+ const height = Math.max(1, scaleByPixelRatio(canvas.clientHeight));
+ if (canvas.width != width || canvas.height != height) {
+ canvas.width = width;
+ canvas.height = height;
+ return true;
+ }
+ return false;
}
-`;
-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);
+function step (dt) {
+ gl.disable(gl.BLEND);
+
+ curlProgram.bind();
+ gl.uniform2f(curlProgram.uniforms.texelSize, velocity.texelSizeX, velocity.texelSizeY);
+ gl.uniform1i(curlProgram.uniforms.uVelocity, velocity.read.attach(0));
+ blit(curl);
+
+ vorticityProgram.bind();
+ gl.uniform2f(vorticityProgram.uniforms.texelSize, velocity.texelSizeX, velocity.texelSizeY);
+ gl.uniform1i(vorticityProgram.uniforms.uVelocity, velocity.read.attach(0));
+ gl.uniform1i(vorticityProgram.uniforms.uCurl, curl.attach(1));
+ gl.uniform1f(vorticityProgram.uniforms.curl, config.CURL);
+ gl.uniform1f(vorticityProgram.uniforms.dt, dt);
+ blit(velocity.write);
+ velocity.swap();
+
+ divergenceProgram.bind();
+ gl.uniform2f(divergenceProgram.uniforms.texelSize, velocity.texelSizeX, velocity.texelSizeY);
+ gl.uniform1i(divergenceProgram.uniforms.uVelocity, velocity.read.attach(0));
+ blit(divergence);
+
+ clearProgram.bind();
+ gl.uniform1i(clearProgram.uniforms.uTexture, pressure.read.attach(0));
+ gl.uniform1f(clearProgram.uniforms.value, config.PRESSURE);
+ blit(pressure.write);
+ pressure.swap();
+
+ pressureProgram.bind();
+ gl.uniform2f(pressureProgram.uniforms.texelSize, velocity.texelSizeX, velocity.texelSizeY);
+ gl.uniform1i(pressureProgram.uniforms.uDivergence, divergence.attach(0));
+ for (let i = 0; i < config.PRESSURE_ITERATIONS; i++) {
+ gl.uniform1i(pressureProgram.uniforms.uPressure, pressure.read.attach(1));
+ blit(pressure.write);
+ pressure.swap();
+ }
+
+ gradienSubtractProgram.bind();
+ gl.uniform2f(gradienSubtractProgram.uniforms.texelSize, velocity.texelSizeX, velocity.texelSizeY);
+ gl.uniform1i(gradienSubtractProgram.uniforms.uPressure, pressure.read.attach(0));
+ gl.uniform1i(gradienSubtractProgram.uniforms.uVelocity, velocity.read.attach(1));
+ blit(velocity.write);
+ velocity.swap();
+
+ advectionProgram.bind();
+ gl.uniform2f(advectionProgram.uniforms.texelSize, velocity.texelSizeX, velocity.texelSizeY);
+ if (!ext.supportLinearFiltering)
+ gl.uniform2f(advectionProgram.uniforms.dyeTexelSize, velocity.texelSizeX, velocity.texelSizeY);
+ let velocityId = velocity.read.attach(0);
+ gl.uniform1i(advectionProgram.uniforms.uVelocity, velocityId);
+ gl.uniform1i(advectionProgram.uniforms.uSource, velocityId);
+ gl.uniform1f(advectionProgram.uniforms.dt, dt);
+ gl.uniform1f(advectionProgram.uniforms.dissipation, config.VELOCITY_DISSIPATION);
+ blit(velocity.write);
+ velocity.swap();
+
+ if (!ext.supportLinearFiltering)
+ gl.uniform2f(advectionProgram.uniforms.dyeTexelSize, dye.texelSizeX, dye.texelSizeY);
+ gl.uniform1i(advectionProgram.uniforms.uVelocity, velocity.read.attach(0));
+ gl.uniform1i(advectionProgram.uniforms.uSource, dye.read.attach(1));
+ gl.uniform1f(advectionProgram.uniforms.dissipation, config.DENSITY_DISSIPATION);
+ blit(dye.write);
+ dye.swap();
}
-`;
-const CLEAR_FRAGMENT = `
-precision mediump float;
-varying highp vec2 vUv;
-uniform sampler2D uTexture;
-uniform float value;
+function render (target) {
+ if (config.BLOOM)
+ applyBloom(dye.read, bloom);
+ if (config.SUNRAYS) {
+ applySunrays(dye.read, dye.write, sunrays);
+ blur(sunrays, sunraysTemp, 1);
+ }
-void main() {
- gl_FragColor = texture2D(uTexture, vUv) * value;
+ if (target == null || !config.TRANSPARENT) {
+ gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
+ gl.enable(gl.BLEND);
+ }
+ else {
+ gl.disable(gl.BLEND);
+ }
+
+ if (!config.TRANSPARENT)
+ drawColor(target, normalizeColor(config.BACK_COLOR));
+ if (target == null && config.TRANSPARENT)
+ drawCheckerboard(target);
+ drawDisplay(target);
}
-`;
-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);
+function drawColor (target, color) {
+ colorProgram.bind();
+ gl.uniform4f(colorProgram.uniforms.color, color.r, color.g, color.b, 1);
+ blit(target);
}
-`;
-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);
+function drawCheckerboard (target) {
+ checkerboardProgram.bind();
+ gl.uniform1f(checkerboardProgram.uniforms.aspectRatio, canvas.width / canvas.height);
+ blit(target);
}
-`;
-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);
+function drawDisplay (target) {
+ let width = target == null ? gl.drawingBufferWidth : target.width;
+ let height = target == null ? gl.drawingBufferHeight : target.height;
+
+ displayMaterial.bind();
+ if (config.SHADING)
+ gl.uniform2f(displayMaterial.uniforms.texelSize, 1.0 / width, 1.0 / height);
+ gl.uniform1i(displayMaterial.uniforms.uTexture, dye.read.attach(0));
+ if (config.BLOOM) {
+ gl.uniform1i(displayMaterial.uniforms.uBloom, bloom.attach(1));
+ gl.uniform1i(displayMaterial.uniforms.uDithering, ditheringTexture.attach(2));
+ let scale = getTextureScale(ditheringTexture, width, height);
+ gl.uniform2f(displayMaterial.uniforms.ditherScale, scale.x, scale.y);
+ }
+ if (config.SUNRAYS)
+ gl.uniform1i(displayMaterial.uniforms.uSunrays, sunrays.attach(3));
+ blit(target);
}
-`;
-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);
+function applyBloom (source, destination) {
+ if (bloomFramebuffers.length < 2)
+ return;
+
+ let last = destination;
+
+ gl.disable(gl.BLEND);
+ bloomPrefilterProgram.bind();
+ let knee = config.BLOOM_THRESHOLD * config.BLOOM_SOFT_KNEE + 0.0001;
+ let curve0 = config.BLOOM_THRESHOLD - knee;
+ let curve1 = knee * 2;
+ let curve2 = 0.25 / knee;
+ gl.uniform3f(bloomPrefilterProgram.uniforms.curve, curve0, curve1, curve2);
+ gl.uniform1f(bloomPrefilterProgram.uniforms.threshold, config.BLOOM_THRESHOLD);
+ gl.uniform1i(bloomPrefilterProgram.uniforms.uTexture, source.attach(0));
+ blit(last);
+
+ bloomBlurProgram.bind();
+ for (let i = 0; i < bloomFramebuffers.length; i++) {
+ let dest = bloomFramebuffers[i];
+ gl.uniform2f(bloomBlurProgram.uniforms.texelSize, last.texelSizeX, last.texelSizeY);
+ gl.uniform1i(bloomBlurProgram.uniforms.uTexture, last.attach(0));
+ blit(dest);
+ last = dest;
+ }
+
+ gl.blendFunc(gl.ONE, gl.ONE);
+ gl.enable(gl.BLEND);
+
+ for (let i = bloomFramebuffers.length - 2; i >= 0; i--) {
+ let baseTex = bloomFramebuffers[i];
+ gl.uniform2f(bloomBlurProgram.uniforms.texelSize, last.texelSizeX, last.texelSizeY);
+ gl.uniform1i(bloomBlurProgram.uniforms.uTexture, last.attach(0));
+ gl.viewport(0, 0, baseTex.width, baseTex.height);
+ blit(baseTex);
+ last = baseTex;
+ }
+
+ gl.disable(gl.BLEND);
+ bloomFinalProgram.bind();
+ gl.uniform2f(bloomFinalProgram.uniforms.texelSize, last.texelSizeX, last.texelSizeY);
+ gl.uniform1i(bloomFinalProgram.uniforms.uTexture, last.attach(0));
+ gl.uniform1f(bloomFinalProgram.uniforms.intensity, config.BLOOM_INTENSITY);
+ blit(destination);
}
-`;
-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);
+function applySunrays (source, mask, destination) {
+ gl.disable(gl.BLEND);
+ sunraysMaskProgram.bind();
+ gl.uniform1i(sunraysMaskProgram.uniforms.uTexture, source.attach(0));
+ blit(mask);
+
+ sunraysProgram.bind();
+ gl.uniform1f(sunraysProgram.uniforms.weight, config.SUNRAYS_WEIGHT);
+ gl.uniform1i(sunraysProgram.uniforms.uTexture, mask.attach(0));
+ blit(destination);
}
-`;
-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);
+function blur (target, temp, iterations) {
+ blurProgram.bind();
+ for (let i = 0; i < iterations; i++) {
+ gl.uniform2f(blurProgram.uniforms.texelSize, target.texelSizeX, 0.0);
+ gl.uniform1i(blurProgram.uniforms.uTexture, target.attach(0));
+ blit(temp);
+
+ gl.uniform2f(blurProgram.uniforms.texelSize, 0.0, target.texelSizeY);
+ gl.uniform1i(blurProgram.uniforms.uTexture, temp.attach(0));
+ blit(target);
+ }
}
-`;
-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);
+function splat (x, y, dx, dy, color) {
+ splatProgram.bind();
+ gl.uniform1i(splatProgram.uniforms.uTarget, velocity.read.attach(0));
+ gl.uniform1f(splatProgram.uniforms.aspectRatio, canvas.width / canvas.height);
+ gl.uniform2f(splatProgram.uniforms.point, x, y);
+ gl.uniform3f(splatProgram.uniforms.color, dx, dy, 0.0);
+ gl.uniform1f(splatProgram.uniforms.radius, correctRadius(config.SPLAT_RADIUS / 100.0));
+ blit(velocity.write);
+ velocity.swap();
+
+ gl.uniform1i(splatProgram.uniforms.uTarget, dye.read.attach(0));
+ gl.uniform3f(splatProgram.uniforms.color, color.r, color.g, color.b);
+ blit(dye.write);
+ dye.swap();
}
-`;
-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);
+function correctRadius (radius) {
+ let aspectRatio = canvas.width / canvas.height;
+ if (aspectRatio > 1)
+ radius *= aspectRatio;
+ return radius;
+}
+
+function correctDeltaX (delta) {
+ let aspectRatio = canvas.width / canvas.height;
+ if (aspectRatio < 1) delta *= aspectRatio;
+ return delta;
+}
+
+function correctDeltaY (delta) {
+ let aspectRatio = canvas.width / canvas.height;
+ if (aspectRatio > 1) delta /= aspectRatio;
+ return delta;
+}
+
+function normalizeColor (input) {
+ let output = {
+ r: input.r / 255,
+ g: input.g / 255,
+ b: input.b / 255
+ };
+ return output;
+}
+
+function wrap (value, min, max) {
+ let range = max - min;
+ if (range == 0) return min;
+ return (value - min) % range + min;
+}
+
+function getResolution (resolution) {
+ let aspectRatio = gl.drawingBufferWidth / gl.drawingBufferHeight;
+ if (aspectRatio < 1)
+ aspectRatio = 1.0 / aspectRatio;
+
+ let min = Math.round(resolution);
+ let max = Math.round(resolution * aspectRatio);
+
+ if (gl.drawingBufferWidth > gl.drawingBufferHeight)
+ return { width: max, height: min };
+ else
+ return { width: min, height: max };
+}
+
+function getTextureScale (texture, width, height) {
+ return {
+ x: width / texture.width,
+ y: height / texture.height
+ };
+}
+
+function scaleByPixelRatio (input) {
+ let pixelRatio = window.devicePixelRatio || 1;
+ return Math.floor(input * pixelRatio);
+}
+
+function hashCode (s) {
+ if (s.length == 0) return 0;
+ let hash = 0;
+ for (let i = 0; i < s.length; i++) {
+ hash = (hash << 5) - hash + s.charCodeAt(i);
+ hash |= 0; // Convert to 32bit integer
+ }
+ return hash;
+};
+
+function clear () {
+ gl.disable(gl.BLEND);
+ const targets = [
+ dye?.read, dye?.write,
+ velocity?.read, velocity?.write,
+ divergence, curl,
+ pressure?.read, pressure?.write,
+ bloom, sunrays, sunraysTemp,
+ ...bloomFramebuffers
+ ].filter(Boolean);
+
+ for (const target of targets) {
+ gl.bindFramebuffer(gl.FRAMEBUFFER, target.fbo);
+ gl.viewport(0, 0, target.width, target.height);
+ gl.clearColor(0.0, 0.0, 0.0, 1.0);
+ gl.clear(gl.COLOR_BUFFER_BIT);
+ }
+
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
+ render(null);
+}
+
+return {
+ canvas,
+ supported: true,
+ clear,
+ update
+};
}
-`;
diff --git a/src/main.js b/src/main.js
index 45784c9..87e831e 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'), visualFluid: $('#visual-fluid'), visualGlow: $('#visual-glow'), visualLabels: $('#visual-labels'), visualFluidResponse: $('#visual-fluid-response'), visualPalette: $('#visual-palette')
+ visualEnabled: $('#visual-enabled')
};
ui.appVersion.textContent = `Version ${APP_VERSION}`;
@@ -144,14 +144,7 @@ function savePreferences() {
scene: preferredSceneAddress
},
visuals: {
- 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
+ enabled: ui.visualEnabled.checked
},
ui: {
sidebarHidden: ui.app.classList.contains('sidebar-hidden'),
@@ -217,16 +210,9 @@ function restorePreferences() {
if (typeof display.vectors === 'boolean') ui.showVectors.checked = display.vectors;
if (typeof display.scene === 'string' && display.scene) preferredSceneAddress = display.scene;
- 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.enabled === 'boolean') {
+ ui.visualEnabled.checked = visualPreferences.enabled;
}
- if (typeof visualPreferences.palette === 'string') ui.visualPalette.value = visualPreferences.palette;
const sidebarWidth = Number(uiPreferences.sidebarWidth);
if (Number.isFinite(sidebarWidth)) setSidebarWidth(sidebarWidth);
@@ -553,20 +539,8 @@ function applyVisibility() {
}
function applyVisualOptions() {
- visuals.setOptions({
- 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 =
- ui.visualEnabled.checked
- ? ui.visualPalette.selectedOptions[0]?.textContent ?? 'Punchy'
- : 'Off';
+ visuals.setOptions({ enabled: ui.visualEnabled.checked });
+ ui.visualsSectionSummary.textContent = ui.visualEnabled.checked ? 'Fluid' : 'Off';
}
viewer.setCameraChangeHandler(scheduleCameraPreferenceSave);
@@ -781,20 +755,10 @@ ui.downsample.addEventListener('change', () => {
savePreferences();
}));
-[
- ui.visualEnabled,
- ui.visualTrails,
- ui.visualGrid,
- ui.visualFluid,
- ui.visualGlow,
- ui.visualLabels,
- ui.visualFluidResponse,
- ui.visualPalette
-]
- .forEach((input) => input.addEventListener('change', () => {
- applyVisualOptions();
- savePreferences();
- }));
+ui.visualEnabled.addEventListener('change', () => {
+ applyVisualOptions();
+ savePreferences();
+});
function syncPreferencesToUi() {
restorePreferences();
diff --git a/src/styles.css b/src/styles.css
index bf976e7..ef35ef2 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -54,6 +54,10 @@ button, input, select { font: inherit; }
pointer-events: none;
}
+#canvas-host .fluid-visuals {
+ background: #000;
+}
+
.viewer-overlay {
position: absolute;
top: 0;
@@ -871,15 +875,3 @@ button.subtle-button.active {
}
}
-
-.visual-palette-field {
- display: grid;
- gap: 6px;
- margin-top: 14px;
-}
-
-.visual-palette-field label {
- color: #aeb6c5;
- font-size: 11px;
- font-weight: 600;
-}
diff --git a/src/visuals.js b/src/visuals.js
index 5fa5c4a..0dd26d5 100644
--- a/src/visuals.js
+++ b/src/visuals.js
@@ -1,619 +1,160 @@
-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
- }
-};
+import { createFluidSimulation } from './fluid.js';
-const PALETTES = {
- punchy: ['#38e8ff', '#ff4fd8', '#a4ff56', '#ffb02e', '#7f7cff', '#ff5c69'],
- cool: ['#49ecff', '#4d9cff', '#8c7dff', '#47ffd0', '#84b7ff', '#b58cff'],
- mono: ['#f4f7ff', '#cfd7e8', '#ffffff', '#aab6cc', '#e8edfa', '#bdc7dc']
+const DEFAULT_SCENE = {
+ minX: -5,
+ maxX: 5,
+ minY: 0,
+ maxY: 4
};
+const MAX_TOTAL_EMITTERS = 16;
+const MAX_EMITTERS_PER_CLOUD = 8;
+const MAX_QUEUED_EMITTERS = 32;
+const MAX_POINT_DELTA_UV = 0.08;
+const MIN_POINT_DELTA_UV = 0.00001;
+const COLOR_UPDATE_SPEED = 10;
+const MIN_FRAME_DT = 1 / 120;
+const MAX_FRAME_DT = 1 / 10;
+
export function createVisuals(host) {
const canvas = document.createElement('canvas');
- canvas.className = 'data-visuals';
+ canvas.className = 'data-visuals fluid-visuals';
canvas.setAttribute('aria-hidden', 'true');
host.appendChild(canvas);
- 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;
- let height = 1;
- let pixelRatio = 1;
- let rightInset = 0;
- let lastDraw = performance.now();
- let scene = defaultScene();
- let zones = [];
- let fluidSamplePhase = 0;
-
- const visibility = {
- clusters: true,
- points: true,
- scene: true,
- zones: true,
- vectors: true
- };
-
- const options = {
- enabled: true,
- trails: true,
- grid: true,
- glow: true,
- labels: true,
- fluid: true,
- fluidResponse: 'balanced',
- palette: 'punchy'
- };
+ const fluid = safeCreateFluid(canvas);
+ const views = new Map();
- function resize() {
- const rect = host.getBoundingClientRect();
- width = Math.max(1, Math.round(rect.width));
- height = Math.max(1, Math.round(rect.height));
- pixelRatio = Math.min(window.devicePixelRatio || 1, 2);
-
- for (const target of [canvas, trailCanvas]) {
- target.width = Math.max(1, Math.round(width * pixelRatio));
- target.height = Math.max(1, Math.round(height * pixelRatio));
- }
-
- ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
- trail.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
- trail.clearRect(0, 0, width, height);
- }
+ let scene = { ...DEFAULT_SCENE };
+ let pendingEmitters = [];
+ let lastAnimationTime = performance.now();
+ let colorUpdateTimer = 0;
+ let enabled = true;
function renderSetup(root, selectedSceneAddress) {
const scenes = [];
- const nextZones = [];
-
walk(root, (container) => {
if (container.isScene?.()) scenes.push(container);
- if (container.isZone?.()) {
- nextZones.push({
- name: container.getName?.() || 'Zone',
- position: vector3(container.getPosition?.(), [0, 0, 0])
- });
- }
});
- const selected = scenes.find((candidate) => (
- selectedSceneAddress && candidate.getAddress?.() === selectedSceneAddress
- )) ?? scenes[0];
-
- scene = selected ? sceneFromContainer(selected) : defaultScene();
- zones = nextZones;
+ scene = sceneBounds(scenes, selectedSceneAddress);
+ clearTracking();
}
function renderFrame(frame) {
const sceneAddress = frame.getSceneInfo?.().getAddress?.() || '';
- const active = new Set();
const now = performance.now();
+ const clouds = [];
+ const active = new Set();
frame.getObjects?.().forEach((object, index) => {
+ if (!object.hasPointCloud?.()) return;
+
const id = object.getID?.();
const uuid = object.getUUID?.();
const key = `${sceneAddress}|${uuid || `id:${id ?? index}`}`;
active.add(key);
- let view = objects.get(key);
+ let view = views.get(key);
if (!view) {
- view = createView(key, id, uuid);
- objects.set(key, view);
+ view = createView(key);
+ views.set(key, view);
}
- view.id = id;
- view.uuid = uuid;
- view.lastSeen = now;
+ const points = object.getPointCloud?.().getPointsData?.();
+ if (!points?.length) return;
+ let velocity = [0, 0, 0];
if (object.hasCluster?.()) {
- const cluster = object.getCluster();
- view.centerTarget = vector3(cluster.getBoundingBoxCenter?.(), view.centerTarget);
- view.sizeTarget = vector3(cluster.getBoundingBoxSize?.(), view.sizeTarget);
- view.centroidTarget = vector3(cluster.getCentroid?.(), view.centerTarget);
- view.velocityTarget = vector3(cluster.getVelocity?.(), [0, 0, 0]);
+ velocity = vector3(object.getCluster().getVelocity?.(), velocity);
}
- if (object.hasPointCloud?.()) {
- const points = object.getPointCloud().getPointsData?.();
- if (points?.length) view.points = points;
- }
+ clouds.push({ view, points, velocity, now });
});
- for (const [key, view] of objects) {
+ for (const [key] of views) {
if (key.startsWith(`${sceneAddress}|`) && !active.has(key)) {
- view.lastSeen = Math.min(view.lastSeen, now - 1);
- }
- }
- }
-
- function createView(key, id, uuid) {
- return {
- key,
- id,
- uuid,
- colorIndex: hashString(key) % PALETTES.punchy.length,
- lastSeen: performance.now(),
- center: [0, 0.9, 0],
- centerTarget: [0, 0.9, 0],
- size: [0.55, 1.7, 0.55],
- sizeTarget: [0.55, 1.7, 0.55],
- centroid: [0, 0.9, 0],
- centroidTarget: [0, 0.9, 0],
- velocity: [0, 0, 0],
- velocityTarget: [0, 0, 0],
- points: undefined
- };
- }
-
- function animate(now) {
- const dt = Math.min(Math.max(now - lastDraw, 0), 50);
- lastDraw = now;
- const amount = 1 - Math.exp(-dt / EASING_MS);
-
- for (const [key, view] of objects) {
- ease(view.center, view.centerTarget, amount);
- ease(view.size, view.sizeTarget, amount);
- ease(view.centroid, view.centroidTarget, amount);
- ease(view.velocity, view.velocityTarget, amount);
- if (now - view.lastSeen > STALE_MS) objects.delete(key);
- }
-
- canvas.hidden = !options.enabled;
- if (options.enabled) draw(now, dt / 1000);
- requestAnimationFrame(animate);
- }
-
- 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);
-
- const views = [...objects.values()];
- for (const view of views) drawObject(view, projection, now);
-
- if (visibility.zones) drawZones(projection);
- drawHud(projection, views.length);
- }
-
- function drawBackground(projection, now) {
- ctx.fillStyle = BACKGROUND;
- ctx.fillRect(0, 0, width, height);
-
- const radial = ctx.createRadialGradient(
- projection.centerX,
- projection.top + projection.stageHeight * 0.55,
- 0,
- projection.centerX,
- projection.top + projection.stageHeight * 0.55,
- Math.max(projection.stageWidth, projection.stageHeight) * 0.85
- );
- radial.addColorStop(0, 'rgba(46, 59, 97, .24)');
- radial.addColorStop(.55, 'rgba(22, 28, 48, .09)');
- radial.addColorStop(1, 'rgba(9, 11, 17, 0)');
- ctx.fillStyle = radial;
- ctx.fillRect(0, 0, projection.usableWidth, height);
-
- drawFloorWaves(projection, now);
- }
-
- function drawFloorWaves(p, now) {
- const palette = currentPalette();
- const startY = p.bottom - p.stageHeight * 0.18;
-
- ctx.save();
- ctx.lineWidth = 1;
-
- for (let row = 0; row < 12; row++) {
- const t = row / 11;
- const y = startY + t * (p.bottom - startY);
- ctx.strokeStyle = colorAlpha(palette[row % palette.length], 0.035 + t * 0.055);
- ctx.beginPath();
-
- for (let column = 0; column <= 56; column++) {
- const x = p.left + (column / 56) * p.stageWidth;
- const wave = Math.sin(column * .58 + row * .72 + now * .0012) * (1.2 + t * 3.2);
- if (column === 0) ctx.moveTo(x, y + wave);
- else ctx.lineTo(x, y + wave);
- }
- ctx.stroke();
- }
-
- ctx.restore();
- }
-
- function drawGrid(p) {
- ctx.save();
- ctx.strokeStyle = 'rgba(155, 177, 222, .10)';
- ctx.lineWidth = 1;
-
- for (let i = 0; i <= 12; i++) {
- const x = p.left + p.stageWidth * (i / 12);
- segment(ctx, x, p.top, x, p.bottom);
- }
-
- for (let i = 0; i <= 8; i++) {
- const y = p.top + p.stageHeight * (i / 8);
- segment(ctx, p.left, y, p.right, y);
- }
-
- ctx.restore();
- }
-
- function drawSceneFrame(p) {
- ctx.save();
- ctx.strokeStyle = 'rgba(220, 230, 255, .26)';
- ctx.lineWidth = 1;
- ctx.setLineDash([7, 8]);
- ctx.strokeRect(p.left, p.top, p.stageWidth, p.stageHeight);
- ctx.setLineDash([]);
-
- const corner = 16;
- ctx.strokeStyle = 'rgba(233, 240, 255, .72)';
- ctx.lineWidth = 1.4;
- cornerMark(ctx, p.left, p.top, 1, 1, corner);
- cornerMark(ctx, p.right, p.top, -1, 1, corner);
- cornerMark(ctx, p.left, p.bottom, 1, -1, corner);
- cornerMark(ctx, p.right, p.bottom, -1, -1, corner);
- 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
- });
+ views.delete(key);
}
-
- 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';
- trail.fillStyle = options.trails ? 'rgba(0, 0, 0, .09)' : 'rgba(0, 0, 0, 1)';
- trail.fillRect(0, 0, width, height);
- trail.restore();
+ if (!enabled || !clouds.length) return;
- if (!options.trails || !visibility.points) return;
+ const perCloudBudget = emitterBudget(clouds.length);
+ const emitters = [];
- trail.save();
- trail.globalCompositeOperation = 'lighter';
- for (const view of objects.values()) {
- if (!view.points?.length) continue;
- drawPointCloud(trail, view, p, .10, 1.15, false);
+ for (const cloud of clouds) {
+ emitters.push(...emittersForCloud(cloud, perCloudBudget, scene));
}
- trail.restore();
- }
- function drawObject(view, p, now) {
- const fade = Math.min(1, Math.max(0, (STALE_MS - (now - view.lastSeen)) / 120));
- const color = objectColor(view);
+ if (!emitters.length) return;
- if (visibility.points && view.points?.length) {
- drawPointCloud(ctx, view, p, .88 * fade, options.glow ? 1.75 : 1.4, options.glow);
+ pendingEmitters.push(...emitters);
+ if (pendingEmitters.length > MAX_QUEUED_EMITTERS) {
+ pendingEmitters = pendingEmitters.slice(-MAX_QUEUED_EMITTERS);
}
-
- if (visibility.clusters) drawBounds(view, p, color, fade);
- if (visibility.vectors) drawVelocity(view, p, color, fade);
}
- function drawPointCloud(target, view, p, alpha, radius, glow) {
- const data = view.points;
- if (!data?.length) return;
-
- const color = objectColor(view);
- const pointCount = Math.floor(data.length / 3);
- const stride = Math.max(1, Math.ceil(pointCount / MAX_POINTS)) * 3;
-
- target.save();
- target.fillStyle = colorAlpha(color, alpha);
- if (glow) {
- target.shadowColor = color;
- target.shadowBlur = 8;
- }
-
- for (let i = 0; i + 1 < data.length; i += stride) {
- const x = p.x(data[i]);
- const y = p.y(data[i + 1]);
- if (x < p.left || x > p.right || y < p.top || y > p.bottom) continue;
- target.fillRect(x - radius * .5, y - radius * .5, radius, radius);
- }
-
- target.restore();
- }
-
- function drawBounds(view, p, color, alpha) {
- const centerX = p.x(view.center[0]);
- const centerY = p.y(view.center[1]);
- const boxWidth = Math.max(Math.abs(view.size[0]) * p.scaleX, 12);
- const boxHeight = Math.max(Math.abs(view.size[1]) * p.scaleY, 20);
- const x = centerX - boxWidth * .5;
- const y = centerY - boxHeight * .5;
-
- ctx.save();
- ctx.strokeStyle = colorAlpha(color, .78 * alpha);
- ctx.lineWidth = 1.2;
-
- if (options.glow) {
- ctx.shadowColor = color;
- ctx.shadowBlur = 10;
+ function animate(now) {
+ const dt = Math.min(Math.max((now - lastAnimationTime) / 1000, 0), 1 / 60);
+ lastAnimationTime = now;
+
+ updateColors(dt);
+
+ canvas.hidden = !enabled || !fluid.supported;
+ if (enabled && fluid.supported) {
+ const emitters = pendingEmitters;
+ pendingEmitters = [];
+ fluid.update(dt, emitters);
+ } else {
+ pendingEmitters = [];
}
- ctx.strokeRect(x, y, boxWidth, boxHeight);
-
- const corner = Math.min(14, Math.max(7, Math.min(boxWidth, boxHeight) * .13));
- ctx.lineWidth = 2.1;
- cornerMark(ctx, x, y, 1, 1, corner);
- cornerMark(ctx, x + boxWidth, y, -1, 1, corner);
- cornerMark(ctx, x, y + boxHeight, 1, -1, corner);
- cornerMark(ctx, x + boxWidth, y + boxHeight, -1, -1, corner);
-
- const cx = p.x(view.centroid[0]);
- const cy = p.y(view.centroid[1]);
- ctx.fillStyle = colorAlpha(color, .9 * alpha);
- ctx.beginPath();
- ctx.arc(cx, cy, 2.4, 0, Math.PI * 2);
- ctx.fill();
-
- if (options.labels) drawLabel(view, x, y, color, alpha);
- ctx.restore();
- }
-
- function drawLabel(view, x, y, color, alpha) {
- const id = view.id !== undefined ? `ID ${view.id}` : view.uuid?.slice(0, 8) || 'OBJECT';
- ctx.shadowBlur = 0;
- ctx.font = '600 9px Inter, ui-sans-serif, system-ui, sans-serif';
- ctx.fillStyle = colorAlpha(color, .92 * alpha);
- ctx.fillText(id, x, Math.max(16, y - 8));
-
- ctx.font = '500 8px Inter, ui-sans-serif, system-ui, sans-serif';
- ctx.fillStyle = 'rgba(205, 216, 240, .48)';
- ctx.fillText(
- `X ${view.center[0].toFixed(2)} Y ${view.center[1].toFixed(2)}`,
- x,
- Math.max(26, y + 5)
- );
- }
-
- function drawVelocity(view, p, color, alpha) {
- const vx = Number(view.velocity[0]) || 0;
- const vy = Number(view.velocity[1]) || 0;
- const speed = Math.hypot(vx, vy);
- if (speed < .01) return;
-
- const x = p.x(view.centroid[0]);
- const y = p.y(view.centroid[1]);
- const length = Math.min(58, 12 + speed * 30);
- const nx = vx / speed;
- const ny = -vy / speed;
-
- ctx.save();
- ctx.strokeStyle = colorAlpha(color, .55 * alpha);
- ctx.fillStyle = colorAlpha(color, .55 * alpha);
- ctx.lineWidth = 1.1;
- segment(ctx, x, y, x + nx * length, y + ny * length);
-
- ctx.translate(x + nx * length, y + ny * length);
- ctx.rotate(Math.atan2(ny, nx));
- ctx.beginPath();
- ctx.moveTo(0, 0);
- ctx.lineTo(-6, -3);
- ctx.lineTo(-6, 3);
- ctx.closePath();
- ctx.fill();
- ctx.restore();
+ requestAnimationFrame(animate);
}
- function drawZones(p) {
- if (!zones.length) return;
-
- ctx.save();
- ctx.font = '500 8px Inter, ui-sans-serif, system-ui, sans-serif';
+ function updateColors(dt) {
+ colorUpdateTimer += dt * COLOR_UPDATE_SPEED;
+ if (colorUpdateTimer < 1) return;
- for (const zone of zones.slice(0, 12)) {
- const x = p.x(zone.position[0]);
- if (x < p.left || x > p.right) continue;
- ctx.strokeStyle = 'rgba(191, 166, 255, .40)';
- segment(ctx, x, p.bottom - 8, x, p.bottom + 8);
- ctx.fillStyle = 'rgba(191, 166, 255, .55)';
- ctx.fillText(zone.name, x + 5, p.bottom - 10);
+ colorUpdateTimer %= 1;
+ for (const view of views.values()) {
+ for (let index = 0; index < view.colors.length; index++) {
+ view.colors[index] = generateColor();
+ }
}
-
- ctx.restore();
- }
-
- function drawHud(p, count) {
- ctx.save();
- ctx.font = '600 9px Inter, ui-sans-serif, system-ui, sans-serif';
- ctx.fillStyle = 'rgba(230, 237, 252, .74)';
-
- const dimensions = scene.size.map((value) => Number(value).toFixed(1)).join(' × ');
- ctx.fillText(`DIMENSIONS ${dimensions} m`, 24, 28);
- ctx.fillText(`PEOPLE ${count}`, 24, 43);
-
- ctx.textAlign = 'right';
- ctx.fillStyle = 'rgba(193, 207, 235, .46)';
- ctx.fillText('AUGMENTA / LIVE DATA', p.right, 28);
- ctx.restore();
- }
-
- function makeProjection() {
- const usableWidth = Math.max(180, width - rightInset);
- const left = Math.max(40, usableWidth * .075);
- const right = usableWidth - Math.max(40, usableWidth * .075);
- const top = Math.max(72, height * .12);
- const bottom = height - Math.max(48, height * .09);
- const stageWidth = Math.max(1, right - left);
- const stageHeight = Math.max(1, bottom - top);
-
- return {
- usableWidth,
- left,
- right,
- top,
- bottom,
- stageWidth,
- stageHeight,
- centerX: (left + right) * .5,
- scaleX: stageWidth / Math.max(scene.maxX - scene.minX, .001),
- scaleY: stageHeight / Math.max(scene.maxY - scene.minY, .001),
- x: (value) => left + ((value - scene.minX) / Math.max(scene.maxX - scene.minX, .001)) * stageWidth,
- y: (value) => bottom - ((value - scene.minY) / Math.max(scene.maxY - scene.minY, .001)) * stageHeight
- };
- }
-
- function currentPalette() {
- return PALETTES[options.palette] || PALETTES.punchy;
- }
-
- function objectColor(view) {
- const palette = currentPalette();
- return palette[view.colorIndex % palette.length];
- }
-
- function setVisibility(next) {
- Object.assign(visibility, next);
- }
-
- 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);
+ views.clear();
+ pendingEmitters = [];
fluid.clear();
}
function clearSetup() {
- scene = defaultScene();
- zones = [];
- fluid.clear();
+ scene = { ...DEFAULT_SCENE };
+ clearTracking();
}
function reset() {
- trail.clearRect(0, 0, width, height);
+ pendingEmitters = [];
+ for (const view of views.values()) view.previousSamples = [];
fluid.clear();
}
- function setRightInset(value) {
- rightInset = Math.max(0, Number(value) || 0);
+ function setOptions(next) {
+ if (typeof next?.enabled !== 'boolean') return;
+
+ const wasEnabled = enabled;
+ enabled = next.enabled;
+ if (wasEnabled !== enabled) reset();
}
- new ResizeObserver(resize).observe(host);
- resize();
+ // Kept for compatibility with the Three.js-based application shell.
+ // Fluid input always comes from point clouds regardless of 3D display toggles.
+ function setVisibility() {}
+ function setRightInset() {}
+
requestAnimationFrame(animate);
return {
@@ -628,83 +169,178 @@ export function createVisuals(host) {
};
}
-function sceneFromContainer(container) {
- const position = vector3(container.getPosition?.(), [0, 0, 0]);
- const size = vector3(container.getSceneParameters?.()?.size, [10, 4, 8]);
-
+function createView(key) {
return {
- minX: position[0],
- maxX: position[0] + Math.max(Math.abs(size[0]), .001),
- minY: position[1],
- maxY: position[1] + Math.max(Math.abs(size[1]), .001),
- size
+ key,
+ colors: [],
+ previousSamples: [],
+ lastFrameTime: undefined
};
}
-function defaultScene() {
- return {
- minX: -5,
- maxX: 5,
- minY: 0,
- maxY: 4,
- size: [10, 4, 8]
+function emittersForCloud({ view, points, velocity, now }, sampleCount, bounds) {
+ const pointCount = Math.floor(points.length / 3);
+ const indices = selectEmitterIndices(pointCount, sampleCount);
+ const frameDt = view.lastFrameTime === undefined
+ ? 1 / 30
+ : clamp((now - view.lastFrameTime) / 1000, MIN_FRAME_DT, MAX_FRAME_DT);
+ view.lastFrameTime = now;
+
+ while (view.colors.length < indices.length) view.colors.push(generateColor());
+ view.colors.length = indices.length;
+
+ const sceneWidth = Math.max(bounds.maxX - bounds.minX, 0.0001);
+ const sceneHeight = Math.max(bounds.maxY - bounds.minY, 0.0001);
+ const fallbackDelta = {
+ x: (Number(velocity[0]) || 0) * frameDt / sceneWidth,
+ y: (Number(velocity[1]) || 0) * frameDt / sceneHeight
};
+
+ const nextSamples = [];
+ const emitters = [];
+
+ indices.forEach((pointIndex, slot) => {
+ const offset = pointIndex * 3;
+ const uv = projectPointToUv(
+ [points[offset], points[offset + 1], points[offset + 2]],
+ bounds
+ );
+ nextSamples[slot] = uv;
+
+ if (!uv || !insideUnitSquare(uv)) return;
+
+ const previous = view.previousSamples[slot];
+ let dx = fallbackDelta.x;
+ let dy = fallbackDelta.y;
+
+ if (previous) {
+ const pointDx = uv.x - previous.x;
+ const pointDy = uv.y - previous.y;
+ const pointDistance = Math.hypot(pointDx, pointDy);
+
+ if (pointDistance <= MAX_POINT_DELTA_UV) {
+ dx = pointDx;
+ dy = pointDy;
+ }
+ }
+
+ if (Math.hypot(dx, dy) <= MIN_POINT_DELTA_UV) return;
+
+ emitters.push({
+ x: uv.x,
+ y: uv.y,
+ dx,
+ dy,
+ color: view.colors[slot]
+ });
+ });
+
+ view.previousSamples = nextSamples;
+ return emitters;
}
-function walk(container, visit) {
- if (!container) return;
- visit(container);
- for (const child of container.getChildren?.() || []) walk(child, visit);
+export function emitterBudget(cloudCount) {
+ const count = Math.max(1, Number(cloudCount) || 1);
+ return Math.max(
+ 1,
+ Math.min(MAX_EMITTERS_PER_CLOUD, Math.floor(MAX_TOTAL_EMITTERS / count))
+ );
}
-function vector3(value, fallback) {
- if (!value || value.length < 3) return [...fallback];
- return [0, 1, 2].map((index) => (
- Number.isFinite(Number(value[index])) ? Number(value[index]) : fallback[index]
+export function selectEmitterIndices(pointCount, sampleCount) {
+ const count = Math.max(0, Math.floor(Number(pointCount) || 0));
+ const samples = Math.min(count, Math.max(0, Math.floor(Number(sampleCount) || 0)));
+ if (!count || !samples) return [];
+
+ return Array.from({ length: samples }, (_, index) => (
+ Math.min(
+ count - 1,
+ Math.floor(((index + 0.5) / samples) * count)
+ )
));
}
-function ease(current, target, amount) {
- for (let i = 0; i < current.length; i++) current[i] += (target[i] - current[i]) * amount;
-}
+export function projectPointToUv(point, bounds) {
+ if (!point || point.length < 2) return undefined;
-function hashString(value) {
- let hash = 2166136261;
- for (const char of String(value)) {
- hash ^= char.charCodeAt(0);
- hash = Math.imul(hash, 16777619);
- }
- return hash >>> 0;
+ const x = Number(point[0]);
+ const y = Number(point[1]);
+ if (!Number.isFinite(x) || !Number.isFinite(y)) return undefined;
+
+ const width = Math.max(bounds.maxX - bounds.minX, 0.0001);
+ const height = Math.max(bounds.maxY - bounds.minY, 0.0001);
+
+ return {
+ x: (x - bounds.minX) / width,
+ y: (y - bounds.minY) / height
+ };
}
-function segment(context, x1, y1, x2, y2) {
- context.beginPath();
- context.moveTo(x1, y1);
- context.lineTo(x2, y2);
- context.stroke();
+function sceneBounds(scenes, selectedSceneAddress) {
+ const selected = selectedSceneAddress
+ ? scenes.find((candidate) => candidate.getAddress?.() === selectedSceneAddress)
+ : undefined;
+
+ if (selected) return boundsForScene(selected);
+ if (!scenes.length) return { ...DEFAULT_SCENE };
+
+ return scenes
+ .map(boundsForScene)
+ .reduce((combined, current) => ({
+ minX: Math.min(combined.minX, current.minX),
+ maxX: Math.max(combined.maxX, current.maxX),
+ minY: Math.min(combined.minY, current.minY),
+ maxY: Math.max(combined.maxY, current.maxY)
+ }));
}
-function cornerMark(context, x, y, sx, sy, length) {
- segment(context, x, y, x + sx * length, y);
- segment(context, x, y, x, y + sy * length);
+function boundsForScene(container) {
+ const position = vector3(container.getPosition?.(), [0, 0, 0]);
+ const size = vector3(container.getSceneParameters?.()?.size, [10, 4, 8]);
+
+ const x2 = position[0] + size[0];
+ const y2 = position[1] + size[1];
+
+ return {
+ minX: Math.min(position[0], x2),
+ maxX: Math.max(position[0], x2),
+ minY: Math.min(position[1], y2),
+ maxY: Math.max(position[1], y2)
+ };
}
-function colorAlpha(hex, alpha) {
- const value = hex.replace('#', '');
- const r = Number.parseInt(value.slice(0, 2), 16);
- const g = Number.parseInt(value.slice(2, 4), 16);
- const b = Number.parseInt(value.slice(4, 6), 16);
- return `rgba(${r}, ${g}, ${b}, ${Math.max(0, Math.min(1, alpha))})`;
+function generateColor() {
+ const color = hsvToRgb(Math.random(), 1, 1);
+ return {
+ r: color.r * 0.15,
+ g: color.g * 0.15,
+ b: color.b * 0.15
+ };
}
+function hsvToRgb(h, s, v) {
+ const i = Math.floor(h * 6);
+ const f = h * 6 - i;
+ const p = v * (1 - s);
+ const q = v * (1 - f * s);
+ const t = v * (1 - (1 - f) * s);
+
+ switch (i % 6) {
+ case 0: return { r: v, g: t, b: p };
+ case 1: return { r: q, g: v, b: p };
+ case 2: return { r: p, g: v, b: t };
+ case 3: return { r: p, g: q, b: v };
+ case 4: return { r: t, g: p, b: v };
+ default: return { r: v, g: p, b: q };
+ }
+}
-function safeCreateFluidField() {
+function safeCreateFluid(canvas) {
try {
- return createFluidField();
+ return createFluidSimulation(canvas);
} catch (error) {
- console.warn('Fluid visuals unavailable:', error);
+ console.warn('Fluid simulation unavailable:', error);
return {
- canvas: document.createElement('canvas'),
supported: false,
clear() {},
update() {}
@@ -712,11 +348,23 @@ function safeCreateFluidField() {
}
}
-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
- ];
+function insideUnitSquare(point) {
+ return point.x >= 0 && point.x <= 1 && point.y >= 0 && point.y <= 1;
+}
+
+function walk(container, visit) {
+ if (!container) return;
+ visit(container);
+ for (const child of container.getChildren?.() || []) walk(child, visit);
+}
+
+function vector3(value, fallback) {
+ if (!value || value.length < 3) return [...fallback];
+ return [0, 1, 2].map((index) => (
+ Number.isFinite(Number(value[index])) ? Number(value[index]) : fallback[index]
+ ));
+}
+
+function clamp(value, min, max) {
+ return Math.min(max, Math.max(min, value));
}
diff --git a/tests/fluid-visuals.test.mjs b/tests/fluid-visuals.test.mjs
new file mode 100644
index 0000000..c53b482
--- /dev/null
+++ b/tests/fluid-visuals.test.mjs
@@ -0,0 +1,71 @@
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import test from 'node:test';
+
+import {
+ emitterBudget,
+ projectPointToUv,
+ selectEmitterIndices
+} from '../src/visuals.js';
+
+test('emitter budget stays bounded and shares capacity across clouds', () => {
+ assert.equal(emitterBudget(1), 8);
+ assert.equal(emitterBudget(2), 8);
+ assert.equal(emitterBudget(3), 5);
+ assert.equal(emitterBudget(6), 2);
+ assert.equal(emitterBudget(24), 1);
+ assert.equal(emitterBudget(100), 1);
+});
+
+test('point-cloud subsampling is deterministic and evenly distributed', () => {
+ assert.deepEqual(selectEmitterIndices(12, 4), [1, 4, 7, 10]);
+ assert.deepEqual(selectEmitterIndices(4, 12), [0, 1, 2, 3]);
+ assert.deepEqual(selectEmitterIndices(0, 4), []);
+
+ const indices = selectEmitterIndices(1000, 12);
+ assert.equal(indices.length, 12);
+ assert.equal(new Set(indices).size, 12);
+ assert.ok(indices.every((index) => index >= 0 && index < 1000));
+});
+
+test('point positions map to fluid UV coordinates using scene bounds', () => {
+ const bounds = { minX: -5, maxX: 5, minY: 0, maxY: 4 };
+ assert.deepEqual(projectPointToUv([0, 2, 1], bounds), { x: 0.5, y: 0.5 });
+ assert.deepEqual(projectPointToUv([-5, 0, 0], bounds), { x: 0, y: 0 });
+ assert.deepEqual(projectPointToUv([5, 4, 0], bounds), { x: 1, y: 1 });
+ assert.equal(projectPointToUv([Number.NaN, 0, 0], bounds), undefined);
+});
+
+test('fluid engine preserves PavelDoGreat default visual parameters and removes pointer input', () => {
+ const source = readFileSync(new URL('../src/fluid.js', import.meta.url), 'utf8');
+
+ for (const expected of [
+ 'SIM_RESOLUTION: 128',
+ 'DYE_RESOLUTION: 1024',
+ 'DENSITY_DISSIPATION: 1',
+ 'VELOCITY_DISSIPATION: 0.2',
+ 'PRESSURE: 0.8',
+ 'PRESSURE_ITERATIONS: 20',
+ 'CURL: 30',
+ 'SPLAT_RADIUS: 0.25',
+ 'SPLAT_FORCE: 6000',
+ 'SHADING: true',
+ 'COLORFUL: true',
+ 'COLOR_UPDATE_SPEED: 10',
+ 'BLOOM: true',
+ 'BLOOM_ITERATIONS: 8',
+ 'BLOOM_RESOLUTION: 256',
+ 'BLOOM_INTENSITY: 0.8',
+ 'BLOOM_THRESHOLD: 0.6',
+ 'BLOOM_SOFT_KNEE: 0.7',
+ 'SUNRAYS: true',
+ 'SUNRAYS_RESOLUTION: 196',
+ 'SUNRAYS_WEIGHT: 1.0'
+ ]) {
+ assert.ok(source.includes(expected), `missing original default: ${expected}`);
+ }
+
+ assert.ok(!source.includes("canvas.addEventListener('mousedown'"));
+ assert.ok(!source.includes("canvas.addEventListener('touchstart'"));
+ assert.ok(source.includes('function applyEmitters (emitters)'));
+});