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
-
-
- - - -
-
- - - -
-
- -
- - -
- -
- - -
+ +

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)')); +});