Skip to content

Instantly share code, notes, and snippets.

@torgeir
Created May 25, 2026 10:01
Show Gist options
  • Select an option

  • Save torgeir/168123bcc4ae7079d907e26e3524d2f4 to your computer and use it in GitHub Desktop.

Select an option

Save torgeir/168123bcc4ae7079d907e26e3524d2f4 to your computer and use it in GitHub Desktop.
Perlin noise webgl2
<!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