Created
May 25, 2026 10:01
-
-
Save torgeir/168123bcc4ae7079d907e26e3524d2f4 to your computer and use it in GitHub Desktop.
Perlin noise webgl2
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <title>Perlin noise flow field — GPU edition</title> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <link rel="shortcut icon" type="image/x-icon" href="data:image/x-icon;,"> | |
| <style> | |
| html, body { width: 100%; height: 100%; padding: 0; margin: 0; background: #0a0a0a; overflow: hidden; | |
| font-family: ui-monospace, "JetBrains Mono", "SF Mono", Menlo, monospace; color: #cfcfd6; } | |
| canvas { display: block; width: 100%; height: 100%; } | |
| /* control overlay — small, glass, gets out of the way */ | |
| .panel { | |
| position: fixed; top: 100px; right: 14px; | |
| background: rgba(12, 12, 14, 0.72); backdrop-filter: blur(8px); | |
| border: 1px solid rgba(255,255,255,0.08); border-radius: 6px; | |
| padding: 10px 12px; font-size: 11px; line-height: 1.6; | |
| user-select: none; -webkit-user-select: none; | |
| } | |
| .panel h1 { margin: 0 0 6px 0; font-size: 11px; font-weight: 500; letter-spacing: 0.08em; | |
| text-transform: uppercase; color: #8a8aa0; | |
| cursor: grab; user-select: none; -webkit-user-select: none; | |
| touch-action: none; /* don't let the browser hijack drags on touch */ | |
| padding: 2px 0; } | |
| .panel h1::before { content: "⠿"; margin-right: 6px; color: #4a4a5a; | |
| font-size: 13px; letter-spacing: 0; } | |
| .panel.dragging { transition: none; } | |
| .panel.dragging h1 { cursor: grabbing; } | |
| .row { display: flex; align-items: center; gap: 8px; } | |
| .row label { width: 78px; color: #9595aa; } | |
| .row input[type=range] { width: 130px; accent-color: #a560fb; } | |
| .row .v { width: 56px; text-align: right; color: #c0a0ff; font-variant-numeric: tabular-nums; } | |
| .section { margin: 8px 0 4px; padding-top: 6px; | |
| border-top: 1px solid rgba(255,255,255,0.06); | |
| color: #6a6a7a; text-transform: uppercase; letter-spacing: 0.08em; | |
| font-size: 10px; } | |
| .swatches { display: flex; gap: 3px; height: 12px; margin: 4px 0 8px; } | |
| .swatches .sw { flex: 1 1 0%; border-radius: 2px; | |
| box-shadow: inset 0 0 0 1px rgba(255,255,255,0.04); } | |
| .panel.collapsed { padding-bottom: 8px; } | |
| .panel.collapsed > :not(h1) { display: none; } | |
| .panel.collapsed h1 { margin-bottom: 0; } | |
| .panel button { | |
| background: rgba(165, 96, 251, 0.12); color: #d8c0ff; | |
| border: 1px solid rgba(165, 96, 251, 0.35); border-radius: 4px; | |
| padding: 4px 10px; font: inherit; cursor: pointer; margin-right: 6px; | |
| } | |
| .panel button:hover { background: rgba(165, 96, 251, 0.22); } | |
| .stats { position: fixed; top: 14px; right: 14px; | |
| background: rgba(12, 12, 14, 0.72); backdrop-filter: blur(8px); | |
| border: 1px solid rgba(255,255,255,0.08); border-radius: 6px; | |
| padding: 8px 12px; font-size: 11px; color: #8a8aa0; | |
| font-variant-numeric: tabular-nums; | |
| cursor: pointer; user-select: none; -webkit-user-select: none; | |
| transition: padding 0.18s ease, border-color 0.18s ease; } | |
| .stats:hover { border-color: rgba(255,255,255,0.18); } | |
| .stats b { color: #c0a0ff; font-weight: 500; } | |
| .stats .dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; | |
| background: #a560fb; box-shadow: 0 0 8px rgba(165,96,251,0.55); | |
| vertical-align: middle; margin-right: 8px; | |
| animation: pulse 2.2s ease-in-out infinite; } | |
| .stats.collapsed { padding: 6px 8px; } | |
| .stats.collapsed #statsBody { display: none; } | |
| .stats.collapsed .dot { margin-right: 0; } | |
| @keyframes pulse { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } } | |
| .corner-btns { position: fixed; bottom: 14px; right: 14px; | |
| display: flex; gap: 6px; } | |
| .icon-btn { background: rgba(12, 12, 14, 0.72); backdrop-filter: blur(8px); | |
| border: 1px solid rgba(255,255,255,0.08); border-radius: 6px; | |
| width: 32px; height: 32px; padding: 0; | |
| color: #8a8aa0; cursor: pointer; | |
| display: flex; align-items: center; justify-content: center; | |
| transition: color 0.15s ease, border-color 0.15s ease; } | |
| .icon-btn:hover { color: #c0a0ff; border-color: rgba(255,255,255,0.18); } | |
| .icon-btn:active { color: #fff; } | |
| .icon-btn svg { display: block; } | |
| /* brief flash to confirm the snapshot was taken */ | |
| .icon-btn.flash { color: #fff; border-color: #c0a0ff; } | |
| .state { margin: 2px; padding: 10px; display: inline-block; | |
| box-shadow: inset 0 0 0 1px rgba(255,255,255,0.04); | |
| border-radius: 2px; } | |
| #err { position: fixed; inset: 0; display: none; align-items: center; justify-content: center; | |
| padding: 40px; text-align: center; color: #ff9090; font-size: 13px; line-height: 1.6; } | |
| </style> | |
| </head> | |
| <body> | |
| <canvas id="sketch"></canvas> | |
| <div class="panel"> | |
| <h1 title="drag to move · click to collapse">perlin flow · gpu</h1> | |
| <div class="row"><label>particles</label> | |
| <input type="range" id="count" min="1" max="8388608" step="10000" value="2000001"> | |
| <span class="v" id="countV"></span> | |
| </div> | |
| <div class="row"><label>noise zoom</label> | |
| <input type="range" id="zoom" min="0.0001" max="1" step="0.0001" value="0.004"> | |
| <span class="v" id="zoomV"></span> | |
| </div> | |
| <div class="row"><label>speed</label> | |
| <input type="range" id="speed" min="0" max="10" step="0.01" value="0.3"> | |
| <span class="v" id="speedV"></span> | |
| </div> | |
| <div class="row"><label>alpha</label> | |
| <input type="range" id="alpha" min="0.0001" max="1" step="0.0005" value="0.008"> | |
| <span class="v" id="alphaV"></span> | |
| </div> | |
| <div class="row"><label>fade</label> | |
| <input type="range" id="fade" min="0" max="0.04" step="0.0001" value="0.0005"> | |
| <span class="v" id="fadeV"></span> | |
| </div> | |
| <div class="row"><label>point size</label> | |
| <input type="range" id="psize" min="1" max="10" step="0.1" value="1.2"> | |
| <span class="v" id="psizeV"></span> | |
| </div> | |
| <div class="section">color</div> | |
| <div class="swatches" id="swatches"></div> | |
| <div class="row"><label>hue</label> | |
| <input type="range" id="hue" min="0" max="360" step="1" value="295"> | |
| <span class="v" id="hueV"></span> | |
| </div> | |
| <div class="row"><label>saturation</label> | |
| <input type="range" id="sat" min="0" max="1.5" step="0.01" value="1.5"> | |
| <span class="v" id="satV"></span> | |
| </div> | |
| <div class="row"><label>brightness</label> | |
| <input type="range" id="bright" min="0" max="1.5" step="0.01" value=".11"> | |
| <span class="v" id="brightV"></span> | |
| </div> | |
| <div class="section">color history</div> | |
| <div class="states" id="states"></div> | |
| <div style="margin-top: 6px;"> | |
| <button id="reset">reset</button> | |
| <button id="reseed">reseed</button> | |
| </div> | |
| </div> | |
| <div class="stats" id="stats" title="click to toggle"><span class="dot"></span><span id="statsBody">booting…</span></div> | |
| <div class="corner-btns"> | |
| <button class="icon-btn" id="save" title="save PNG" aria-label="save PNG"></button> | |
| <button class="icon-btn" id="fs" title="enter fullscreen" aria-label="enter fullscreen"></button> | |
| </div> | |
| <div id="err"></div> | |
| <script> | |
| "use strict"; | |
| /* ---------- 0. setup & sanity checks ---------- */ | |
| const canvas = document.getElementById("sketch"); | |
| const gl = canvas.getContext("webgl2", { antialias: false, alpha: false, | |
| preserveDrawingBuffer: false, | |
| powerPreference: "high-performance" }); | |
| const errBox = document.getElementById("err"); | |
| function die(msg) { errBox.style.display = "flex"; errBox.textContent = msg; throw new Error(msg); } | |
| if (!gl) die("WebGL2 is required. Try a recent Chrome, Firefox, Safari or Edge."); | |
| if (!gl.getExtension("EXT_color_buffer_float")) | |
| die("EXT_color_buffer_float not available — your GPU/driver can't render to float textures."); | |
| gl.getExtension("OES_texture_float_linear"); // optional, nicer fade | |
| /* ---------- 1. shader sources ---------- | |
| * | |
| * QUAD_VS a single full-screen triangle, no attributes needed. | |
| * INIT_FS seeds the position texture with random (x, y, 0, 0). | |
| * UPDATE_FS reads (x, y, vx, vy) from texture A, writes updated state to B. | |
| * This is the entire simulation step. One pixel = one particle. | |
| * POINTS_VS/FS draws all particles as POINTS using gl_VertexID -> texelFetch. | |
| * DISPLAY_FS blits the accumulation texture to the screen. | |
| * FADE_FS multiplies accumulation by (1 - fade) when fade > 0. | |
| * | |
| * The simplex noise (Gustavson) is in NOISE_GLSL and pasted into UPDATE_FS. | |
| * It returns roughly [-1, 1]; we remap to [0, 1] to mirror q/noise. | |
| */ | |
| const QUAD_VS = `#version 300 es | |
| out vec2 v_uv; | |
| void main() { | |
| /* one big triangle covers the screen */ | |
| vec2 p = vec2((gl_VertexID == 1) ? 3.0 : -1.0, | |
| (gl_VertexID == 2) ? 3.0 : -1.0); | |
| v_uv = p * 0.5 + 0.5; | |
| gl_Position = vec4(p, 0.0, 1.0); | |
| } | |
| `; | |
| const NOISE_GLSL = ` | |
| /* simplex 3D noise — Ashima / Stefan Gustavson, MIT license. | |
| returns approximately in [-1, 1] */ | |
| vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; } | |
| vec4 mod289(vec4 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; } | |
| vec4 permute(vec4 x) { return mod289(((x * 34.0) + 1.0) * x); } | |
| vec4 taylorInvSqrt(vec4 r) { return 1.79284291400159 - 0.85373472095314 * r; } | |
| float snoise(vec3 v) { | |
| const vec2 C = vec2(1.0/6.0, 1.0/3.0); | |
| const vec4 D = vec4(0.0, 0.5, 1.0, 2.0); | |
| vec3 i = floor(v + dot(v, C.yyy)); | |
| vec3 x0 = v - i + dot(i, C.xxx); | |
| vec3 g = step(x0.yzx, x0.xyz); | |
| vec3 l = 1.0 - g; | |
| vec3 i1 = min(g.xyz, l.zxy); | |
| vec3 i2 = max(g.xyz, l.zxy); | |
| vec3 x1 = x0 - i1 + C.xxx; | |
| vec3 x2 = x0 - i2 + C.yyy; | |
| vec3 x3 = x0 - D.yyy; | |
| i = mod289(i); | |
| vec4 p = permute(permute(permute( | |
| i.z + vec4(0.0, i1.z, i2.z, 1.0)) | |
| + i.y + vec4(0.0, i1.y, i2.y, 1.0)) | |
| + i.x + vec4(0.0, i1.x, i2.x, 1.0)); | |
| float n_ = 0.142857142857; | |
| vec3 ns = n_ * D.wyz - D.xzx; | |
| vec4 j = p - 49.0 * floor(p * ns.z * ns.z); | |
| vec4 x_ = floor(j * ns.z); | |
| vec4 y_ = floor(j - 7.0 * x_); | |
| vec4 x = x_ * ns.x + ns.yyyy; | |
| vec4 y = y_ * ns.x + ns.yyyy; | |
| vec4 h = 1.0 - abs(x) - abs(y); | |
| vec4 b0 = vec4(x.xy, y.xy); | |
| vec4 b1 = vec4(x.zw, y.zw); | |
| vec4 s0 = floor(b0) * 2.0 + 1.0; | |
| vec4 s1 = floor(b1) * 2.0 + 1.0; | |
| vec4 sh = -step(h, vec4(0.0)); | |
| vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy; | |
| vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww; | |
| vec3 p0 = vec3(a0.xy, h.x); | |
| vec3 p1 = vec3(a0.zw, h.y); | |
| vec3 p2 = vec3(a1.xy, h.z); | |
| vec3 p3 = vec3(a1.zw, h.w); | |
| vec4 norm = taylorInvSqrt(vec4(dot(p0,p0), dot(p1,p1), dot(p2,p2), dot(p3,p3))); | |
| p0 *= norm.x; p1 *= norm.y; p2 *= norm.z; p3 *= norm.w; | |
| vec4 m = max(0.6 - vec4(dot(x0,x0), dot(x1,x1), dot(x2,x2), dot(x3,x3)), 0.0); | |
| m = m * m; | |
| return 42.0 * dot(m * m, vec4(dot(p0,x0), dot(p1,x1), dot(p2,x2), dot(p3,x3))); | |
| } | |
| `; | |
| const INIT_FS = `#version 300 es | |
| precision highp float; | |
| in vec2 v_uv; | |
| uniform vec2 u_resolution; | |
| uniform float u_seed; | |
| out vec4 outData; | |
| /* cheap hash — only used once to scatter initial positions */ | |
| float hash(vec2 p) { | |
| p = fract(p * vec2(123.34, 456.21)); | |
| p += dot(p, p + 45.32); | |
| return fract(p.x * p.y); | |
| } | |
| void main() { | |
| float rx = hash(v_uv + u_seed); | |
| float ry = hash(v_uv + u_seed + 13.7); | |
| outData = vec4(rx * u_resolution.x, ry * u_resolution.y, 0.0, 0.0); | |
| } | |
| `; | |
| const UPDATE_FS = `#version 300 es | |
| precision highp float; | |
| in vec2 v_uv; | |
| uniform sampler2D u_positions; // (x, y, vx, vy) | |
| uniform vec2 u_resolution; // canvas dimensions in pixels | |
| uniform float u_noiseZoom; // your noise-zoom from the post | |
| uniform float u_idScale; // per-particle z offset in noise space | |
| uniform float u_speed; // velocity multiplier when stepping position | |
| out vec4 outData; | |
| ${NOISE_GLSL} | |
| void main() { | |
| /* same texel-fetch logic as q/noise sampling, just parallel */ | |
| ivec2 texSize = textureSize(u_positions, 0); | |
| ivec2 coord = ivec2(v_uv * vec2(texSize)); | |
| vec4 data = texelFetch(u_positions, coord, 0); | |
| vec2 pos = data.xy; | |
| vec2 vel = data.zw; | |
| /* unique-ish id per particle, used to make each one's flow slightly different */ | |
| float id = float(coord.x + coord.y * texSize.x); | |
| /* two octaves of noise — global field + per-particle wobble. | |
| remapped to [0, 1] to match Quil's q/noise */ | |
| float n1 = 0.5 + 0.5 * snoise(vec3(pos.x * u_noiseZoom, pos.y * u_noiseZoom, 0.0)); | |
| float n2 = 0.5 + 0.5 * snoise(vec3(pos.x * u_noiseZoom, pos.y * u_noiseZoom, id * u_idScale)); | |
| float dir = 2.0 * 3.14159265 * (n1 + 0.2 * n2); | |
| /* average current velocity with new heading, exactly like the post */ | |
| vec2 newVel = (vel + vec2(cos(dir), sin(dir))) * 0.5; | |
| /* wrap at screen edges */ | |
| vec2 newPos = mod(pos + newVel * u_speed + u_resolution, u_resolution); | |
| outData = vec4(newPos, newVel); | |
| } | |
| `; | |
| const POINTS_VS = `#version 300 es | |
| uniform sampler2D u_positions; | |
| uniform vec2 u_resolution; | |
| uniform ivec2 u_texSize; | |
| uniform float u_pointSize; | |
| uniform float u_hueShift; // [0, 1], wrap-around hue rotation | |
| uniform float u_satMul; // multiplier on HSV saturation | |
| uniform float u_brightMul; // linear RGB multiplier (post-conversion) | |
| out vec3 v_color; | |
| /* purple haze, straight from the post */ | |
| const vec3 palette[7] = vec3[]( | |
| vec3( 32.0, 0.0, 40.0) / 255.0, | |
| vec3( 82.0, 15.0, 125.0) / 255.0, | |
| vec3( 99.0, 53.0, 126.0) / 255.0, | |
| vec3(102.0, 10.0, 150.0) / 255.0, | |
| vec3(132.0, 26.0, 200.0) / 255.0, | |
| vec3(165.0, 32.0, 250.0) / 255.0, | |
| vec3(196.0, 106.0, 251.0) / 255.0 | |
| ); | |
| /* RGB <-> HSV, Sam Hocevar's classic branchless version */ | |
| vec3 rgb2hsv(vec3 c) { | |
| vec4 K = vec4(0.0, -1.0/3.0, 2.0/3.0, -1.0); | |
| vec4 p = mix(vec4(c.bg, K.wz), vec4(c.gb, K.xy), step(c.b, c.g)); | |
| vec4 q = mix(vec4(p.xyw, c.r), vec4(c.r, p.yzx), step(p.x, c.r)); | |
| float d = q.x - min(q.w, q.y); | |
| float e = 1.0e-10; | |
| return vec3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x); | |
| } | |
| vec3 hsv2rgb(vec3 c) { | |
| vec4 K = vec4(1.0, 2.0/3.0, 1.0/3.0, 3.0); | |
| vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www); | |
| return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y); | |
| } | |
| void main() { | |
| /* no attribute buffer — vertex id IS the particle id. | |
| reconstruct texture coord, fetch position. */ | |
| int id = gl_VertexID; | |
| ivec2 coord = ivec2(id % u_texSize.x, id / u_texSize.x); | |
| vec2 pos = texelFetch(u_positions, coord, 0).xy; | |
| /* pixel -> clip space, flipped y to match top-left origin */ | |
| vec2 clip = (pos / u_resolution) * 2.0 - 1.0; | |
| clip.y = -clip.y; | |
| gl_Position = vec4(clip, 0.0, 1.0); | |
| gl_PointSize = u_pointSize; | |
| /* palette pick, then hue rotate + saturation in HSV, then linear brightness */ | |
| vec3 base = palette[id % 7]; | |
| vec3 hsv = rgb2hsv(base); | |
| hsv.x = fract(hsv.x + u_hueShift); | |
| hsv.y = clamp(hsv.y * u_satMul, 0.0, 1.0); | |
| v_color = hsv2rgb(hsv) * u_brightMul; | |
| } | |
| `; | |
| const POINTS_FS = `#version 300 es | |
| precision highp float; | |
| in vec3 v_color; | |
| uniform float u_alpha; | |
| out vec4 outColor; | |
| void main() { | |
| /* premultiplied so additive blending lands cleanly in the float buffer */ | |
| outColor = vec4(v_color * u_alpha, u_alpha); | |
| } | |
| `; | |
| const DISPLAY_FS = `#version 300 es | |
| precision highp float; | |
| in vec2 v_uv; | |
| uniform sampler2D u_tex; | |
| out vec4 outColor; | |
| void main() { | |
| vec3 c = texture(u_tex, v_uv).rgb; | |
| /* mild tone curve to keep highlights from blowing out */ | |
| c = c / (1.0 + c); | |
| c = pow(c, vec3(0.85)); | |
| outColor = vec4(c, 1.0); | |
| } | |
| `; | |
| const FADE_FS = `#version 300 es | |
| precision highp float; | |
| in vec2 v_uv; | |
| uniform sampler2D u_tex; | |
| uniform float u_fade; | |
| out vec4 outColor; | |
| void main() { | |
| outColor = vec4(texture(u_tex, v_uv).rgb * (1.0 - u_fade), 1.0); | |
| } | |
| `; | |
| /* ---------- 2. tiny GL helper layer ---------- */ | |
| function compile(type, src) { | |
| const s = gl.createShader(type); | |
| gl.shaderSource(s, src); | |
| gl.compileShader(s); | |
| if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) { | |
| const log = gl.getShaderInfoLog(s); | |
| console.error(src); | |
| die("Shader compile failed:\n" + log); | |
| } | |
| return s; | |
| } | |
| function link(vsSrc, fsSrc) { | |
| const p = gl.createProgram(); | |
| gl.attachShader(p, compile(gl.VERTEX_SHADER, vsSrc)); | |
| gl.attachShader(p, compile(gl.FRAGMENT_SHADER, fsSrc)); | |
| gl.linkProgram(p); | |
| if (!gl.getProgramParameter(p, gl.LINK_STATUS)) die("Program link failed:\n" + gl.getProgramInfoLog(p)); | |
| /* cache uniform locations */ | |
| p.u = {}; | |
| const n = gl.getProgramParameter(p, gl.ACTIVE_UNIFORMS); | |
| for (let i = 0; i < n; i++) { | |
| const info = gl.getActiveUniform(p, i); | |
| p.u[info.name] = gl.getUniformLocation(p, info.name); | |
| } | |
| return p; | |
| } | |
| const progInit = link(QUAD_VS, INIT_FS); | |
| const progUpdate = link(QUAD_VS, UPDATE_FS); | |
| const progPoints = link(POINTS_VS, POINTS_FS); | |
| const progDisplay = link(QUAD_VS, DISPLAY_FS); | |
| const progFade = link(QUAD_VS, FADE_FS); | |
| /* ---------- 3. textures & framebuffer ---------- */ | |
| function makeDataTex(w, h) { | |
| const t = gl.createTexture(); | |
| gl.bindTexture(gl.TEXTURE_2D, t); | |
| gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, w, h, 0, gl.RGBA, gl.FLOAT, null); | |
| 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); | |
| return t; | |
| } | |
| function makeAccumTex(w, h) { | |
| const t = gl.createTexture(); | |
| gl.bindTexture(gl.TEXTURE_2D, t); | |
| gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16F, w, h, 0, gl.RGBA, gl.HALF_FLOAT, null); | |
| 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.CLAMP_TO_EDGE); | |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); | |
| return t; | |
| } | |
| const fbo = gl.createFramebuffer(); | |
| const emptyVAO = gl.createVertexArray(); | |
| /* Position texture is non-square: 4096 × 2048 = 8,388,608 max particles. | |
| Active particle count is driven by the slider via drawArrays — see the | |
| points pass below. The simulation pass always processes the full texture | |
| so hidden particles keep moving consistently; revealing them looks smooth. */ | |
| const _maxTex = gl.getParameter(gl.MAX_TEXTURE_SIZE); | |
| const TEX_W = Math.min(_maxTex, 4096); | |
| const TEX_H = Math.min(_maxTex, 2048); | |
| const TEX_MAX = TEX_W * TEX_H; | |
| let posA, posB; // ping-pong position state | |
| let accumA, accumB, accumW = 0, accumH = 0; | |
| function rebuildPositionTextures() { | |
| if (posA) gl.deleteTexture(posA); | |
| if (posB) gl.deleteTexture(posB); | |
| posA = makeDataTex(TEX_W, TEX_H); | |
| posB = makeDataTex(TEX_W, TEX_H); | |
| } | |
| function rebuildAccumTextures(w, h, preserve) { | |
| const newA = makeAccumTex(w, h); | |
| const newB = makeAccumTex(w, h); | |
| gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); | |
| if (preserve && accumA && accumW > 0 && accumH > 0) { | |
| /* blit old accumA → newA so trails carry through resize. | |
| LINEAR mag filter on accum tex stretches gracefully when sizes differ. | |
| Reuse progFade with u_fade=0 — that's just a pass-through copy. */ | |
| gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, newA, 0); | |
| gl.viewport(0, 0, w, h); | |
| gl.useProgram(progFade); | |
| gl.activeTexture(gl.TEXTURE0); | |
| gl.bindTexture(gl.TEXTURE_2D, accumA); | |
| gl.uniform1i(progFade.u["u_tex"], 0); | |
| gl.uniform1f(progFade.u["u_fade"], 0.0); | |
| gl.disable(gl.BLEND); | |
| gl.bindVertexArray(emptyVAO); | |
| gl.drawArrays(gl.TRIANGLES, 0, 3); | |
| /* newB will be written next frame if fade>0; clearing it to bg | |
| avoids one frame of garbage if fade flips on right after a resize */ | |
| gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, newB, 0); | |
| gl.clearColor(10/255, 10/255, 10/255, 1.0); | |
| gl.clear(gl.COLOR_BUFFER_BIT); | |
| } else { | |
| /* fresh start — paint both with the palette's background color */ | |
| for (const t of [newA, newB]) { | |
| gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, t, 0); | |
| gl.viewport(0, 0, w, h); | |
| gl.clearColor(10/255, 10/255, 10/255, 1.0); | |
| gl.clear(gl.COLOR_BUFFER_BIT); | |
| } | |
| } | |
| if (accumA) gl.deleteTexture(accumA); | |
| if (accumB) gl.deleteTexture(accumB); | |
| accumA = newA; accumB = newB; | |
| accumW = w; accumH = h; | |
| } | |
| /* ---------- 4. passes ---------- */ | |
| function pass(prog, target, w, h, configure) { | |
| gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); | |
| gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, target, 0); | |
| gl.viewport(0, 0, w, h); | |
| gl.useProgram(prog); | |
| gl.bindVertexArray(emptyVAO); | |
| if (configure) configure(); | |
| gl.disable(gl.BLEND); | |
| gl.drawArrays(gl.TRIANGLES, 0, 3); | |
| } | |
| function initPositions(seed) { | |
| pass(progInit, posA, TEX_W, TEX_H, () => { | |
| gl.uniform2f(progInit.u["u_resolution"], canvas.width, canvas.height); | |
| gl.uniform1f(progInit.u["u_seed"], seed); | |
| }); | |
| } | |
| function clearAccum() { rebuildAccumTextures(accumW, accumH, false); } | |
| /* ---------- 5. resize + UI ---------- */ | |
| function resize() { | |
| const dpr = Math.min(window.devicePixelRatio || 1, 2); | |
| const w = Math.max(1, Math.floor(canvas.clientWidth * dpr)); | |
| const h = Math.max(1, Math.floor(canvas.clientHeight * dpr)); | |
| if (canvas.width !== w || canvas.height !== h) { | |
| canvas.width = w; canvas.height = h; | |
| /* preserve = true: stretch existing accumulation into the new size so | |
| entering/exiting fullscreen doesn't wipe the artwork. positions stay | |
| intact too — particles' x/y get mod'd by the new u_resolution next | |
| frame, which produces a tiny shift but trails continue from there. */ | |
| rebuildAccumTextures(w, h, true); | |
| return true; | |
| } | |
| return false; | |
| } | |
| const ui = { | |
| count: document.getElementById("count"), countV: document.getElementById("countV"), | |
| zoom: document.getElementById("zoom"), zoomV: document.getElementById("zoomV"), | |
| speed: document.getElementById("speed"), speedV: document.getElementById("speedV"), | |
| alpha: document.getElementById("alpha"), alphaV: document.getElementById("alphaV"), | |
| fade: document.getElementById("fade"), fadeV: document.getElementById("fadeV"), | |
| psize: document.getElementById("psize"), psizeV: document.getElementById("psizeV"), | |
| hue: document.getElementById("hue"), hueV: document.getElementById("hueV"), | |
| sat: document.getElementById("sat"), satV: document.getElementById("satV"), | |
| bright: document.getElementById("bright"), brightV: document.getElementById("brightV"), | |
| reset: document.getElementById("reset"), | |
| reseed: document.getElementById("reseed"), | |
| stats: document.getElementById("stats"), | |
| statsBody: document.getElementById("statsBody"), | |
| }; | |
| /* if the GPU's MAX_TEXTURE_SIZE forced us under 4096, the slider's HTML max | |
| of 8388608 is too high — clamp it to what the texture can actually hold */ | |
| ui.count.max = String(TEX_MAX); | |
| if (parseFloat(ui.count.value) > TEX_MAX) ui.count.value = String(TEX_MAX); | |
| /* readable counts: 1234567 -> "1.23M", 50000 -> "50K", 800 -> "800" */ | |
| function fmtCount(n) { | |
| if (n >= 1e6) return (n / 1e6).toFixed(2) + "M"; | |
| if (n >= 1e3) return Math.round(n / 1e3) + "K"; | |
| return String(n); | |
| } | |
| const debouncedUpdateSwatches = debounce(updateUiSwatches, 10); | |
| const state = { | |
| hue: ui.hue.value, | |
| sat: ui.sat.value, | |
| bright: ui.bright.value | |
| }; | |
| function syncLabels() { | |
| ui.countV.textContent = fmtCount(+ui.count.value); | |
| ui.zoomV.textContent = (+ui.zoom.value).toFixed(4); | |
| ui.speedV.textContent = (+ui.speed.value).toFixed(2) + "×"; | |
| ui.alphaV.textContent = (+ui.alpha.value).toFixed(3); | |
| ui.fadeV.textContent = (+ui.fade.value).toFixed(4); | |
| ui.psizeV.textContent = (+ui.psize.value).toFixed(1); | |
| ui.hueV.textContent = (+ui.hue.value).toFixed(0) + "°"; | |
| ui.satV.textContent = (+ui.sat.value).toFixed(2); | |
| ui.brightV.textContent = (+ui.bright.value).toFixed(2); | |
| debouncedUpdateSwatches(); | |
| } | |
| let mouseDown = false; | |
| function trackMouseDown(el) { | |
| el.addEventListener("mousedown", () => (mouseDown = true)); | |
| el.addEventListener("mouseup", () => (mouseDown = false)); | |
| } | |
| const debouncedUpdateState = debounce(updateState, 200); | |
| function updateState(e) { | |
| if (mouseDown) { | |
| return debouncedUpdateState(); | |
| } | |
| const hue = +ui.hue.value; | |
| const sat = +ui.sat.value; | |
| const bright = +ui.bright.value; | |
| state.hue = hue | |
| state.sat = sat | |
| state.bright = bright | |
| debouncedAddState({ hue, sat, bright }); | |
| syncLabels() | |
| } | |
| ["input", "change"].forEach(e => { | |
| ui.count.addEventListener(e, syncLabels); | |
| ui.zoom.addEventListener(e, syncLabels); | |
| ui.speed.addEventListener(e, syncLabels); | |
| ui.alpha.addEventListener(e, syncLabels); | |
| ui.fade.addEventListener(e, syncLabels); | |
| ui.psize.addEventListener(e, syncLabels); | |
| ui.hue.addEventListener(e, debouncedUpdateState); | |
| ui.sat.addEventListener(e, debouncedUpdateState); | |
| ui.bright.addEventListener(e, debouncedUpdateState); | |
| ui.hue.addEventListener(e, syncLabels); | |
| ui.sat.addEventListener(e, syncLabels); | |
| ui.bright.addEventListener(e, syncLabels); | |
| trackMouseDown(ui.hue) | |
| trackMouseDown(ui.sat) | |
| trackMouseDown(ui.bright) | |
| }); | |
| function debounce(fn, ms) { | |
| let t = null; | |
| return (...args) => { | |
| if (t != null) { | |
| clearTimeout(t) | |
| t = null; | |
| } | |
| t = setTimeout(() => fn(...args), ms) | |
| }; | |
| } | |
| /* ---- color swatches: mirror the shader's HSV transform so users see the | |
| actual particle colors they're dialing in ---- */ | |
| const PALETTE = [ | |
| [ 32, 0, 40], [ 82, 15, 125], [ 99, 53, 126], [102, 10, 150], | |
| [132, 26, 200], [165, 32, 250], [196, 106, 251], | |
| ]; | |
| function rgb2hsv(r, g, b) { | |
| const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn; | |
| let h = 0; | |
| if (d !== 0) { | |
| if (mx === r) h = ((g - b) / d + 6) % 6; | |
| else if (mx === g) h = (b - r) / d + 2; | |
| else h = (r - g) / d + 4; | |
| h /= 6; | |
| } | |
| return [h, mx === 0 ? 0 : d / mx, mx]; | |
| } | |
| function hsv2rgb(h, s, v) { | |
| const f = n => { const k = (n + h * 6) % 6; return v - v * s * Math.max(0, Math.min(k, 4 - k, 1)); }; | |
| return [f(5), f(3), f(1)]; | |
| } | |
| /* build the 7 swatch <divs> once */ | |
| const swatchesEl = document.getElementById("swatches"); | |
| const swatchEls = PALETTE.map(() => { | |
| const d = document.createElement("div"); d.className = "sw"; | |
| swatchesEl.appendChild(d); return d; | |
| }); | |
| function updateUiSwatches() { | |
| const hueShift = (+ui.hue.value) / 360; | |
| const satMul = +ui.sat.value; | |
| const brtMul = +ui.bright.value; | |
| PALETTE.forEach((rgb, i) => { | |
| let [h, s, v] = rgb2hsv(rgb[0]/255, rgb[1]/255, rgb[2]/255); | |
| h = (h + hueShift) % 1; | |
| s = Math.min(1, Math.max(0, s * satMul)); | |
| let [r, g, b] = hsv2rgb(h, s, v); | |
| r = Math.min(255, Math.max(0, r * brtMul * 255)); | |
| g = Math.min(255, Math.max(0, g * brtMul * 255)); | |
| b = Math.min(255, Math.max(0, b * brtMul * 255)); | |
| swatchEls[i].style.backgroundColor = `rgb(${r|0}, ${g|0}, ${b|0})`; | |
| }); | |
| } | |
| syncLabels(); | |
| /* color history */ | |
| const maxStates = 10 | |
| const states = [] | |
| const statesEl = document.getElementById("states"); | |
| let clickTimer; | |
| statesEl.addEventListener("click", (e) => { | |
| const el = e.target.closest(".state"); | |
| if (!el || !statesEl.contains(el)) return; | |
| clearTimeout(clickTimer); | |
| clickTimer = setTimeout(() => { | |
| const index = Array.prototype.indexOf.call(statesEl.children, el); | |
| const s = states[index]; | |
| if (!s) return; | |
| state.hue = s.hue; state.sat = s.sat; state.bright = s.bright; | |
| ui.hue.value = s.hue; ui.sat.value = s.sat; ui.bright.value = s.bright; | |
| debouncedUpdateSwatches(); | |
| }, 250); | |
| }); | |
| statesEl.addEventListener("dblclick", (e) => { | |
| const el = e.target.closest(".state"); | |
| if (!el || !statesEl.contains(el)) return; | |
| clearTimeout(clickTimer); | |
| const index = Array.prototype.indexOf.call(statesEl.children, el); | |
| states.splice(index, 1); | |
| statesEl.children[index]?.remove(); | |
| }); | |
| const debouncedAddState = debounce(addState, 200) | |
| function addState(state) { | |
| states.push(state); | |
| (states.length > maxStates && states.shift()); | |
| statesEl.appendChild(stateEl(state)); | |
| if (statesEl.children.length > maxStates) { | |
| statesEl.removeChild(statesEl.firstElementChild); | |
| } | |
| } | |
| function stateEl(state) { | |
| const el = document.createElement("div"); | |
| const base = PALETTE[PALETTE.length - 1] | |
| const hueShift = (+state.hue) / 360; | |
| const satMul = +state.sat; | |
| const brtMul = +state.bright; | |
| const clamp01 = x => Math.max(0, Math.min(1, x)); | |
| let [h, s, v] = rgb2hsv(base[0]/255, base[1]/255, base[2]/255); | |
| h = (h + hueShift) % 1; | |
| s = clamp01(s * satMul); | |
| v = clamp01(v * brtMul); | |
| let [r, g, b] = hsv2rgb(h, s, v); | |
| el.style.backgroundColor = `rgb(${(r*255)|0}, ${(g*255)|0}, ${(b*255)|0})`; | |
| el.className = "state"; | |
| el.textContent = ""; | |
| statesEl.appendChild(el); | |
| return el; | |
| }; | |
| debouncedAddState({hue: state.hue, sat: state.sat, bright: state.bright }) | |
| /* mouse wheel adjusts sliders by one step. | |
| - up = increase, down = decrease (standard value-picker convention) | |
| - shift = 10x step for coarse jumps | |
| - snap to the step grid so we don't accumulate float drift | |
| - dispatch a synthetic 'input' event so syncLabels and the render loop pick it up */ | |
| document.querySelectorAll('.panel input[type=range]').forEach(el => { | |
| el.addEventListener('wheel', (e) => { | |
| e.preventDefault(); | |
| const step = parseFloat(el.step) || 1; | |
| const min = parseFloat(el.min); | |
| const max = parseFloat(el.max); | |
| const mult = e.shiftKey ? 10 : 1; | |
| const dir = e.deltaY < 0 ? 1 : -1; | |
| const next = parseFloat(el.value) + dir * step * mult; | |
| const clamped = Math.min(max, Math.max(min, next)); | |
| /* snap to step grid relative to min, then round to step's decimal count to kill 0.1+0.2-style noise */ | |
| const decimals = (el.step.split('.')[1] || '').length; | |
| const snapped = +(Math.round((clamped - min) / step) * step + min).toFixed(decimals); | |
| if (snapped !== parseFloat(el.value)) { | |
| el.value = snapped; | |
| el.dispatchEvent(new Event('input', { bubbles: true })); | |
| } | |
| }, { passive: false }); | |
| }); | |
| /* drag the panel by its header. uses pointer events so mouse + touch share | |
| the same code path. setPointerCapture means we keep receiving moves even | |
| when the cursor leaves the header element during a fast drag. | |
| if the pointer barely moves between down and up, treat it as a click and | |
| toggle the collapsed state instead — so the same handle does both jobs. */ | |
| (function makeDraggable() { | |
| const panel = document.querySelector('.panel'); | |
| const handle = panel.querySelector('h1'); | |
| let dragging = false, moved = false; | |
| let startX = 0, startY = 0, originX = 0, originY = 0; | |
| const CLICK_SLOP = 4; // px | |
| function clamp(x, y) { | |
| const maxX = window.innerWidth - panel.offsetWidth; | |
| const maxY = window.innerHeight - panel.offsetHeight; | |
| return [Math.max(0, Math.min(maxX, x)), Math.max(0, Math.min(maxY, y))]; | |
| } | |
| handle.addEventListener('pointerdown', (e) => { | |
| dragging = true; moved = false; | |
| const rect = panel.getBoundingClientRect(); | |
| startX = e.clientX; startY = e.clientY; | |
| originX = rect.left; originY = rect.top; | |
| panel.style.left = originX + 'px'; | |
| panel.style.top = originY + 'px'; | |
| panel.style.right = 'auto'; | |
| panel.classList.add('dragging'); | |
| handle.setPointerCapture(e.pointerId); | |
| e.preventDefault(); | |
| }); | |
| handle.addEventListener('pointermove', (e) => { | |
| if (!dragging) return; | |
| const dx = e.clientX - startX, dy = e.clientY - startY; | |
| if (Math.abs(dx) >= CLICK_SLOP || Math.abs(dy) >= CLICK_SLOP) moved = true; | |
| const [x, y] = clamp(originX + dx, originY + dy); | |
| panel.style.left = x + 'px'; | |
| panel.style.top = y + 'px'; | |
| }); | |
| function end() { | |
| if (dragging && !moved) panel.classList.toggle('collapsed'); | |
| dragging = false; | |
| panel.classList.remove('dragging'); | |
| } | |
| handle.addEventListener('pointerup', end); | |
| handle.addEventListener('pointercancel', end); | |
| /* if the viewport shrinks, nudge the panel back into view */ | |
| window.addEventListener('resize', () => { | |
| if (!panel.style.left) return; | |
| const [x, y] = clamp(parseFloat(panel.style.left), parseFloat(panel.style.top)); | |
| panel.style.left = x + 'px'; | |
| panel.style.top = y + 'px'; | |
| }); | |
| })(); | |
| /* collapsible stats. starts expanded; click anywhere on the pill to toggle. */ | |
| ui.stats.addEventListener('click', () => ui.stats.classList.toggle('collapsed')); | |
| /* fullscreen toggle. uses the standard Fullscreen API on the document root. | |
| icon swaps between four-corners-out (expand) and four-corners-in (compress) | |
| based on the actual fullscreen state, so it stays correct even when the | |
| user hits Esc or F11 to leave fullscreen outside our button. */ | |
| (function makeFullscreenButton() { | |
| const btn = document.getElementById('fs'); | |
| const ICON_EXPAND = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9V3h6M21 9V3h-6M3 15v6h6M21 15v6h-6"/></svg>'; | |
| const ICON_COMPRESS = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 3v6H3M15 3v6h6M9 21v-6H3M15 21v-6h6"/></svg>'; | |
| function sync() { | |
| const on = !!document.fullscreenElement; | |
| btn.innerHTML = on ? ICON_COMPRESS : ICON_EXPAND; | |
| btn.title = on ? "exit fullscreen" : "enter fullscreen"; | |
| btn.setAttribute('aria-label', btn.title); | |
| } | |
| btn.addEventListener('click', () => { | |
| if (document.fullscreenElement) document.exitFullscreen().catch(() => {}); | |
| else document.documentElement.requestFullscreen().catch(() => {}); | |
| }); | |
| document.addEventListener('fullscreenchange', sync); | |
| sync(); | |
| })(); | |
| /* save current canvas as PNG. WebGL was created with preserveDrawingBuffer:false | |
| for perf, so the buffer can be cleared after composition. We work around that | |
| by setting a flag here, then calling canvas.toBlob inside the rAF callback | |
| immediately after the display pass — at that moment the buffer is guaranteed | |
| to still hold the just-drawn frame. */ | |
| let captureRequested = false; | |
| (function makeSaveButton() { | |
| const btn = document.getElementById('save'); | |
| btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg>'; | |
| btn.addEventListener('click', () => { | |
| captureRequested = true; | |
| btn.classList.add('flash'); | |
| setTimeout(() => btn.classList.remove('flash'), 220); | |
| }); | |
| })(); | |
| let seed = 666; // same magic number as the post | |
| /* count is now a slider read directly each frame in the points pass — no | |
| handler needed here. Reset and reseed still reinitialize all positions. */ | |
| ui.reset.addEventListener("click", () => { initPositions(seed); clearAccum(); }); | |
| ui.reseed.addEventListener("click", () => { seed = Math.random() * 1000; initPositions(seed); clearAccum(); }); | |
| /* ---------- 6. boot ---------- */ | |
| resize(); | |
| rebuildPositionTextures(); | |
| initPositions(seed); | |
| /* ---------- 7. main loop ---------- */ | |
| let frame = 0, fpsCount = 0, lastFpsT = performance.now(), displayedFps = 0; | |
| function tick() { | |
| resize(); // resize preserves content now — no reset on fullscreen toggle | |
| const noiseZoom = +ui.zoom.value; | |
| const alpha = +ui.alpha.value; | |
| const fade = +ui.fade.value; | |
| const psize = +ui.psize.value; | |
| /* --- A. simulate: posA -> posB --- */ | |
| gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); | |
| gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, posB, 0); | |
| gl.viewport(0, 0, TEX_W, TEX_H); | |
| gl.useProgram(progUpdate); | |
| gl.activeTexture(gl.TEXTURE0); | |
| gl.bindTexture(gl.TEXTURE_2D, posA); | |
| gl.uniform1i(progUpdate.u["u_positions"], 0); | |
| gl.uniform2f(progUpdate.u["u_resolution"], canvas.width, canvas.height); | |
| gl.uniform1f(progUpdate.u["u_noiseZoom"], noiseZoom); | |
| gl.uniform1f(progUpdate.u["u_speed"], +ui.speed.value); | |
| /* keep per-particle variation in a sensible noise range regardless of N */ | |
| gl.uniform1f(progUpdate.u["u_idScale"], noiseZoom / Math.max(1, TEX_MAX / 2000)); | |
| gl.bindVertexArray(emptyVAO); | |
| gl.disable(gl.BLEND); | |
| gl.drawArrays(gl.TRIANGLES, 0, 3); | |
| [posA, posB] = [posB, posA]; | |
| /* --- B. (optional) fade the trail buffer: accumA -> accumB --- */ | |
| if (fade > 0) { | |
| gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); | |
| gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, accumB, 0); | |
| gl.viewport(0, 0, accumW, accumH); | |
| gl.useProgram(progFade); | |
| gl.activeTexture(gl.TEXTURE0); | |
| gl.bindTexture(gl.TEXTURE_2D, accumA); | |
| gl.uniform1i(progFade.u["u_tex"], 0); | |
| gl.uniform1f(progFade.u["u_fade"], fade); | |
| gl.disable(gl.BLEND); | |
| gl.drawArrays(gl.TRIANGLES, 0, 3); | |
| [accumA, accumB] = [accumB, accumA]; | |
| } | |
| /* --- C. draw particles additively into accumA --- */ | |
| gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); | |
| gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, accumA, 0); | |
| gl.viewport(0, 0, accumW, accumH); | |
| gl.useProgram(progPoints); | |
| gl.activeTexture(gl.TEXTURE0); | |
| gl.bindTexture(gl.TEXTURE_2D, posA); | |
| gl.uniform1i(progPoints.u["u_positions"], 0); | |
| gl.uniform2f(progPoints.u["u_resolution"], canvas.width, canvas.height); | |
| gl.uniform2i(progPoints.u["u_texSize"], TEX_W, TEX_H); | |
| gl.uniform1f(progPoints.u["u_pointSize"], psize); | |
| gl.uniform1f(progPoints.u["u_alpha"], alpha); | |
| gl.uniform1f(progPoints.u["u_hueShift"], (+state.hue) / 360.0); | |
| gl.uniform1f(progPoints.u["u_satMul"], +state.sat); | |
| gl.uniform1f(progPoints.u["u_brightMul"], +state.bright); | |
| gl.enable(gl.BLEND); | |
| gl.blendFunc(gl.ONE, gl.ONE); // additive into float buffer | |
| /* the slider says how many particles to render. the simulation pass above | |
| always runs over the full 8.39M-particle texture, so the "hidden" ones | |
| keep moving consistently — sliding the slider up reveals them in place. */ | |
| const activeCount = Math.min(TEX_MAX, parseInt(ui.count.value, 10) || 0); | |
| gl.drawArrays(gl.POINTS, 0, activeCount); | |
| /* --- D. tonemap + present --- */ | |
| gl.bindFramebuffer(gl.FRAMEBUFFER, null); | |
| gl.viewport(0, 0, canvas.width, canvas.height); | |
| gl.useProgram(progDisplay); | |
| gl.activeTexture(gl.TEXTURE0); | |
| gl.bindTexture(gl.TEXTURE_2D, accumA); | |
| gl.uniform1i(progDisplay.u["u_tex"], 0); | |
| gl.disable(gl.BLEND); | |
| gl.drawArrays(gl.TRIANGLES, 0, 3); | |
| /* PNG capture — has to happen between drawing and yielding back to the | |
| browser, otherwise the buffer may already be cleared by the time | |
| toBlob runs. toBlob takes its snapshot synchronously here; the | |
| encoding+download finishes async. */ | |
| if (captureRequested) { | |
| captureRequested = false; | |
| const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); | |
| canvas.toBlob((blob) => { | |
| if (!blob) return; | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = `perlin-flow-${ts}.png`; | |
| document.body.appendChild(a); | |
| a.click(); | |
| a.remove(); | |
| URL.revokeObjectURL(url); | |
| }, 'image/png'); | |
| } | |
| /* fps */ | |
| frame++; fpsCount++; | |
| const now = performance.now(); | |
| if (now - lastFpsT > 500) { | |
| displayedFps = Math.round(fpsCount * 1000 / (now - lastFpsT)); | |
| fpsCount = 0; lastFpsT = now; | |
| const active = parseInt(ui.count.value, 10); | |
| ui.statsBody.innerHTML = `<b>${fmtCount(active)}</b> particles · <b>${displayedFps}</b> fps · ${canvas.width}×${canvas.height}`; | |
| } | |
| requestAnimationFrame(tick); | |
| } | |
| requestAnimationFrame(tick); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment