diff --git a/demo/particles-sonnet.html b/demo/particles-sonnet.html
new file mode 100644
index 0000000..634a95e
--- /dev/null
+++ b/demo/particles-sonnet.html
@@ -0,0 +1,26 @@
+
+
+
+
+
+ kampos · particles-sonnet
+
+
+
+
+
+
+
diff --git a/demo/particles-sonnet.js b/demo/particles-sonnet.js
new file mode 100644
index 0000000..d30b7c1
--- /dev/null
+++ b/demo/particles-sonnet.js
@@ -0,0 +1,486 @@
+import { Kampos, Ticker, effects } from '../index.js';
+
+// ── Source image dimensions ────────────────────────────────────────────────────
+const IMG_W = 256;
+const IMG_H = 256;
+
+// ── HSL [0,1] → [R,G,B] [0,255] ──────────────────────────────────────────────
+function hsl(h, s, l) {
+ const c = (1 - Math.abs(2 * l - 1)) * s;
+ const x = c * (1 - Math.abs((h * 6) % 2 - 1));
+ const m = l - c / 2;
+ const h6 = h * 6;
+ let r, g, b;
+ if (h6 < 1) { r = c; g = x; b = 0; }
+ else if (h6 < 2) { r = x; g = c; b = 0; }
+ else if (h6 < 3) { r = 0; g = c; b = x; }
+ else if (h6 < 4) { r = 0; g = x; b = c; }
+ else if (h6 < 5) { r = x; g = 0; b = c; }
+ else { r = c; g = 0; b = x; }
+ return [(r + m) * 255 | 0, (g + m) * 255 | 0, (b + m) * 255 | 0];
+}
+
+// ── Plasma mandala + text overlay ─────────────────────────────────────────────
+function buildPlasmaSource() {
+ const cv = document.createElement('canvas');
+ cv.width = IMG_W;
+ cv.height = IMG_H;
+ const ctx = cv.getContext('2d');
+ const id = ctx.createImageData(IMG_W, IMG_H);
+ const d = id.data;
+
+ for (let y = 0; y < IMG_H; y++) {
+ for (let x = 0; x < IMG_W; x++) {
+ const nx = x / IMG_W * 2 - 1;
+ const ny = y / IMG_H * 2 - 1;
+ const r = Math.sqrt(nx * nx + ny * ny);
+ const a = Math.atan2(ny, nx);
+ const v = 0.45 * Math.sin(r * 9.5)
+ + 0.30 * Math.sin(a * 5 + r * 7)
+ + 0.25 * Math.sin(nx * 9 + ny * 6);
+ const hue = ((v + 1) * 0.5 + a / (Math.PI * 2) * 0.4 + 0.5) % 1;
+ const lit = 0.48 + 0.12 * Math.sin(r * 5 + a * 2);
+ const [R, G, B] = hsl(hue, 0.88, Math.max(0.2, Math.min(0.75, lit)));
+ const i = (y * IMG_W + x) * 4;
+ d[i] = R; d[i + 1] = G; d[i + 2] = B; d[i + 3] = 255;
+ }
+ }
+ ctx.putImageData(id, 0, 0);
+
+ ctx.globalCompositeOperation = 'screen';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillStyle = 'rgba(255,255,255,0.78)';
+ ctx.font = 'bold 54px monospace';
+ ctx.fillText('KAM', IMG_W / 2, IMG_H / 2 - 29);
+ ctx.fillText('POS', IMG_W / 2, IMG_H / 2 + 29);
+
+ return cv;
+}
+
+// ── Text on transparent background ────────────────────────────────────────────
+function buildTextSource(text = 'KAMPOS', color = '#ffffff') {
+ const cv = document.createElement('canvas');
+ cv.width = IMG_W;
+ cv.height = IMG_H;
+ const ctx = cv.getContext('2d');
+ ctx.clearRect(0, 0, IMG_W, IMG_H);
+
+ const rawLines = text.split('\n').filter(l => l.trim().length > 0);
+ if (!rawLines.length) return cv;
+
+ const face = s => `bold ${s}px -apple-system, BlinkMacSystemFont, Arial, sans-serif`;
+ let size = 120;
+ while (size > 8) {
+ ctx.font = face(size);
+ const fits = rawLines.every(l => ctx.measureText(l).width <= IMG_W * 0.90);
+ if (fits && rawLines.length * size * 1.2 <= IMG_H * 0.90) break;
+ size -= 2;
+ }
+
+ ctx.font = face(size);
+ ctx.fillStyle = color;
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+
+ const lineH = size * 1.2;
+ const startY = (IMG_H - rawLines.length * lineH) / 2 + lineH / 2;
+ rawLines.forEach((line, i) => ctx.fillText(line, IMG_W / 2, startY + i * lineH));
+
+ return cv;
+}
+
+// ── JS-side easing functions ───────────────────────────────────────────────────
+// The shader receives the already-eased value, so any curve is supported,
+// including elastic overshoot (values slightly outside [0, 1]).
+const EASINGS = {
+ 'smooth': t => t * t * (3 - 2 * t),
+ 'linear': t => t,
+ 'ease-in': t => t * t * t,
+ 'ease-out': t => 1 - Math.pow(1 - t, 3),
+ 'ease-in-out': t => t < 0.5 ? 4*t*t*t : 1 - Math.pow(-2*t + 2, 3) / 2,
+ 'sine': t => -(Math.cos(Math.PI * t) - 1) / 2,
+ 'elastic': t => {
+ if (t <= 0) return 0;
+ if (t >= 1) return 1;
+ const c5 = (2 * Math.PI) / 4.5;
+ return t < 0.5
+ ? -(Math.pow(2, 20 * t - 10) * Math.sin((20 * t - 11.125) * c5)) / 2
+ : (Math.pow(2, -20 * t + 10) * Math.sin((20 * t - 11.125) * c5)) / 2 + 1;
+ },
+ 'bounce': t => {
+ const n = 7.5625, d = 2.75;
+ if (t < 1 / d) { return n * t * t; }
+ if (t < 2 / d) { t -= 1.5 / d; return n * t * t + 0.75; }
+ if (t < 2.5 / d) { t -= 2.25 / d; return n * t * t + 0.9375; }
+ t -= 2.625 / d; return n * t * t + 0.984375;
+ },
+};
+
+// ── Animation state ────────────────────────────────────────────────────────────
+let ANIM = 4.5; // seconds for one-way trip
+let HOLD = 2.0; // seconds to hold each extreme
+let rawT = 0; // linear progress [0, 1]
+let windT = 0; // wind time (continuously advancing)
+let phase = 0; // 0 assembling | 1 assembled | 2 dispersing | 3 dispersed
+let phElap = 0; // elapsed time in current phase
+let lastTime = null;
+
+let curEasing = 'smooth';
+let sourceMode = 'plasma';
+let textDebounce = null;
+let currentSize = 256; // active grid size
+
+// ── Particle presets ────────────────────────────────────────────────────────────
+const GRID_PRESETS = [64, 128, 192, 256, 384, 512, 768, 1024];
+
+// ── DOM ────────────────────────────────────────────────────────────────────────
+const canvas = document.querySelector('#target');
+
+// ── Source canvas ─────────────────────────────────────────────────────────────
+let sourceCanvas = buildPlasmaSource();
+
+// ── Effect & Kampos ───────────────────────────────────────────────────────────
+const ticker = new Ticker();
+
+const MAX_GRID_SIZE = GRID_PRESETS[GRID_PRESETS.length - 1]; // 1024
+
+const effect = effects.particlesSonnet({
+ gridSize: currentSize,
+ maxGridSize: MAX_GRID_SIZE,
+ source: sourceCanvas,
+ spread: 1.8,
+ windStr: 0.30,
+});
+
+const kampos = new Kampos({
+ target: canvas,
+ effects: [effect],
+ noSource: true,
+ ticker,
+ beforeDraw: (timeMs) => {
+ const now = timeMs * 0.001;
+
+ // First-frame guard
+ if (lastTime === null) { lastTime = now; }
+ const dt = Math.min(now - lastTime, 0.1);
+ lastTime = now;
+
+ // Resize canvas to fill its CSS layout
+ const w = canvas.clientWidth | 0;
+ const h = canvas.clientHeight | 0;
+ if (canvas.width !== w || canvas.height !== h) {
+ canvas.width = w;
+ canvas.height = h;
+ gl.viewport(0, 0, w, h);
+ }
+
+ // Advance animation phase
+ windT += dt;
+ phElap += dt;
+
+ if (phase === 0) {
+ rawT = Math.min(phElap / ANIM, 1);
+ if (rawT >= 1) { phase = 1; phElap = 0; }
+ } else if (phase === 1) {
+ rawT = 1;
+ if (phElap >= HOLD) { phase = 2; phElap = 0; }
+ } else if (phase === 2) {
+ rawT = Math.max(0, 1 - phElap / ANIM);
+ if (rawT <= 0) { phase = 3; phElap = 0; }
+ } else {
+ rawT = 0;
+ if (phElap >= HOLD) { phase = 0; phElap = 0; }
+ }
+
+ // Push uniforms
+ effect.t = EASINGS[curEasing](rawT);
+ effect.time = windT;
+ effect.canvasSize = { width: canvas.width, height: canvas.height };
+ effect.pointSize = Math.max(1.0, canvas.width / currentSize);
+
+ gl.clearColor(0.036, 0.036, 0.07, 1.0);
+ gl.clear(gl.COLOR_BUFFER_BIT);
+ },
+ afterDraw: () => {
+ // Prevent re-uploading the source texture on every frame
+ effect.textures[1].update = false;
+ },
+});
+
+const { gl } = kampos;
+
+// Verify vertex texture fetch capability (needed for the float data texture)
+if (gl.getParameter(gl.MAX_VERTEX_TEXTURE_IMAGE_UNITS) < 1) {
+ kampos.destroy();
+ document.body.innerHTML = ''
+ + 'particles-sonnet requires vertex texture fetch support.
';
+ throw new Error('particles-sonnet: MAX_VERTEX_TEXTURE_IMAGE_UNITS < 1');
+}
+
+gl.disable(gl.DEPTH_TEST);
+gl.enable(gl.BLEND);
+gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
+
+// ── Replay helper ─────────────────────────────────────────────────────────────
+function replay() {
+ rawT = 0; windT = 0; phase = 0; phElap = 0; lastTime = null;
+}
+
+// ── Source update helpers ─────────────────────────────────────────────────────
+function applySource(cv) {
+ sourceCanvas = cv;
+ effect.source = cv; // setter marks textures[1].update = true
+}
+
+function rebuildSource() {
+ if (sourceMode === 'text') {
+ applySource(buildTextSource(
+ (document.getElementById('psn-text-input').value || 'KAMPOS').trim(),
+ document.getElementById('psn-color-input').value,
+ ));
+ } else {
+ applySource(buildPlasmaSource());
+ }
+ replay();
+}
+
+// ── Grid size change ──────────────────────────────────────────────────────────
+function applyGridSize(newSize) {
+ currentSize = newSize;
+ effect.rebuild(gl, newSize);
+ effect.pointSize = Math.max(1.0, canvas.width / newSize);
+ replay();
+}
+
+// ── Overlay UI ────────────────────────────────────────────────────────────────
+const overlay = document.createElement('div');
+Object.assign(overlay.style, {
+ position: 'fixed',
+ inset: '14px 14px auto auto',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '8px',
+ zIndex: '10',
+ fontFamily: 'system-ui, sans-serif',
+ fontSize: '12px',
+ color: '#d8e0f0',
+});
+document.body.appendChild(overlay);
+
+// source preview canvas
+const preview = document.createElement('canvas');
+preview.width = 96;
+preview.height = 96;
+Object.assign(preview.style, {
+ width: '96px',
+ height: '96px',
+ border: '1px solid rgba(255,255,255,0.12)',
+ borderRadius: '8px',
+ imageRendering: 'pixelated',
+ alignSelf: 'flex-end',
+});
+overlay.appendChild(preview);
+
+function updatePreview() {
+ const ctx = preview.getContext('2d');
+ ctx.clearRect(0, 0, 96, 96);
+ ctx.drawImage(sourceCanvas, 0, 0, 96, 96);
+}
+updatePreview();
+
+function makeRow(label, ...children) {
+ const row = document.createElement('div');
+ Object.assign(row.style, {
+ display: 'flex',
+ alignItems: 'center',
+ gap: '6px',
+ background: 'rgba(6,10,22,0.76)',
+ border: '1px solid rgba(255,255,255,0.07)',
+ borderRadius: '8px',
+ padding: '6px 10px',
+ backdropFilter: 'blur(6px)',
+ });
+ if (label) {
+ const lbl = document.createElement('span');
+ lbl.textContent = label;
+ Object.assign(lbl.style, { opacity: '0.5', minWidth: '58px' });
+ row.appendChild(lbl);
+ }
+ children.forEach(c => row.appendChild(c));
+ return row;
+}
+
+function makeSegBtn(text, active = false) {
+ const b = document.createElement('button');
+ b.textContent = text;
+ Object.assign(b.style, {
+ padding: '3px 9px',
+ border: '1px solid rgba(255,255,255,0.13)',
+ borderRadius: '5px',
+ background: active ? 'rgba(120,160,255,0.22)' : 'transparent',
+ color: active ? '#c8d8ff' : '#8898bb',
+ cursor: 'pointer',
+ fontSize: '11px',
+ });
+ return b;
+}
+
+function makeRange(min, max, step, value) {
+ const r = document.createElement('input');
+ r.type = 'range';
+ Object.assign(r, { min, max, step, value });
+ r.style.width = '90px';
+ return r;
+}
+
+function makeValSpan(text) {
+ const s = document.createElement('span');
+ s.textContent = text;
+ Object.assign(s.style, { opacity: '0.45', minWidth: '34px', fontVariantNumeric: 'tabular-nums' });
+ return s;
+}
+
+function makeSelect(options, value) {
+ const s = document.createElement('select');
+ Object.assign(s.style, {
+ background: 'rgba(14,20,38,0.9)',
+ border: '1px solid rgba(255,255,255,0.12)',
+ color: '#c8d8ff',
+ borderRadius: '5px',
+ padding: '2px 4px',
+ fontSize: '11px',
+ });
+ options.forEach(([val, label]) => {
+ const opt = document.createElement('option');
+ opt.value = val;
+ opt.text = label;
+ opt.selected = val === value;
+ s.appendChild(opt);
+ });
+ return s;
+}
+
+// ── Source row ────────────────────────────────────────────────────────────────
+const plasmaBtn = makeSegBtn('Plasma', true);
+const textBtn = makeSegBtn('Text');
+const textInput = document.createElement('input');
+Object.assign(textInput, { id: 'psn-text-input', type: 'text', value: 'KAMPOS', maxLength: 24 });
+Object.assign(textInput.style, {
+ display: 'none', width: '80px', padding: '2px 6px',
+ background: 'rgba(14,20,38,0.9)', border: '1px solid rgba(255,255,255,0.12)',
+ color: '#c8d8ff', borderRadius: '5px', fontSize: '11px',
+});
+const colorInput = document.createElement('input');
+Object.assign(colorInput, { id: 'psn-color-input', type: 'color', value: '#ffffff' });
+Object.assign(colorInput.style, { display: 'none', width: '32px', height: '24px', padding: '1px', cursor: 'pointer', border: 'none', borderRadius: '4px', background: 'transparent' });
+
+overlay.appendChild(makeRow('source', plasmaBtn, textBtn, textInput, colorInput));
+
+[plasmaBtn, textBtn].forEach(btn => btn.addEventListener('click', () => {
+ sourceMode = btn === plasmaBtn ? 'plasma' : 'text';
+ [plasmaBtn, textBtn].forEach(b => {
+ b.style.background = b === btn ? 'rgba(120,160,255,0.22)' : 'transparent';
+ b.style.color = b === btn ? '#c8d8ff' : '#8898bb';
+ });
+ textInput.style.display = sourceMode === 'text' ? '' : 'none';
+ colorInput.style.display = sourceMode === 'text' ? '' : 'none';
+ rebuildSource();
+ updatePreview();
+}));
+
+textInput.addEventListener('input', () => {
+ clearTimeout(textDebounce);
+ textDebounce = setTimeout(() => { rebuildSource(); updatePreview(); }, 600);
+});
+textInput.addEventListener('keydown', e => {
+ if (e.key === 'Enter') { clearTimeout(textDebounce); rebuildSource(); updatePreview(); }
+});
+colorInput.addEventListener('input', () => { rebuildSource(); updatePreview(); });
+
+// ── Particles row ─────────────────────────────────────────────────────────────
+const particleSel = makeSelect(
+ GRID_PRESETS.map(s => {
+ const n = s * s;
+ const label = n >= 1_000_000
+ ? `${(n / 1_000_000).toFixed(0)} M (${s}×${s})`
+ : n >= 1_000
+ ? `${(n / 1_000).toFixed(0)} K (${s}×${s})`
+ : `${n} (${s}×${s})`;
+ return [String(s), label];
+ }),
+ String(currentSize),
+);
+overlay.appendChild(makeRow('particles', particleSel));
+particleSel.addEventListener('change', () => applyGridSize(+particleSel.value));
+
+// ── Duration / hold ────────────────────────────────────────────────────────────
+const durSlider = makeRange(0.5, 12, 0.5, ANIM);
+const durVal = makeValSpan(`${ANIM.toFixed(1)} s`);
+const holdSlider = makeRange(0, 5, 0.5, HOLD);
+const holdVal = makeValSpan(`${HOLD.toFixed(1)} s`);
+
+overlay.appendChild(makeRow('duration', durSlider, durVal));
+overlay.appendChild(makeRow('hold', holdSlider, holdVal));
+
+durSlider.addEventListener('input', () => {
+ ANIM = parseFloat(durSlider.value);
+ durVal.textContent = `${ANIM.toFixed(1)} s`;
+});
+holdSlider.addEventListener('input', () => {
+ HOLD = parseFloat(holdSlider.value);
+ holdVal.textContent = `${HOLD.toFixed(1)} s`;
+});
+
+// ── Easing row ────────────────────────────────────────────────────────────────
+const easingSel = makeSelect([
+ ['smooth', 'Smooth (default)'],
+ ['linear', 'Linear'],
+ ['ease-in', 'Ease in (cubic)'],
+ ['ease-out', 'Ease out (cubic)'],
+ ['ease-in-out', 'Ease in-out (cubic)'],
+ ['sine', 'Sine in-out'],
+ ['elastic', 'Elastic ✦'],
+ ['bounce', 'Bounce out ✦'],
+], curEasing);
+overlay.appendChild(makeRow('easing', easingSel));
+easingSel.addEventListener('change', () => { curEasing = easingSel.value; });
+
+// ── Wind strength row ─────────────────────────────────────────────────────────
+const windSlider = makeRange(0, 1.5, 0.05, effect.windStr);
+const windVal = makeValSpan(effect.windStr.toFixed(2));
+overlay.appendChild(makeRow('wind', windSlider, windVal));
+windSlider.addEventListener('input', () => {
+ effect.windStr = parseFloat(windSlider.value);
+ windVal.textContent = effect.windStr.toFixed(2);
+});
+
+// ── Spread row ────────────────────────────────────────────────────────────────
+const spreadSlider = makeRange(0.5, 3.5, 0.1, effect.spread);
+const spreadVal = makeValSpan(effect.spread.toFixed(1) + '×');
+overlay.appendChild(makeRow('spread', spreadSlider, spreadVal));
+spreadSlider.addEventListener('input', () => {
+ effect.spread = parseFloat(spreadSlider.value);
+ spreadVal.textContent = effect.spread.toFixed(1) + '×';
+});
+
+// ── Replay button ─────────────────────────────────────────────────────────────
+const replayBtn = document.createElement('button');
+replayBtn.textContent = 'Replay';
+Object.assign(replayBtn.style, {
+ marginTop: '4px',
+ padding: '8px 18px',
+ border: '1px solid rgba(255,255,255,0.14)',
+ borderRadius: '999px',
+ background: 'rgba(10,16,34,0.82)',
+ color: '#d8e0f0',
+ cursor: 'pointer',
+ fontSize: '12px',
+ alignSelf: 'flex-end',
+});
+overlay.appendChild(replayBtn);
+replayBtn.addEventListener('click', replay);
+window.addEventListener('keydown', e => { if (e.code === 'Space') { e.preventDefault(); replay(); } });
+
+// ── Start ─────────────────────────────────────────────────────────────────────
+ticker.start();
diff --git a/demo/particles.html b/demo/particles.html
new file mode 100644
index 0000000..543c77e
--- /dev/null
+++ b/demo/particles.html
@@ -0,0 +1,27 @@
+
+
+
+
+
+ kampos particles demo
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/demo/particles.js b/demo/particles.js
new file mode 100644
index 0000000..637a867
--- /dev/null
+++ b/demo/particles.js
@@ -0,0 +1,997 @@
+import { Kampos, Ticker, effects } from '../index.js';
+
+const target = document.querySelector('#target');
+const ticker = new Ticker();
+
+const MAX_CANVASES = 6;
+const DEFAULT_PRESET_INDEX = 3;
+const EASING_OPTIONS = [
+ { value: 'smooth', label: 'Smoothstep' },
+ { value: 'linear', label: 'Linear' },
+ { value: 'outQuad', label: 'Ease Out' },
+ { value: 'inOutSine', label: 'Sine In-Out' },
+ { value: 'inOutCubic', label: 'Cubic In-Out' },
+];
+const DEFAULT_SETTINGS = {
+ duration: 6.0,
+ hold: 1.8,
+ easing: EASING_OPTIONS[0].value,
+ pointScale: 1.12,
+ spread: 1.0,
+ wind: 1.0,
+ stagger: 1.15,
+};
+const SOURCE_MODES = [
+ { value: 'card', label: 'Gradient card' },
+ { value: 'text', label: 'Transparent text' },
+];
+const DEFAULT_SOURCE_MODE = SOURCE_MODES[0].value;
+const PARTICLE_PRESETS = [
+ { width: 48, height: 27 },
+ { width: 96, height: 54 },
+ { width: 128, height: 72 },
+ { width: 192, height: 108 },
+ { width: 256, height: 144 },
+ { width: 384, height: 216 },
+ { width: 512, height: 288 },
+ { width: 768, height: 432 },
+ { width: 1024, height: 576 },
+];
+const DEFAULT_PRESET = PARTICLE_PRESETS[DEFAULT_PRESET_INDEX];
+const MAX_PRESET = PARTICLE_PRESETS[PARTICLE_PRESETS.length - 1];
+
+const urlParams = new URLSearchParams(window.location.search);
+const initialPhase = Number(urlParams.get('phase'));
+const settings = {
+ duration: resolveNumberSetting(urlParams.get('duration'), DEFAULT_SETTINGS.duration, 1.0, 12.0),
+ hold: resolveNumberSetting(urlParams.get('hold'), DEFAULT_SETTINGS.hold, 0.0, 4.0),
+ easing: resolveEasing(urlParams.get('easing')),
+ pointScale: resolveNumberSetting(urlParams.get('size'), DEFAULT_SETTINGS.pointScale, 0.6, 2.4),
+ spread: resolveNumberSetting(urlParams.get('spread'), DEFAULT_SETTINGS.spread, 0.25, 1.8),
+ wind: resolveNumberSetting(urlParams.get('wind'), DEFAULT_SETTINGS.wind, 0.0, 2.5),
+ stagger: resolveNumberSetting(urlParams.get('stagger'), DEFAULT_SETTINGS.stagger, 0.0, 2.5),
+};
+
+let currentPreset = resolveParticlePreset(urlParams.get('particles'));
+let currentParticleCount = currentPreset.width * currentPreset.height;
+let currentCanvasCount = resolveCanvasCount(urlParams.get('canvases'));
+let currentSourceMode = resolveSourceMode(urlParams.get('source'));
+let cycleStart = performance.now() * 0.001 - (Number.isFinite(initialPhase) ? initialPhase : 0.0);
+let globalSeed = Math.random() * 1000;
+
+const screenGrid = document.createElement('div');
+applyStyles(screenGrid, {
+ position: 'fixed',
+ inset: '0',
+ display: 'grid',
+ gap: '12px',
+ padding: '12px',
+ background: '#02040a',
+ zIndex: '0',
+ pointerEvents: 'none',
+});
+document.body.appendChild(screenGrid);
+
+const overlay = document.createElement('div');
+applyStyles(overlay, {
+ position: 'fixed',
+ inset: '16px 16px auto auto',
+ display: 'grid',
+ gap: '10px',
+ justifyItems: 'end',
+ maxHeight: 'calc(100vh - 32px)',
+ overflow: 'auto',
+ zIndex: '5',
+ pointerEvents: 'auto',
+});
+document.body.appendChild(overlay);
+
+const sourceCanvas = document.createElement('canvas');
+applyStyles(sourceCanvas, {
+ width: '192px',
+ height: '108px',
+ border: '1px solid rgba(255, 255, 255, 0.18)',
+ borderRadius: '10px',
+ boxShadow: '0 10px 30px rgba(0, 0, 0, 0.35)',
+ imageRendering: 'pixelated',
+});
+overlay.appendChild(sourceCanvas);
+
+const info = document.createElement('div');
+applyStyles(info, {
+ padding: '8px 12px',
+ borderRadius: '999px',
+ background: 'rgba(6, 12, 28, 0.72)',
+ color: '#eef3ff',
+ font: '500 13px/1.2 system-ui, sans-serif',
+ letterSpacing: '0.02em',
+});
+overlay.appendChild(info);
+
+const sourceSelect = createSelectControl('Media source', SOURCE_MODES);
+overlay.appendChild(sourceSelect.wrapper);
+
+const particleSelect = createSelectControl('Stress test', PARTICLE_PRESETS.map((preset) => ({
+ value: getPresetValue(preset),
+ label: formatPresetLabel(preset),
+})));
+overlay.appendChild(particleSelect.wrapper);
+
+const canvasSelect = createSelectControl('Canvases', Array.from({ length: MAX_CANVASES }, (_, index) => ({
+ value: String(index + 1),
+ label: `${index + 1} ${index === 0 ? 'canvas' : 'canvases'}`,
+})));
+overlay.appendChild(canvasSelect.wrapper);
+
+const animationPanel = createControlPanel('Animation');
+overlay.appendChild(animationPanel.wrapper);
+
+const easingControl = createPanelSelectControl('Easing', EASING_OPTIONS);
+animationPanel.content.appendChild(easingControl.wrapper);
+
+const durationControl = createRangeControl('Duration', {
+ min: 1.0,
+ max: 12.0,
+ step: 0.1,
+ value: settings.duration,
+ format: (value) => `${value.toFixed(1)}s`,
+});
+animationPanel.content.appendChild(durationControl.wrapper);
+
+const holdControl = createRangeControl('Hold', {
+ min: 0.0,
+ max: 4.0,
+ step: 0.1,
+ value: settings.hold,
+ format: (value) => `${value.toFixed(1)}s`,
+});
+animationPanel.content.appendChild(holdControl.wrapper);
+
+const staggerControl = createRangeControl('Stagger', {
+ min: 0.0,
+ max: 2.5,
+ step: 0.01,
+ value: settings.stagger,
+ format: formatFactor,
+});
+animationPanel.content.appendChild(staggerControl.wrapper);
+
+const windControl = createRangeControl('Wind', {
+ min: 0.0,
+ max: 2.5,
+ step: 0.01,
+ value: settings.wind,
+ format: formatFactor,
+});
+animationPanel.content.appendChild(windControl.wrapper);
+
+const spreadControl = createRangeControl('Spread', {
+ min: 0.25,
+ max: 1.8,
+ step: 0.01,
+ value: settings.spread,
+ format: formatFactor,
+});
+animationPanel.content.appendChild(spreadControl.wrapper);
+
+const pointSizeControl = createRangeControl('Point size', {
+ min: 0.6,
+ max: 2.4,
+ step: 0.01,
+ value: settings.pointScale,
+ format: formatFactor,
+});
+animationPanel.content.appendChild(pointSizeControl.wrapper);
+
+const replayButton = document.createElement('button');
+replayButton.textContent = 'Replay';
+applyStyles(replayButton, {
+ position: 'fixed',
+ left: '50%',
+ bottom: '24px',
+ transform: 'translateX(-50%)',
+ padding: '12px 18px',
+ border: '1px solid rgba(255, 255, 255, 0.18)',
+ borderRadius: '999px',
+ background: 'rgba(6, 12, 28, 0.76)',
+ color: '#eef3ff',
+ font: '600 14px/1 system-ui, sans-serif',
+ cursor: 'pointer',
+ zIndex: '5',
+});
+document.body.appendChild(replayButton);
+
+const instances = [];
+
+renderSourceCanvas(sourceCanvas, currentPreset.width, currentPreset.height, currentSourceMode);
+updateSourceCanvasPreview();
+applyCanvasCount(currentCanvasCount, { restartAnimation: false, syncUrl: false });
+applySourceMode(currentSourceMode, { restartAnimation: false, syncUrl: false });
+applyParticlePreset(currentPreset, { restartAnimation: false, syncUrl: false });
+applyAnimationSettings({}, { restartAnimation: false, syncUrl: false });
+
+ticker.start();
+
+replayButton.addEventListener('click', replay);
+sourceSelect.select.addEventListener('change', () => {
+ applySourceMode(resolveSourceMode(sourceSelect.select.value));
+});
+particleSelect.select.addEventListener('change', () => {
+ applyParticlePreset(resolveParticlePreset(particleSelect.select.value));
+});
+canvasSelect.select.addEventListener('change', () => {
+ applyCanvasCount(resolveCanvasCount(canvasSelect.select.value));
+});
+easingControl.select.addEventListener('change', () => {
+ applyAnimationSettings({ easing: easingControl.select.value });
+});
+bindRangeSettingControl(durationControl, 'duration');
+bindRangeSettingControl(holdControl, 'hold');
+bindRangeSettingControl(staggerControl, 'stagger');
+bindRangeSettingControl(windControl, 'wind');
+bindRangeSettingControl(spreadControl, 'spread');
+bindRangeSettingControl(pointSizeControl, 'pointScale');
+window.addEventListener('keydown', (event) => {
+ if (event.code === 'Space') {
+ event.preventDefault();
+ replay();
+ }
+});
+
+function replay() {
+ cycleStart = performance.now() * 0.001;
+ globalSeed = Math.random() * 1000;
+}
+
+function applyParticlePreset(preset, { restartAnimation = true, syncUrl = true } = {}) {
+ currentPreset = preset;
+ currentParticleCount = preset.width * preset.height;
+
+ renderSourceCanvas(sourceCanvas, preset.width, preset.height, currentSourceMode);
+ syncInstancesSource();
+
+ particleSelect.select.value = getPresetValue(preset);
+ updateInfo();
+
+ if (syncUrl) {
+ syncUrlState();
+ }
+
+ if (restartAnimation) {
+ replay();
+ }
+}
+
+function applySourceMode(mode, { restartAnimation = true, syncUrl = true } = {}) {
+ currentSourceMode = mode;
+
+ renderSourceCanvas(sourceCanvas, currentPreset.width, currentPreset.height, currentSourceMode);
+ updateSourceCanvasPreview();
+ syncInstancesSource();
+
+ sourceSelect.select.value = currentSourceMode;
+
+ if (syncUrl) {
+ syncUrlState();
+ }
+
+ if (restartAnimation) {
+ replay();
+ }
+}
+
+function applyAnimationSettings(nextSettings = {}, { restartAnimation = true, syncUrl = true } = {}) {
+ if (typeof nextSettings.duration !== 'undefined') {
+ settings.duration = clamp(nextSettings.duration, 1.0, 12.0);
+ }
+
+ if (typeof nextSettings.hold !== 'undefined') {
+ settings.hold = clamp(nextSettings.hold, 0.0, 4.0);
+ }
+
+ if (typeof nextSettings.easing !== 'undefined') {
+ settings.easing = resolveEasing(nextSettings.easing);
+ }
+
+ if (typeof nextSettings.pointScale !== 'undefined') {
+ settings.pointScale = clamp(nextSettings.pointScale, 0.6, 2.4);
+ }
+
+ if (typeof nextSettings.spread !== 'undefined') {
+ settings.spread = clamp(nextSettings.spread, 0.25, 1.8);
+ }
+
+ if (typeof nextSettings.wind !== 'undefined') {
+ settings.wind = clamp(nextSettings.wind, 0.0, 2.5);
+ }
+
+ if (typeof nextSettings.stagger !== 'undefined') {
+ settings.stagger = clamp(nextSettings.stagger, 0.0, 2.5);
+ }
+
+ syncAnimationControls();
+ syncInstanceSettings();
+
+ if (syncUrl) {
+ syncUrlState();
+ }
+
+ if (restartAnimation) {
+ replay();
+ }
+}
+
+function applyCanvasCount(count, { restartAnimation = true, syncUrl = true } = {}) {
+ currentCanvasCount = count;
+
+ ensureInstanceCount(count);
+ updateGridLayout();
+ syncInstancesSource();
+ syncInstanceSettings();
+
+ canvasSelect.select.value = String(count);
+ updateInfo();
+
+ if (syncUrl) {
+ syncUrlState();
+ }
+
+ if (restartAnimation) {
+ replay();
+ }
+}
+
+function ensureInstanceCount(count) {
+ while (instances.length < count) {
+ instances.push(createDemoInstance(instances.length === 0 ? target : null));
+ }
+
+ while (instances.length > count) {
+ destroyDemoInstance(instances.pop());
+ }
+}
+
+function createDemoInstance(existingCanvas) {
+ const canvas = existingCanvas || document.createElement('canvas');
+ applyStyles(canvas, {
+ display: 'block',
+ width: '100%',
+ height: '100%',
+ });
+
+ const cell = document.createElement('div');
+ applyStyles(cell, {
+ position: 'relative',
+ minWidth: '0',
+ minHeight: '0',
+ overflow: 'hidden',
+ borderRadius: '16px',
+ border: '1px solid rgba(255, 255, 255, 0.05)',
+ boxShadow: '0 14px 40px rgba(0, 0, 0, 0.35)',
+ background: 'radial-gradient(circle at 50% 50%, rgba(15, 22, 46, 0.95), rgba(3, 5, 11, 1))',
+ });
+ cell.appendChild(canvas);
+ screenGrid.appendChild(cell);
+
+ const effect = effects.particlesGpt({
+ width: currentPreset.width,
+ height: currentPreset.height,
+ maxWidth: MAX_PRESET.width,
+ maxHeight: MAX_PRESET.height,
+ duration: settings.duration,
+ hold: settings.hold,
+ easing: settings.easing,
+ pointScale: settings.pointScale,
+ spread: settings.spread,
+ wind: settings.wind,
+ stagger: settings.stagger,
+ source: sourceCanvas,
+ });
+
+ const instanceState = {
+ cell,
+ canvas,
+ effect,
+ seedOffset: Math.random() * 4000,
+ kampos: null,
+ };
+
+ const kampos = new Kampos({
+ target: canvas,
+ effects: [effect],
+ noSource: true,
+ ticker,
+ beforeDraw: (time) => renderInstance(instanceState, time),
+ afterDraw: () => {
+ effect.textures[0].update = false;
+ },
+ });
+
+ const { gl } = kampos;
+ if (gl.getParameter(gl.MAX_VERTEX_TEXTURE_IMAGE_UNITS) < 1) {
+ kampos.destroy();
+ cell.remove();
+ throw new Error('This demo requires vertex texture fetch support.');
+ }
+
+ gl.disable(gl.DEPTH_TEST);
+ gl.enable(gl.BLEND);
+ gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
+
+ instanceState.kampos = kampos;
+
+ return instanceState;
+}
+
+function renderInstance(instance, nowMs) {
+ if (!resizeInstance(instance)) {
+ return false;
+ }
+
+ const now = nowMs * 0.001;
+ const phase = getCyclePhase(now);
+ const { effect, kampos } = instance;
+ const { gl } = kampos;
+
+ gl.clearColor(0.02, 0.03, 0.06, 1.0);
+ gl.clear(gl.COLOR_BUFFER_BIT);
+
+ effect.canvasSize = {
+ width: instance.canvas.width,
+ height: instance.canvas.height,
+ };
+ effect.time = now;
+ effect.phase = phase;
+ effect.seed = globalSeed + instance.seedOffset;
+
+ return true;
+}
+
+function destroyDemoInstance(instance) {
+ if (!instance) {
+ return;
+ }
+
+ instance.kampos.destroy();
+ instance.cell.remove();
+}
+
+function syncInstancesSource() {
+ instances.forEach(({ effect }) => {
+ effect.sourceSize = currentPreset;
+ effect.source = sourceCanvas;
+ });
+}
+
+function resizeInstance(instance) {
+ const rect = instance.canvas.getBoundingClientRect();
+ if (!rect.width || !rect.height) {
+ return false;
+ }
+
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
+ const width = Math.max(1, Math.floor(rect.width * dpr));
+ const height = Math.max(1, Math.floor(rect.height * dpr));
+
+ if (instance.canvas.width !== width || instance.canvas.height !== height) {
+ instance.canvas.width = width;
+ instance.canvas.height = height;
+ instance.kampos.gl.viewport(0, 0, width, height);
+ }
+
+ return true;
+}
+
+function updateGridLayout() {
+ const [columns, rows] = getGridLayout(currentCanvasCount);
+
+ screenGrid.style.gridTemplateColumns = `repeat(${columns}, minmax(0, 1fr))`;
+ screenGrid.style.gridTemplateRows = `repeat(${rows}, minmax(0, 1fr))`;
+}
+
+function getGridLayout(count) {
+ if (count <= 1) {
+ return [1, 1];
+ }
+
+ if (count === 2) {
+ return [2, 1];
+ }
+
+ if (count === 3) {
+ return [3, 1];
+ }
+
+ if (count === 4) {
+ return [2, 2];
+ }
+
+ return [3, 2];
+}
+
+function updateInfo() {
+ const totalParticles = currentParticleCount * currentCanvasCount;
+ info.textContent = `${currentParticleCount.toLocaleString()} each · ${currentCanvasCount} canvas${currentCanvasCount === 1 ? '' : 'es'} · ${totalParticles.toLocaleString()} total`;
+}
+
+function getCycleDuration() {
+ return settings.duration + settings.hold;
+}
+
+function getCyclePhase(now) {
+ const duration = getCycleDuration();
+ if (!duration) {
+ return 0.0;
+ }
+
+ return ((now - cycleStart) % duration + duration) % duration;
+}
+
+function syncAnimationControls() {
+ easingControl.select.value = settings.easing;
+ durationControl.setValue(settings.duration);
+ holdControl.setValue(settings.hold);
+ staggerControl.setValue(settings.stagger);
+ windControl.setValue(settings.wind);
+ spreadControl.setValue(settings.spread);
+ pointSizeControl.setValue(settings.pointScale);
+}
+
+function syncInstanceSettings() {
+ instances.forEach(({ effect }) => {
+ effect.duration = settings.duration;
+ effect.hold = settings.hold;
+ effect.easing = settings.easing;
+ effect.pointScale = settings.pointScale;
+ effect.spread = settings.spread;
+ effect.wind = settings.wind;
+ effect.stagger = settings.stagger;
+ });
+}
+
+function bindRangeSettingControl(control, key) {
+ control.input.addEventListener('input', () => {
+ applyAnimationSettings({ [key]: Number(control.input.value) }, {
+ restartAnimation: false,
+ });
+ });
+
+ control.input.addEventListener('change', () => {
+ replay();
+ });
+}
+
+function createSelectControl(title, options) {
+ const wrapper = document.createElement('label');
+ applyStyles(wrapper, {
+ display: 'grid',
+ gap: '6px',
+ padding: '10px 12px',
+ borderRadius: '12px',
+ background: 'rgba(6, 12, 28, 0.72)',
+ border: '1px solid rgba(255, 255, 255, 0.12)',
+ boxShadow: '0 10px 30px rgba(0, 0, 0, 0.25)',
+ });
+
+ const label = document.createElement('span');
+ label.textContent = title;
+ applyStyles(label, {
+ color: '#eef3ff',
+ font: '600 11px/1 system-ui, sans-serif',
+ letterSpacing: '0.08em',
+ textTransform: 'uppercase',
+ });
+ wrapper.appendChild(label);
+
+ const select = document.createElement('select');
+ applyStyles(select, {
+ padding: '8px 10px',
+ borderRadius: '8px',
+ border: '1px solid rgba(255, 255, 255, 0.14)',
+ background: 'rgba(16, 23, 48, 0.94)',
+ color: '#eef3ff',
+ font: '500 13px/1.2 system-ui, sans-serif',
+ cursor: 'pointer',
+ });
+
+ options.forEach(({ value, label: text }) => {
+ const option = document.createElement('option');
+ option.value = value;
+ option.textContent = text;
+ select.appendChild(option);
+ });
+
+ wrapper.appendChild(select);
+
+ return { wrapper, select };
+}
+
+function createControlPanel(title) {
+ const wrapper = document.createElement('section');
+ applyStyles(wrapper, {
+ display: 'grid',
+ gap: '10px',
+ minWidth: '240px',
+ padding: '10px 12px 12px',
+ borderRadius: '12px',
+ background: 'rgba(6, 12, 28, 0.72)',
+ border: '1px solid rgba(255, 255, 255, 0.12)',
+ boxShadow: '0 10px 30px rgba(0, 0, 0, 0.25)',
+ });
+
+ const label = document.createElement('span');
+ label.textContent = title;
+ applyStyles(label, {
+ color: '#eef3ff',
+ font: '600 11px/1 system-ui, sans-serif',
+ letterSpacing: '0.08em',
+ textTransform: 'uppercase',
+ });
+ wrapper.appendChild(label);
+
+ const content = document.createElement('div');
+ applyStyles(content, {
+ display: 'grid',
+ gap: '10px',
+ });
+ wrapper.appendChild(content);
+
+ return { wrapper, content };
+}
+
+function createPanelSelectControl(title, options) {
+ const wrapper = document.createElement('label');
+ applyStyles(wrapper, {
+ display: 'grid',
+ gap: '6px',
+ });
+
+ const label = document.createElement('span');
+ label.textContent = title;
+ applyStyles(label, {
+ color: '#c8d3ef',
+ font: '500 11px/1 system-ui, sans-serif',
+ letterSpacing: '0.04em',
+ });
+ wrapper.appendChild(label);
+
+ const select = document.createElement('select');
+ applyStyles(select, {
+ padding: '8px 10px',
+ borderRadius: '8px',
+ border: '1px solid rgba(255, 255, 255, 0.14)',
+ background: 'rgba(16, 23, 48, 0.94)',
+ color: '#eef3ff',
+ font: '500 13px/1.2 system-ui, sans-serif',
+ cursor: 'pointer',
+ });
+
+ options.forEach(({ value, label: text }) => {
+ const option = document.createElement('option');
+ option.value = value;
+ option.textContent = text;
+ select.appendChild(option);
+ });
+
+ wrapper.appendChild(select);
+
+ return { wrapper, select };
+}
+
+function createRangeControl(title, { min, max, step, value, format }) {
+ const wrapper = document.createElement('label');
+ applyStyles(wrapper, {
+ display: 'grid',
+ gap: '6px',
+ });
+
+ const header = document.createElement('div');
+ applyStyles(header, {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ gap: '12px',
+ });
+
+ const label = document.createElement('span');
+ label.textContent = title;
+ applyStyles(label, {
+ color: '#c8d3ef',
+ font: '500 11px/1 system-ui, sans-serif',
+ letterSpacing: '0.04em',
+ });
+ header.appendChild(label);
+
+ const valueEl = document.createElement('span');
+ applyStyles(valueEl, {
+ color: '#eef3ff',
+ font: '600 11px/1 system-ui, sans-serif',
+ letterSpacing: '0.04em',
+ });
+ header.appendChild(valueEl);
+ wrapper.appendChild(header);
+
+ const input = document.createElement('input');
+ input.type = 'range';
+ input.min = String(min);
+ input.max = String(max);
+ input.step = String(step);
+ applyStyles(input, {
+ width: '100%',
+ margin: '0',
+ accentColor: '#84e8f7',
+ cursor: 'pointer',
+ });
+ wrapper.appendChild(input);
+
+ function setValue(nextValue) {
+ const numericValue = Number(nextValue);
+ input.value = String(numericValue);
+ valueEl.textContent = format(numericValue);
+ }
+
+ input.addEventListener('input', () => {
+ valueEl.textContent = format(Number(input.value));
+ });
+
+ setValue(value);
+
+ return { wrapper, input, setValue };
+}
+
+function renderSourceCanvas(canvas, width, height, sourceMode) {
+ canvas.width = width;
+ canvas.height = height;
+
+ const ctx = canvas.getContext('2d');
+ ctx.clearRect(0, 0, width, height);
+
+ if (sourceMode === 'text') {
+ renderTransparentTextSource(ctx, width, height);
+ return;
+ }
+
+ renderCardSource(ctx, width, height);
+}
+
+function renderCardSource(ctx, width, height) {
+ ctx.clearRect(0, 0, width, height);
+
+ const cardX = width * 0.12;
+ const cardY = height * 0.16;
+ const cardWidth = width * 0.76;
+ const cardHeight = height * 0.68;
+ const cardRadius = 18;
+
+ const glow = ctx.createRadialGradient(width * 0.32, height * 0.34, Math.max(2, width * 0.03), width * 0.32, height * 0.34, width * 0.3);
+ glow.addColorStop(0.0, 'rgba(255, 224, 188, 0.95)');
+ glow.addColorStop(0.42, 'rgba(255, 131, 147, 0.42)');
+ glow.addColorStop(1.0, 'rgba(255, 131, 147, 0.0)');
+ ctx.fillStyle = glow;
+ ctx.fillRect(0, 0, width, height);
+
+ ctx.save();
+ ctx.shadowColor = 'rgba(47, 21, 118, 0.45)';
+ ctx.shadowBlur = 18;
+ ctx.shadowOffsetY = 8;
+ const gradient = ctx.createLinearGradient(cardX, cardY, cardX + cardWidth, cardY + cardHeight);
+ gradient.addColorStop(0.0, '#20104f');
+ gradient.addColorStop(0.38, '#4f2fd0');
+ gradient.addColorStop(0.72, '#1b9ec6');
+ gradient.addColorStop(1.0, '#89f3e0');
+ ctx.fillStyle = gradient;
+ roundedRect(ctx, cardX, cardY, cardWidth, cardHeight, cardRadius);
+ ctx.fill();
+ ctx.restore();
+
+ ctx.save();
+ roundedRect(ctx, cardX, cardY, cardWidth, cardHeight, cardRadius);
+ ctx.clip();
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)';
+ ctx.lineWidth = Math.max(1, width / 128);
+ for (let i = -height; i < width + height; i += Math.max(6, Math.round(width / 16))) {
+ ctx.beginPath();
+ ctx.moveTo(i, 0);
+ ctx.lineTo(i - height, height);
+ ctx.stroke();
+ }
+ ctx.restore();
+
+ ctx.fillStyle = '#f6f7ff';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.font = `bold ${Math.round(height * 0.5)}px system-ui, sans-serif`;
+ ctx.fillText('GLSL', width * 0.5, height * 0.48);
+
+ ctx.fillStyle = '#9de3ff';
+ ctx.font = `600 ${Math.round(height * 0.185)}px monospace`;
+ ctx.fillText('particles', width * 0.5, height * 0.76);
+}
+
+function renderTransparentTextSource(ctx, width, height) {
+ ctx.clearRect(0, 0, width, height);
+ ctx.save();
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.shadowColor = 'rgba(63, 34, 160, 0.55)';
+ ctx.shadowBlur = Math.max(3, Math.round(height * 0.09));
+ ctx.shadowOffsetY = Math.max(1, Math.round(height * 0.02));
+
+ ctx.fillStyle = '#f6f7ff';
+ ctx.font = `bold ${Math.round(height * 0.52)}px system-ui, sans-serif`;
+ ctx.fillText('GLSL', width * 0.5, height * 0.44);
+
+ ctx.fillStyle = '#9de3ff';
+ ctx.font = `600 ${Math.round(height * 0.19)}px monospace`;
+ ctx.fillText('particles', width * 0.5, height * 0.74);
+ ctx.restore();
+}
+
+function updateSourceCanvasPreview() {
+ if (currentSourceMode === 'text') {
+ applyStyles(sourceCanvas, {
+ backgroundColor: 'rgba(8, 14, 28, 0.88)',
+ backgroundImage: `
+ linear-gradient(45deg, rgba(255, 255, 255, 0.06) 25%, transparent 25%, transparent 75%, rgba(255, 255, 255, 0.06) 75%),
+ linear-gradient(45deg, rgba(255, 255, 255, 0.06) 25%, transparent 25%, transparent 75%, rgba(255, 255, 255, 0.06) 75%)
+ `,
+ backgroundPosition: '0 0, 8px 8px',
+ backgroundSize: '16px 16px',
+ });
+ return;
+ }
+
+ applyStyles(sourceCanvas, {
+ backgroundColor: 'rgba(8, 14, 28, 0.88)',
+ backgroundImage: 'none',
+ backgroundPosition: '',
+ backgroundSize: '',
+ });
+}
+
+function roundedRect(ctx, x, y, width, height, radius) {
+ const r = Math.min(radius, width * 0.5, height * 0.5);
+ ctx.beginPath();
+ ctx.moveTo(x + r, y);
+ ctx.lineTo(x + width - r, y);
+ ctx.quadraticCurveTo(x + width, y, x + width, y + r);
+ ctx.lineTo(x + width, y + height - r);
+ ctx.quadraticCurveTo(x + width, y + height, x + width - r, y + height);
+ ctx.lineTo(x + r, y + height);
+ ctx.quadraticCurveTo(x, y + height, x, y + height - r);
+ ctx.lineTo(x, y + r);
+ ctx.quadraticCurveTo(x, y, x + r, y);
+ ctx.closePath();
+}
+
+function applyStyles(element, styles) {
+ Object.assign(element.style, styles);
+}
+
+function formatFactor(value) {
+ return trimNumber(value, 2);
+}
+
+function formatPresetLabel(preset) {
+ return `${(preset.width * preset.height).toLocaleString()} · ${preset.width}×${preset.height}`;
+}
+
+function getPresetValue(preset) {
+ return `${preset.width}x${preset.height}`;
+}
+
+function resolveParticlePreset(value) {
+ if (!value) {
+ return DEFAULT_PRESET;
+ }
+
+ if (/^\d+x\d+$/i.test(value)) {
+ const [width, height] = value.toLowerCase().split('x').map(Number);
+ return PARTICLE_PRESETS.find((preset) => preset.width === width && preset.height === height) || DEFAULT_PRESET;
+ }
+
+ if (/^\d+$/.test(value)) {
+ const count = Number(value);
+ return PARTICLE_PRESETS.find((preset) => preset.width * preset.height === count) || DEFAULT_PRESET;
+ }
+
+ return DEFAULT_PRESET;
+}
+
+function resolveCanvasCount(value) {
+ const count = Number(value);
+ if (!Number.isFinite(count)) {
+ return 1;
+ }
+
+ return Math.min(MAX_CANVASES, Math.max(1, Math.round(count)));
+}
+
+function resolveSourceMode(value) {
+ return SOURCE_MODES.some((mode) => mode.value === value)
+ ? value
+ : DEFAULT_SOURCE_MODE;
+}
+
+function resolveEasing(value) {
+ return EASING_OPTIONS.some((option) => option.value === value)
+ ? value
+ : DEFAULT_SETTINGS.easing;
+}
+
+function resolveNumberSetting(value, fallback, min, max) {
+ if (value === null || value === '') {
+ return fallback;
+ }
+
+ const numericValue = Number(value);
+
+ if (!Number.isFinite(numericValue)) {
+ return fallback;
+ }
+
+ return clamp(numericValue, min, max);
+}
+
+function trimNumber(value, precision = 2) {
+ return Number(value.toFixed(precision)).toString();
+}
+
+function clamp(value, min, max) {
+ return Math.min(max, Math.max(min, value));
+}
+
+function syncUrlState() {
+ const url = new URL(window.location.href);
+
+ url.searchParams.delete('phase');
+
+ if (getPresetValue(currentPreset) === getPresetValue(DEFAULT_PRESET)) {
+ url.searchParams.delete('particles');
+ }
+ else {
+ url.searchParams.set('particles', getPresetValue(currentPreset));
+ }
+
+ if (currentCanvasCount === 1) {
+ url.searchParams.delete('canvases');
+ }
+ else {
+ url.searchParams.set('canvases', String(currentCanvasCount));
+ }
+
+ if (currentSourceMode === DEFAULT_SOURCE_MODE) {
+ url.searchParams.delete('source');
+ }
+ else {
+ url.searchParams.set('source', currentSourceMode);
+ }
+
+ syncNumberParam(url, 'duration', settings.duration, DEFAULT_SETTINGS.duration, 1);
+ syncNumberParam(url, 'hold', settings.hold, DEFAULT_SETTINGS.hold, 1);
+ syncStringParam(url, 'easing', settings.easing, DEFAULT_SETTINGS.easing);
+ syncNumberParam(url, 'size', settings.pointScale, DEFAULT_SETTINGS.pointScale);
+ syncNumberParam(url, 'spread', settings.spread, DEFAULT_SETTINGS.spread);
+ syncNumberParam(url, 'wind', settings.wind, DEFAULT_SETTINGS.wind);
+ syncNumberParam(url, 'stagger', settings.stagger, DEFAULT_SETTINGS.stagger);
+
+ window.history.replaceState({}, '', url);
+}
+
+function syncNumberParam(url, key, value, defaultValue, precision = 2) {
+ if (Math.abs(value - defaultValue) < 0.0001) {
+ url.searchParams.delete(key);
+ return;
+ }
+
+ url.searchParams.set(key, trimNumber(value, precision));
+}
+
+function syncStringParam(url, key, value, defaultValue) {
+ if (value === defaultValue) {
+ url.searchParams.delete(key);
+ return;
+ }
+
+ url.searchParams.set(key, value);
+}
diff --git a/demo/particles2.html b/demo/particles2.html
new file mode 100644
index 0000000..872bb10
--- /dev/null
+++ b/demo/particles2.html
@@ -0,0 +1,1032 @@
+
+
+
+
+
+ Particle Assembler — kampos
+
+
+
+
+Particle Assembler
+
+
+
+
+
+
+
+
+
+
+
+
+
source
+
+
+
+
+
+
+
+
+
+
+
+
canvases
+
+
+
+
+
+
+
+
+
+
+
+ particles
+
+
+
+
+
+ duration
+
+ 4.5 s
+
+ hold
+
+ 2.0 s
+
+
+
+ easing
+
+
+
+
+ wind
+ strength
+
+ 0.30
+
+ speed
+
+ 1.0×
+
+
+
+ — fps
+ assembling
+ 65 536 particles
+
+
+
+
+
+
diff --git a/dist/index.cjs b/dist/index.cjs
index eb61e2d..55c6600 100644
--- a/dist/index.cjs
+++ b/dist/index.cjs
@@ -1460,6 +1460,426 @@ function slitScan ({
};
}
+const EASING_MODES = ['smooth', 'linear', 'outQuad', 'inOutSine', 'inOutCubic'];
+const particleIdCache = new Map();
+
+function getParticleIds(maxParticles) {
+ if (!particleIdCache.has(maxParticles)) {
+ const particleIds = new Float32Array(maxParticles);
+ for (let i = 0; i < maxParticles; i++) {
+ particleIds[i] = i;
+ }
+ particleIdCache.set(maxParticles, particleIds);
+ }
+
+ return particleIdCache.get(maxParticles);
+}
+
+function createSourceCanvas(width, height) {
+ const canvas = document.createElement('canvas');
+ canvas.width = width;
+ canvas.height = height;
+ return canvas;
+}
+
+/**
+ * Particle image assembly effect rendered as point sprites.
+ *
+ * Requires vertex texture fetch support.
+ *
+ * @function particlesGpt
+ * @param {Object} [params]
+ * @param {number} [params.width=192] active source width / particle columns
+ * @param {number} [params.height=108] active source height / particle rows
+ * @param {number} [params.maxWidth=1024] max source width used to size the particle id buffer
+ * @param {number} [params.maxHeight=576] max source height used to size the particle id buffer
+ * @param {number} [params.duration=6.0] animation duration in seconds
+ * @param {number} [params.hold=1.8] hold duration in seconds
+ * @param {number} [params.pointScale=1.12] point size multiplier
+ * @param {number} [params.spread=1.0] starting spread multiplier
+ * @param {number} [params.wind=1.0] wind multiplier
+ * @param {number} [params.stagger=1.15] random start delay window in seconds
+ * @param {string} [params.easing='smooth'] easing mode
+ * @param {HTMLCanvasElement|ImageBitmap|HTMLImageElement} [params.source] source texture
+ * @returns {particlesGptEffect}
+ */
+function particlesGpt({
+ width = 192,
+ height = 108,
+ maxWidth = 1024,
+ maxHeight = 576,
+ duration = 6.0,
+ hold = 1.8,
+ pointScale = 1.12,
+ spread = 1.0,
+ wind = 1.0,
+ stagger = 1.15,
+ easing = EASING_MODES[0],
+ source = createSourceCanvas(width, height),
+} = {}) {
+ const maxParticles = maxWidth * maxHeight;
+
+ if (width * height > maxParticles) {
+ throw new Error('particles-gpt :: width * height exceeds max particle capacity');
+ }
+
+ const draw = {
+ mode: 'POINTS',
+ count: width * height,
+ };
+
+ let holdDuration = Math.max(0, hold);
+
+ const effect = {
+ draw,
+ vertex: {
+ uniform: {
+ u_particlesGptMap: 'sampler2D',
+ u_particlesGptImageSize: 'vec2',
+ u_particlesGptCanvasSize: 'vec2',
+ u_particlesGptTime: 'float',
+ u_particlesGptPhase: 'float',
+ u_particlesGptDuration: 'float',
+ u_particlesGptDelayWindow: 'float',
+ u_particlesGptPointScale: 'float',
+ u_particlesGptSpread: 'float',
+ u_particlesGptWindStrength: 'float',
+ u_particlesGptSeed: 'float',
+ u_particlesGptEaseMode: 'int',
+ },
+ attribute: {
+ a_particlesGptId: 'float',
+ },
+ constant: `
+const float particlesGptTau = 6.283185307179586;
+
+float particlesGptHash11(float p) {
+ p = fract(p * 0.1031);
+ p *= p + 33.33;
+ p *= p + p;
+ return fract(p);
+}
+
+vec2 particlesGptHash21(float p) {
+ vec3 q = fract(vec3(p) * vec3(0.1031, 0.1030, 0.0973));
+ q += dot(q, q.yzx + 33.33);
+ return fract((q.xx + q.yz) * q.zy);
+}
+
+float particlesGptSmoother(float t) {
+ return t * t * t * (t * (t * 6.0 - 15.0) + 10.0);
+}
+
+float particlesGptEase(float t) {
+ if (u_particlesGptEaseMode == 1) {
+ return t;
+ }
+
+ if (u_particlesGptEaseMode == 2) {
+ return 1.0 - pow(1.0 - t, 2.0);
+ }
+
+ if (u_particlesGptEaseMode == 3) {
+ return 0.5 - 0.5 * cos(t * PI);
+ }
+
+ if (u_particlesGptEaseMode == 4) {
+ return t < 0.5
+ ? 4.0 * t * t * t
+ : 1.0 - pow(-2.0 * t + 2.0, 3.0) * 0.5;
+ }
+
+ return particlesGptSmoother(t);
+}
+
+vec2 particlesGptScreenToClip(vec2 pixelPosition) {
+ vec2 clip = pixelPosition / u_particlesGptCanvasSize * 2.0 - 1.0;
+ clip.y *= -1.0;
+ return clip;
+}`,
+ main: `
+ float particlesGptX = mod(a_particlesGptId, u_particlesGptImageSize.x);
+ float particlesGptY = floor(a_particlesGptId / u_particlesGptImageSize.x);
+ vec2 particlesGptUv = (
+ vec2(particlesGptX, particlesGptY) + 0.5
+ ) / u_particlesGptImageSize;
+ v_particlesGptColor = texture2D(u_particlesGptMap, particlesGptUv);
+
+ vec2 particlesGptImageFit = u_particlesGptCanvasSize * 0.58;
+ float particlesGptCell = min(
+ particlesGptImageFit.x / u_particlesGptImageSize.x,
+ particlesGptImageFit.y / u_particlesGptImageSize.y
+ );
+ vec2 particlesGptTargetPosition =
+ (vec2(particlesGptX + 0.5, particlesGptY + 0.5) - u_particlesGptImageSize * 0.5) *
+ particlesGptCell +
+ u_particlesGptCanvasSize * 0.5;
+
+ vec2 particlesGptRandomBox =
+ (particlesGptHash21(a_particlesGptId + 19.7 + u_particlesGptSeed) - 0.5) *
+ u_particlesGptCanvasSize *
+ (1.9 * u_particlesGptSpread);
+ float particlesGptOrbitAngle =
+ particlesGptHash11(a_particlesGptId * 0.173 + 4.1 + u_particlesGptSeed) *
+ particlesGptTau;
+ float particlesGptOrbitRadius = mix(
+ 0.24,
+ 1.12,
+ pow(particlesGptHash11(a_particlesGptId * 0.537 + 7.0 + u_particlesGptSeed), 0.65)
+ );
+ vec2 particlesGptRing =
+ vec2(cos(particlesGptOrbitAngle), sin(particlesGptOrbitAngle)) *
+ particlesGptOrbitRadius *
+ length(u_particlesGptCanvasSize) *
+ 0.48 *
+ u_particlesGptSpread;
+ vec2 particlesGptStartPosition =
+ u_particlesGptCanvasSize * 0.5 +
+ mix(particlesGptRandomBox, particlesGptRing, 0.62);
+
+ float particlesGptDelay =
+ particlesGptHash11(a_particlesGptId * 0.071 + u_particlesGptSeed) *
+ u_particlesGptDelayWindow;
+ float particlesGptT = clamp(
+ (u_particlesGptPhase - particlesGptDelay) /
+ max(u_particlesGptDuration - particlesGptDelay, 0.001),
+ 0.0,
+ 1.0
+ );
+ float particlesGptProgress = particlesGptEase(particlesGptT);
+
+ vec2 particlesGptDelta = particlesGptTargetPosition - particlesGptStartPosition;
+ float particlesGptDistanceToTarget = max(length(particlesGptDelta), 0.0001);
+ vec2 particlesGptForward = particlesGptDelta / particlesGptDistanceToTarget;
+ vec2 particlesGptSide = vec2(-particlesGptForward.y, particlesGptForward.x);
+
+ float particlesGptFlowPhase =
+ particlesGptHash11(a_particlesGptId * 1.91 + 13.0 + u_particlesGptSeed) *
+ particlesGptTau;
+ float particlesGptField =
+ sin(u_particlesGptTime * 1.25 + particlesGptFlowPhase + particlesGptTargetPosition.y * 0.015) +
+ 0.5 * sin(u_particlesGptTime * 2.1 - particlesGptFlowPhase * 1.3 + particlesGptTargetPosition.x * 0.01);
+ vec2 particlesGptGust = vec2(
+ sin(u_particlesGptTime * 0.92 + particlesGptFlowPhase + particlesGptTargetPosition.y * 0.018),
+ cos(u_particlesGptTime * 1.08 - particlesGptFlowPhase + particlesGptTargetPosition.x * 0.014)
+ );
+
+ float particlesGptEnvelope = (1.0 - particlesGptProgress);
+ particlesGptEnvelope *= particlesGptEnvelope;
+ particlesGptEnvelope *= smoothstep(0.0, 0.08, particlesGptT);
+
+ float particlesGptBend =
+ min(particlesGptDistanceToTarget * 0.16, 56.0) * u_particlesGptWindStrength;
+ vec2 particlesGptWindOffset =
+ particlesGptSide * particlesGptField * particlesGptBend * particlesGptEnvelope;
+ particlesGptWindOffset +=
+ particlesGptGust * (10.0 * u_particlesGptWindStrength) * particlesGptEnvelope;
+ particlesGptWindOffset +=
+ particlesGptForward *
+ sin(u_particlesGptTime * 1.7 + particlesGptFlowPhase * 1.7) *
+ (6.0 * u_particlesGptWindStrength) *
+ particlesGptEnvelope;
+
+ vec2 particlesGptPosition =
+ mix(particlesGptStartPosition, particlesGptTargetPosition, particlesGptProgress) +
+ particlesGptWindOffset;
+ vec2 particlesGptClipPosition = particlesGptScreenToClip(particlesGptPosition);
+
+ gl_PointSize = max(1.0, particlesGptCell * u_particlesGptPointScale);`,
+ position: 'vec4(particlesGptClipPosition, 0.0, 1.0)',
+ },
+ fragment: {
+ main: `
+ vec2 particlesGptCentered = abs(gl_PointCoord - 0.5);
+ float particlesGptEdge = max(particlesGptCentered.x, particlesGptCentered.y);
+ float particlesGptAlpha = 1.0 - smoothstep(0.47, 0.5, particlesGptEdge);
+
+ color = v_particlesGptColor.rgb;
+ alpha = v_particlesGptColor.a * particlesGptAlpha;`,
+ },
+ varying: {
+ v_particlesGptColor: 'vec4',
+ },
+ uniforms: [
+ {
+ name: 'u_particlesGptMap',
+ type: 'i',
+ data: [0],
+ },
+ {
+ name: 'u_particlesGptImageSize',
+ type: 'f',
+ data: [width, height],
+ },
+ {
+ name: 'u_particlesGptCanvasSize',
+ type: 'f',
+ data: [1, 1],
+ },
+ {
+ name: 'u_particlesGptTime',
+ type: 'f',
+ data: [0],
+ },
+ {
+ name: 'u_particlesGptPhase',
+ type: 'f',
+ data: [0],
+ },
+ {
+ name: 'u_particlesGptDuration',
+ type: 'f',
+ data: [Math.max(0.001, duration)],
+ },
+ {
+ name: 'u_particlesGptDelayWindow',
+ type: 'f',
+ data: [Math.max(0, stagger)],
+ },
+ {
+ name: 'u_particlesGptPointScale',
+ type: 'f',
+ data: [Math.max(0.1, pointScale)],
+ },
+ {
+ name: 'u_particlesGptSpread',
+ type: 'f',
+ data: [Math.max(0.01, spread)],
+ },
+ {
+ name: 'u_particlesGptWindStrength',
+ type: 'f',
+ data: [Math.max(0, wind)],
+ },
+ {
+ name: 'u_particlesGptSeed',
+ type: 'f',
+ data: [0],
+ },
+ {
+ name: 'u_particlesGptEaseMode',
+ type: 'i',
+ data: [Math.max(0, EASING_MODES.indexOf(easing))],
+ },
+ ],
+ attributes: [
+ {
+ name: 'a_particlesGptId',
+ size: 1,
+ type: 'FLOAT',
+ data: getParticleIds(maxParticles),
+ },
+ ],
+ textures: [
+ {
+ format: 'RGBA',
+ data: source,
+ update: true,
+ },
+ ],
+ get source() {
+ return this.textures[0].data;
+ },
+ set source(media) {
+ this.textures[0].data = media;
+ this.textures[0].update = true;
+ },
+ get sourceSize() {
+ const [currentWidth, currentHeight] = this.uniforms[1].data;
+ return { width: currentWidth, height: currentHeight };
+ },
+ set sourceSize({ width: nextWidth, height: nextHeight }) {
+ const widthValue = typeof nextWidth === 'number' ? Math.max(1, Math.floor(nextWidth)) : this.uniforms[1].data[0];
+ const heightValue = typeof nextHeight === 'number' ? Math.max(1, Math.floor(nextHeight)) : this.uniforms[1].data[1];
+
+ if (widthValue * heightValue > maxParticles) {
+ throw new Error('particles-gpt :: sourceSize exceeds max particle capacity');
+ }
+
+ this.uniforms[1].data[0] = widthValue;
+ this.uniforms[1].data[1] = heightValue;
+ this.draw.count = widthValue * heightValue;
+ },
+ get canvasSize() {
+ const [widthValue, heightValue] = this.uniforms[2].data;
+ return { width: widthValue, height: heightValue };
+ },
+ set canvasSize({ width: nextWidth, height: nextHeight }) {
+ if (typeof nextWidth === 'number') this.uniforms[2].data[0] = nextWidth;
+ if (typeof nextHeight === 'number') this.uniforms[2].data[1] = nextHeight;
+ },
+ get time() {
+ return this.uniforms[3].data[0];
+ },
+ set time(value) {
+ this.uniforms[3].data[0] = Number(value) || 0;
+ },
+ get phase() {
+ return this.uniforms[4].data[0];
+ },
+ set phase(value) {
+ this.uniforms[4].data[0] = Number(value) || 0;
+ },
+ get duration() {
+ return this.uniforms[5].data[0];
+ },
+ set duration(value) {
+ this.uniforms[5].data[0] = Math.max(0.001, Number(value) || 0.001);
+ },
+ get stagger() {
+ return this.uniforms[6].data[0];
+ },
+ set stagger(value) {
+ this.uniforms[6].data[0] = Math.max(0, Number(value) || 0);
+ },
+ get pointScale() {
+ return this.uniforms[7].data[0];
+ },
+ set pointScale(value) {
+ this.uniforms[7].data[0] = Math.max(0.1, Number(value) || 0.1);
+ },
+ get spread() {
+ return this.uniforms[8].data[0];
+ },
+ set spread(value) {
+ this.uniforms[8].data[0] = Math.max(0.01, Number(value) || 0.01);
+ },
+ get wind() {
+ return this.uniforms[9].data[0];
+ },
+ set wind(value) {
+ this.uniforms[9].data[0] = Math.max(0, Number(value) || 0);
+ },
+ get seed() {
+ return this.uniforms[10].data[0];
+ },
+ set seed(value) {
+ this.uniforms[10].data[0] = Number(value) || 0;
+ },
+ get easing() {
+ return EASING_MODES[this.uniforms[11].data[0]] || EASING_MODES[0];
+ },
+ set easing(value) {
+ const easingIndex = EASING_MODES.indexOf(value);
+ this.uniforms[11].data[0] = easingIndex === -1 ? 0 : easingIndex;
+ },
+ get hold() {
+ return holdDuration;
+ },
+ set hold(value) {
+ holdDuration = Math.max(0, Number(value) || 0);
+ },
+ get cycleDuration() {
+ return this.duration + this.hold;
+ },
+ get maxParticleCount() {
+ return maxParticles;
+ },
+ };
+
+ return effect;
+}
+
/*!
* GLSL textureless classic 3D noise "cnoise",
* with an RSL-style periodic variant "pnoise".
@@ -3247,6 +3667,7 @@ const vertexSimpleTemplate = ({
varying = '',
constant = '',
main = '',
+ position = 'vec4(a_position.xy, 0.0, 1.0)',
}) => `
precision highp float;
${uniform}
@@ -3259,7 +3680,7 @@ ${MATH_PI}
${constant}
void main() {
${main}
- gl_Position = vec4(a_position.xy, 0.0, 1.0);
+ gl_Position = ${position};
}`;
const vertexMediaTemplate = ({
@@ -3268,6 +3689,7 @@ const vertexMediaTemplate = ({
varying = '',
constant = '',
main = '',
+ position = 'vec4(a_position.xy, 0.0, 1.0)',
}) => `
precision highp float;
${uniform}
@@ -3283,7 +3705,7 @@ ${constant}
void main() {
v_texCoord = a_texCoord;
${main}
- gl_Position = vec4(a_position.xy, 0.0, 1.0);
+ gl_Position = ${position};
}`;
const fragmentSimpleTemplate = ({
@@ -3460,7 +3882,8 @@ function draw(gl, plane = {}, media, data, fboData) {
uniforms,
textures,
extensions,
- vao
+ vao,
+ draw: drawConfig
} = data;
const { xSegments = 1, ySegments = 1 } = plane;
@@ -3529,7 +3952,12 @@ function draw(gl, plane = {}, media, data, fboData) {
}
}
- gl.drawArrays(gl.TRIANGLES, 0, 6 * xSegments * ySegments);
+ const mode = (drawConfig && drawConfig.mode) || 'TRIANGLES';
+ const count = typeof drawConfig?.count === 'number'
+ ? drawConfig.count
+ : 6 * xSegments * ySegments;
+
+ gl.drawArrays(gl[mode], 0, count);
}
function drawFBO(gl, fboData) {
@@ -3665,6 +4093,7 @@ function _initProgram(gl, plane, effects, hasFBO = false, noSource = false) {
uniforms,
textures: data.textures,
vao,
+ draw: data.draw,
};
}
@@ -3720,6 +4149,7 @@ function _mergeEffectsData(plane, effects, hasFBO = false, noSource = false) {
uniforms = [],
textures = [],
varying = {},
+ draw,
} = config;
const merge = (shader) =>
Object.keys(config[shader] || {}).forEach((key) => {
@@ -3729,6 +4159,8 @@ function _mergeEffectsData(plane, effects, hasFBO = false, noSource = false) {
key === 'source'
) {
result[shader][key] += config[shader][key] + '\n';
+ } else if (key === 'position') {
+ result[shader][key] = config[shader][key];
} else {
result[shader][key] = {
...result[shader][key],
@@ -3775,6 +4207,10 @@ function _mergeEffectsData(plane, effects, hasFBO = false, noSource = false) {
result.uniforms.push(...uniforms);
result.textures.push(...textures);
+ if (draw) {
+ result.draw = draw;
+ }
+
Object.assign(result.vertex.varying, varying);
Object.assign(result.fragment.varying, varying);
@@ -3873,6 +4309,7 @@ function getEffectDefaults(plane, hasFBO, noSource) {
varying: {},
constant: '',
main: '',
+ position: 'vec4(a_position.xy, 0.0, 1.0)',
},
fragment: {
uniform: {},
@@ -3887,6 +4324,10 @@ function getEffectDefaults(plane, hasFBO, noSource) {
* Default textures
*/
textures: [],
+ draw: {
+ mode: 'TRIANGLES',
+ count: 6 * (plane.xSegments || 1) * (plane.ySegments || 1),
+ },
};
}
@@ -4043,6 +4484,11 @@ function createTexture(
function _createBuffer(gl, program, name, data) {
const location = gl.getAttribLocation(program, name);
+
+ if (location === -1) {
+ return { location, buffer: null };
+ }
+
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
@@ -4112,6 +4558,10 @@ function _enableVertexAttributes(gl, attributes) {
(attributes || []).forEach((attrib) => {
const { location, buffer, size, type } = attrib;
+ if (location === -1 || !buffer) {
+ return;
+ }
+
gl.enableVertexAttribArray(location);
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.vertexAttribPointer(location, size, gl[type], false, 0, 0);
@@ -4349,6 +4799,10 @@ class Kampos {
this.data = data;
this.fboData = fboData;
+ if (noSource && data.textures && data.textures.length) {
+ this._createTextures();
+ }
+
// cache for restoring context
this.config = config;
@@ -4589,13 +5043,15 @@ class Kampos {
}
_createTextures() {
+ const dimensions = this.dimensions || {};
+
this.data &&
this.data.textures.forEach((texture, i) => {
const data = this.data.textures[i];
data.texture = createTexture(this.gl, {
- width: this.dimensions.width,
- height: this.dimensions.height,
+ width: dimensions.width,
+ height: dimensions.height,
format: texture.format,
data: texture.data,
wrap: texture.wrap,
@@ -4772,6 +5228,7 @@ const effects = {
duotone,
hueSaturation,
kaleidoscope,
+ particlesGpt,
turbulence,
slitScan,
flowmapGridDisplacement,
diff --git a/index.js b/index.js
index b8a910f..0789eb4 100644
--- a/index.js
+++ b/index.js
@@ -11,6 +11,8 @@ import displacement from './src/effects/displacement.js';
import channelSplit from './src/effects/channel-split.js';
import kaleidoscope from './src/effects/kaleidoscope.js';
import slitScan from './src/effects/slit-scan.js';
+import particlesGpt from './src/effects/particles-gpt.js';
+import particlesSonnet from './src/effects/particles-sonnet.js';
import perlinNoise from './src/noise/perlin-noise-3d.js';
import cellular from './src/noise/cellular-noise-3d.js';
import simplex from './src/noise/simplex-3d.js';
@@ -38,6 +40,8 @@ export const effects = {
duotone,
hueSaturation,
kaleidoscope,
+ particlesGpt,
+ particlesSonnet,
turbulence,
slitScan,
flowmapGridDisplacement,
diff --git a/index.umd.js b/index.umd.js
index 7679e7b..a0b87eb 100644
--- a/index.umd.js
+++ b/index.umd.js
@@ -1464,6 +1464,426 @@ const mat3 satmat = mat3(
};
}
+ const EASING_MODES = ['smooth', 'linear', 'outQuad', 'inOutSine', 'inOutCubic'];
+ const particleIdCache = new Map();
+
+ function getParticleIds(maxParticles) {
+ if (!particleIdCache.has(maxParticles)) {
+ const particleIds = new Float32Array(maxParticles);
+ for (let i = 0; i < maxParticles; i++) {
+ particleIds[i] = i;
+ }
+ particleIdCache.set(maxParticles, particleIds);
+ }
+
+ return particleIdCache.get(maxParticles);
+ }
+
+ function createSourceCanvas(width, height) {
+ const canvas = document.createElement('canvas');
+ canvas.width = width;
+ canvas.height = height;
+ return canvas;
+ }
+
+ /**
+ * Particle image assembly effect rendered as point sprites.
+ *
+ * Requires vertex texture fetch support.
+ *
+ * @function particlesGpt
+ * @param {Object} [params]
+ * @param {number} [params.width=192] active source width / particle columns
+ * @param {number} [params.height=108] active source height / particle rows
+ * @param {number} [params.maxWidth=1024] max source width used to size the particle id buffer
+ * @param {number} [params.maxHeight=576] max source height used to size the particle id buffer
+ * @param {number} [params.duration=6.0] animation duration in seconds
+ * @param {number} [params.hold=1.8] hold duration in seconds
+ * @param {number} [params.pointScale=1.12] point size multiplier
+ * @param {number} [params.spread=1.0] starting spread multiplier
+ * @param {number} [params.wind=1.0] wind multiplier
+ * @param {number} [params.stagger=1.15] random start delay window in seconds
+ * @param {string} [params.easing='smooth'] easing mode
+ * @param {HTMLCanvasElement|ImageBitmap|HTMLImageElement} [params.source] source texture
+ * @returns {particlesGptEffect}
+ */
+ function particlesGpt({
+ width = 192,
+ height = 108,
+ maxWidth = 1024,
+ maxHeight = 576,
+ duration = 6.0,
+ hold = 1.8,
+ pointScale = 1.12,
+ spread = 1.0,
+ wind = 1.0,
+ stagger = 1.15,
+ easing = EASING_MODES[0],
+ source = createSourceCanvas(width, height),
+ } = {}) {
+ const maxParticles = maxWidth * maxHeight;
+
+ if (width * height > maxParticles) {
+ throw new Error('particles-gpt :: width * height exceeds max particle capacity');
+ }
+
+ const draw = {
+ mode: 'POINTS',
+ count: width * height,
+ };
+
+ let holdDuration = Math.max(0, hold);
+
+ const effect = {
+ draw,
+ vertex: {
+ uniform: {
+ u_particlesGptMap: 'sampler2D',
+ u_particlesGptImageSize: 'vec2',
+ u_particlesGptCanvasSize: 'vec2',
+ u_particlesGptTime: 'float',
+ u_particlesGptPhase: 'float',
+ u_particlesGptDuration: 'float',
+ u_particlesGptDelayWindow: 'float',
+ u_particlesGptPointScale: 'float',
+ u_particlesGptSpread: 'float',
+ u_particlesGptWindStrength: 'float',
+ u_particlesGptSeed: 'float',
+ u_particlesGptEaseMode: 'int',
+ },
+ attribute: {
+ a_particlesGptId: 'float',
+ },
+ constant: `
+const float particlesGptTau = 6.283185307179586;
+
+float particlesGptHash11(float p) {
+ p = fract(p * 0.1031);
+ p *= p + 33.33;
+ p *= p + p;
+ return fract(p);
+}
+
+vec2 particlesGptHash21(float p) {
+ vec3 q = fract(vec3(p) * vec3(0.1031, 0.1030, 0.0973));
+ q += dot(q, q.yzx + 33.33);
+ return fract((q.xx + q.yz) * q.zy);
+}
+
+float particlesGptSmoother(float t) {
+ return t * t * t * (t * (t * 6.0 - 15.0) + 10.0);
+}
+
+float particlesGptEase(float t) {
+ if (u_particlesGptEaseMode == 1) {
+ return t;
+ }
+
+ if (u_particlesGptEaseMode == 2) {
+ return 1.0 - pow(1.0 - t, 2.0);
+ }
+
+ if (u_particlesGptEaseMode == 3) {
+ return 0.5 - 0.5 * cos(t * PI);
+ }
+
+ if (u_particlesGptEaseMode == 4) {
+ return t < 0.5
+ ? 4.0 * t * t * t
+ : 1.0 - pow(-2.0 * t + 2.0, 3.0) * 0.5;
+ }
+
+ return particlesGptSmoother(t);
+}
+
+vec2 particlesGptScreenToClip(vec2 pixelPosition) {
+ vec2 clip = pixelPosition / u_particlesGptCanvasSize * 2.0 - 1.0;
+ clip.y *= -1.0;
+ return clip;
+}`,
+ main: `
+ float particlesGptX = mod(a_particlesGptId, u_particlesGptImageSize.x);
+ float particlesGptY = floor(a_particlesGptId / u_particlesGptImageSize.x);
+ vec2 particlesGptUv = (
+ vec2(particlesGptX, particlesGptY) + 0.5
+ ) / u_particlesGptImageSize;
+ v_particlesGptColor = texture2D(u_particlesGptMap, particlesGptUv);
+
+ vec2 particlesGptImageFit = u_particlesGptCanvasSize * 0.58;
+ float particlesGptCell = min(
+ particlesGptImageFit.x / u_particlesGptImageSize.x,
+ particlesGptImageFit.y / u_particlesGptImageSize.y
+ );
+ vec2 particlesGptTargetPosition =
+ (vec2(particlesGptX + 0.5, particlesGptY + 0.5) - u_particlesGptImageSize * 0.5) *
+ particlesGptCell +
+ u_particlesGptCanvasSize * 0.5;
+
+ vec2 particlesGptRandomBox =
+ (particlesGptHash21(a_particlesGptId + 19.7 + u_particlesGptSeed) - 0.5) *
+ u_particlesGptCanvasSize *
+ (1.9 * u_particlesGptSpread);
+ float particlesGptOrbitAngle =
+ particlesGptHash11(a_particlesGptId * 0.173 + 4.1 + u_particlesGptSeed) *
+ particlesGptTau;
+ float particlesGptOrbitRadius = mix(
+ 0.24,
+ 1.12,
+ pow(particlesGptHash11(a_particlesGptId * 0.537 + 7.0 + u_particlesGptSeed), 0.65)
+ );
+ vec2 particlesGptRing =
+ vec2(cos(particlesGptOrbitAngle), sin(particlesGptOrbitAngle)) *
+ particlesGptOrbitRadius *
+ length(u_particlesGptCanvasSize) *
+ 0.48 *
+ u_particlesGptSpread;
+ vec2 particlesGptStartPosition =
+ u_particlesGptCanvasSize * 0.5 +
+ mix(particlesGptRandomBox, particlesGptRing, 0.62);
+
+ float particlesGptDelay =
+ particlesGptHash11(a_particlesGptId * 0.071 + u_particlesGptSeed) *
+ u_particlesGptDelayWindow;
+ float particlesGptT = clamp(
+ (u_particlesGptPhase - particlesGptDelay) /
+ max(u_particlesGptDuration - particlesGptDelay, 0.001),
+ 0.0,
+ 1.0
+ );
+ float particlesGptProgress = particlesGptEase(particlesGptT);
+
+ vec2 particlesGptDelta = particlesGptTargetPosition - particlesGptStartPosition;
+ float particlesGptDistanceToTarget = max(length(particlesGptDelta), 0.0001);
+ vec2 particlesGptForward = particlesGptDelta / particlesGptDistanceToTarget;
+ vec2 particlesGptSide = vec2(-particlesGptForward.y, particlesGptForward.x);
+
+ float particlesGptFlowPhase =
+ particlesGptHash11(a_particlesGptId * 1.91 + 13.0 + u_particlesGptSeed) *
+ particlesGptTau;
+ float particlesGptField =
+ sin(u_particlesGptTime * 1.25 + particlesGptFlowPhase + particlesGptTargetPosition.y * 0.015) +
+ 0.5 * sin(u_particlesGptTime * 2.1 - particlesGptFlowPhase * 1.3 + particlesGptTargetPosition.x * 0.01);
+ vec2 particlesGptGust = vec2(
+ sin(u_particlesGptTime * 0.92 + particlesGptFlowPhase + particlesGptTargetPosition.y * 0.018),
+ cos(u_particlesGptTime * 1.08 - particlesGptFlowPhase + particlesGptTargetPosition.x * 0.014)
+ );
+
+ float particlesGptEnvelope = (1.0 - particlesGptProgress);
+ particlesGptEnvelope *= particlesGptEnvelope;
+ particlesGptEnvelope *= smoothstep(0.0, 0.08, particlesGptT);
+
+ float particlesGptBend =
+ min(particlesGptDistanceToTarget * 0.16, 56.0) * u_particlesGptWindStrength;
+ vec2 particlesGptWindOffset =
+ particlesGptSide * particlesGptField * particlesGptBend * particlesGptEnvelope;
+ particlesGptWindOffset +=
+ particlesGptGust * (10.0 * u_particlesGptWindStrength) * particlesGptEnvelope;
+ particlesGptWindOffset +=
+ particlesGptForward *
+ sin(u_particlesGptTime * 1.7 + particlesGptFlowPhase * 1.7) *
+ (6.0 * u_particlesGptWindStrength) *
+ particlesGptEnvelope;
+
+ vec2 particlesGptPosition =
+ mix(particlesGptStartPosition, particlesGptTargetPosition, particlesGptProgress) +
+ particlesGptWindOffset;
+ vec2 particlesGptClipPosition = particlesGptScreenToClip(particlesGptPosition);
+
+ gl_PointSize = max(1.0, particlesGptCell * u_particlesGptPointScale);`,
+ position: 'vec4(particlesGptClipPosition, 0.0, 1.0)',
+ },
+ fragment: {
+ main: `
+ vec2 particlesGptCentered = abs(gl_PointCoord - 0.5);
+ float particlesGptEdge = max(particlesGptCentered.x, particlesGptCentered.y);
+ float particlesGptAlpha = 1.0 - smoothstep(0.47, 0.5, particlesGptEdge);
+
+ color = v_particlesGptColor.rgb;
+ alpha = v_particlesGptColor.a * particlesGptAlpha;`,
+ },
+ varying: {
+ v_particlesGptColor: 'vec4',
+ },
+ uniforms: [
+ {
+ name: 'u_particlesGptMap',
+ type: 'i',
+ data: [0],
+ },
+ {
+ name: 'u_particlesGptImageSize',
+ type: 'f',
+ data: [width, height],
+ },
+ {
+ name: 'u_particlesGptCanvasSize',
+ type: 'f',
+ data: [1, 1],
+ },
+ {
+ name: 'u_particlesGptTime',
+ type: 'f',
+ data: [0],
+ },
+ {
+ name: 'u_particlesGptPhase',
+ type: 'f',
+ data: [0],
+ },
+ {
+ name: 'u_particlesGptDuration',
+ type: 'f',
+ data: [Math.max(0.001, duration)],
+ },
+ {
+ name: 'u_particlesGptDelayWindow',
+ type: 'f',
+ data: [Math.max(0, stagger)],
+ },
+ {
+ name: 'u_particlesGptPointScale',
+ type: 'f',
+ data: [Math.max(0.1, pointScale)],
+ },
+ {
+ name: 'u_particlesGptSpread',
+ type: 'f',
+ data: [Math.max(0.01, spread)],
+ },
+ {
+ name: 'u_particlesGptWindStrength',
+ type: 'f',
+ data: [Math.max(0, wind)],
+ },
+ {
+ name: 'u_particlesGptSeed',
+ type: 'f',
+ data: [0],
+ },
+ {
+ name: 'u_particlesGptEaseMode',
+ type: 'i',
+ data: [Math.max(0, EASING_MODES.indexOf(easing))],
+ },
+ ],
+ attributes: [
+ {
+ name: 'a_particlesGptId',
+ size: 1,
+ type: 'FLOAT',
+ data: getParticleIds(maxParticles),
+ },
+ ],
+ textures: [
+ {
+ format: 'RGBA',
+ data: source,
+ update: true,
+ },
+ ],
+ get source() {
+ return this.textures[0].data;
+ },
+ set source(media) {
+ this.textures[0].data = media;
+ this.textures[0].update = true;
+ },
+ get sourceSize() {
+ const [currentWidth, currentHeight] = this.uniforms[1].data;
+ return { width: currentWidth, height: currentHeight };
+ },
+ set sourceSize({ width: nextWidth, height: nextHeight }) {
+ const widthValue = typeof nextWidth === 'number' ? Math.max(1, Math.floor(nextWidth)) : this.uniforms[1].data[0];
+ const heightValue = typeof nextHeight === 'number' ? Math.max(1, Math.floor(nextHeight)) : this.uniforms[1].data[1];
+
+ if (widthValue * heightValue > maxParticles) {
+ throw new Error('particles-gpt :: sourceSize exceeds max particle capacity');
+ }
+
+ this.uniforms[1].data[0] = widthValue;
+ this.uniforms[1].data[1] = heightValue;
+ this.draw.count = widthValue * heightValue;
+ },
+ get canvasSize() {
+ const [widthValue, heightValue] = this.uniforms[2].data;
+ return { width: widthValue, height: heightValue };
+ },
+ set canvasSize({ width: nextWidth, height: nextHeight }) {
+ if (typeof nextWidth === 'number') this.uniforms[2].data[0] = nextWidth;
+ if (typeof nextHeight === 'number') this.uniforms[2].data[1] = nextHeight;
+ },
+ get time() {
+ return this.uniforms[3].data[0];
+ },
+ set time(value) {
+ this.uniforms[3].data[0] = Number(value) || 0;
+ },
+ get phase() {
+ return this.uniforms[4].data[0];
+ },
+ set phase(value) {
+ this.uniforms[4].data[0] = Number(value) || 0;
+ },
+ get duration() {
+ return this.uniforms[5].data[0];
+ },
+ set duration(value) {
+ this.uniforms[5].data[0] = Math.max(0.001, Number(value) || 0.001);
+ },
+ get stagger() {
+ return this.uniforms[6].data[0];
+ },
+ set stagger(value) {
+ this.uniforms[6].data[0] = Math.max(0, Number(value) || 0);
+ },
+ get pointScale() {
+ return this.uniforms[7].data[0];
+ },
+ set pointScale(value) {
+ this.uniforms[7].data[0] = Math.max(0.1, Number(value) || 0.1);
+ },
+ get spread() {
+ return this.uniforms[8].data[0];
+ },
+ set spread(value) {
+ this.uniforms[8].data[0] = Math.max(0.01, Number(value) || 0.01);
+ },
+ get wind() {
+ return this.uniforms[9].data[0];
+ },
+ set wind(value) {
+ this.uniforms[9].data[0] = Math.max(0, Number(value) || 0);
+ },
+ get seed() {
+ return this.uniforms[10].data[0];
+ },
+ set seed(value) {
+ this.uniforms[10].data[0] = Number(value) || 0;
+ },
+ get easing() {
+ return EASING_MODES[this.uniforms[11].data[0]] || EASING_MODES[0];
+ },
+ set easing(value) {
+ const easingIndex = EASING_MODES.indexOf(value);
+ this.uniforms[11].data[0] = easingIndex === -1 ? 0 : easingIndex;
+ },
+ get hold() {
+ return holdDuration;
+ },
+ set hold(value) {
+ holdDuration = Math.max(0, Number(value) || 0);
+ },
+ get cycleDuration() {
+ return this.duration + this.hold;
+ },
+ get maxParticleCount() {
+ return maxParticles;
+ },
+ };
+
+ return effect;
+ }
+
/*!
* GLSL textureless classic 3D noise "cnoise",
* with an RSL-style periodic variant "pnoise".
@@ -3251,6 +3671,7 @@ float turbulence (vec3 seed, vec2 frequency, int numOctaves, bool isFractal) {
varying = '',
constant = '',
main = '',
+ position = 'vec4(a_position.xy, 0.0, 1.0)',
}) => `
precision highp float;
${uniform}
@@ -3263,7 +3684,7 @@ ${MATH_PI}
${constant}
void main() {
${main}
- gl_Position = vec4(a_position.xy, 0.0, 1.0);
+ gl_Position = ${position};
}`;
const vertexMediaTemplate = ({
@@ -3272,6 +3693,7 @@ void main() {
varying = '',
constant = '',
main = '',
+ position = 'vec4(a_position.xy, 0.0, 1.0)',
}) => `
precision highp float;
${uniform}
@@ -3287,7 +3709,7 @@ ${constant}
void main() {
v_texCoord = a_texCoord;
${main}
- gl_Position = vec4(a_position.xy, 0.0, 1.0);
+ gl_Position = ${position};
}`;
const fragmentSimpleTemplate = ({
@@ -3464,7 +3886,8 @@ void main() {
uniforms,
textures,
extensions,
- vao
+ vao,
+ draw: drawConfig
} = data;
const { xSegments = 1, ySegments = 1 } = plane;
@@ -3533,7 +3956,12 @@ void main() {
}
}
- gl.drawArrays(gl.TRIANGLES, 0, 6 * xSegments * ySegments);
+ const mode = (drawConfig && drawConfig.mode) || 'TRIANGLES';
+ const count = typeof drawConfig?.count === 'number'
+ ? drawConfig.count
+ : 6 * xSegments * ySegments;
+
+ gl.drawArrays(gl[mode], 0, count);
}
function drawFBO(gl, fboData) {
@@ -3669,6 +4097,7 @@ void main() {
uniforms,
textures: data.textures,
vao,
+ draw: data.draw,
};
}
@@ -3724,6 +4153,7 @@ void main() {
uniforms = [],
textures = [],
varying = {},
+ draw,
} = config;
const merge = (shader) =>
Object.keys(config[shader] || {}).forEach((key) => {
@@ -3733,6 +4163,8 @@ void main() {
key === 'source'
) {
result[shader][key] += config[shader][key] + '\n';
+ } else if (key === 'position') {
+ result[shader][key] = config[shader][key];
} else {
result[shader][key] = {
...result[shader][key],
@@ -3779,6 +4211,10 @@ void main() {
result.uniforms.push(...uniforms);
result.textures.push(...textures);
+ if (draw) {
+ result.draw = draw;
+ }
+
Object.assign(result.vertex.varying, varying);
Object.assign(result.fragment.varying, varying);
@@ -3877,6 +4313,7 @@ void main() {
varying: {},
constant: '',
main: '',
+ position: 'vec4(a_position.xy, 0.0, 1.0)',
},
fragment: {
uniform: {},
@@ -3891,6 +4328,10 @@ void main() {
* Default textures
*/
textures: [],
+ draw: {
+ mode: 'TRIANGLES',
+ count: 6 * (plane.xSegments || 1) * (plane.ySegments || 1),
+ },
};
}
@@ -4047,6 +4488,11 @@ void main() {
function _createBuffer(gl, program, name, data) {
const location = gl.getAttribLocation(program, name);
+
+ if (location === -1) {
+ return { location, buffer: null };
+ }
+
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
@@ -4116,6 +4562,10 @@ void main() {
(attributes || []).forEach((attrib) => {
const { location, buffer, size, type } = attrib;
+ if (location === -1 || !buffer) {
+ return;
+ }
+
gl.enableVertexAttribArray(location);
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.vertexAttribPointer(location, size, gl[type], false, 0, 0);
@@ -4353,6 +4803,10 @@ void main() {
this.data = data;
this.fboData = fboData;
+ if (noSource && data.textures && data.textures.length) {
+ this._createTextures();
+ }
+
// cache for restoring context
this.config = config;
@@ -4593,13 +5047,15 @@ void main() {
}
_createTextures() {
+ const dimensions = this.dimensions || {};
+
this.data &&
this.data.textures.forEach((texture, i) => {
const data = this.data.textures[i];
data.texture = createTexture(this.gl, {
- width: this.dimensions.width,
- height: this.dimensions.height,
+ width: dimensions.width,
+ height: dimensions.height,
format: texture.format,
data: texture.data,
wrap: texture.wrap,
@@ -4776,6 +5232,7 @@ void main() {
duotone,
hueSaturation,
kaleidoscope,
+ particlesGpt,
turbulence,
slitScan,
flowmapGridDisplacement,
diff --git a/src/core.js b/src/core.js
index 6f55868..2b4d5cc 100644
--- a/src/core.js
+++ b/src/core.js
@@ -8,6 +8,7 @@ const vertexSimpleTemplate = ({
varying = '',
constant = '',
main = '',
+ position = 'vec4(a_position.xy, 0.0, 1.0)',
}) => `
precision highp float;
${uniform}
@@ -20,7 +21,7 @@ ${MATH_PI}
${constant}
void main() {
${main}
- gl_Position = vec4(a_position.xy, 0.0, 1.0);
+ gl_Position = ${position};
}`;
const vertexMediaTemplate = ({
@@ -29,6 +30,7 @@ const vertexMediaTemplate = ({
varying = '',
constant = '',
main = '',
+ position = 'vec4(a_position.xy, 0.0, 1.0)',
}) => `
precision highp float;
${uniform}
@@ -44,7 +46,7 @@ ${constant}
void main() {
v_texCoord = a_texCoord;
${main}
- gl_Position = vec4(a_position.xy, 0.0, 1.0);
+ gl_Position = ${position};
}`;
const fragmentSimpleTemplate = ({
@@ -221,7 +223,8 @@ export function draw(gl, plane = {}, media, data, fboData) {
uniforms,
textures,
extensions,
- vao
+ vao,
+ draw: drawConfig
} = data;
const { xSegments = 1, ySegments = 1 } = plane;
@@ -290,7 +293,12 @@ export function draw(gl, plane = {}, media, data, fboData) {
}
}
- gl.drawArrays(gl.TRIANGLES, 0, 6 * xSegments * ySegments);
+ const mode = (drawConfig && drawConfig.mode) || 'TRIANGLES';
+ const count = typeof drawConfig?.count === 'number'
+ ? drawConfig.count
+ : 6 * xSegments * ySegments;
+
+ gl.drawArrays(gl[mode], 0, count);
}
function drawFBO(gl, fboData) {
@@ -426,6 +434,7 @@ function _initProgram(gl, plane, effects, hasFBO = false, noSource = false) {
uniforms,
textures: data.textures,
vao,
+ draw: data.draw,
};
}
@@ -481,6 +490,7 @@ function _mergeEffectsData(plane, effects, hasFBO = false, noSource = false) {
uniforms = [],
textures = [],
varying = {},
+ draw,
} = config;
const merge = (shader) =>
Object.keys(config[shader] || {}).forEach((key) => {
@@ -490,6 +500,8 @@ function _mergeEffectsData(plane, effects, hasFBO = false, noSource = false) {
key === 'source'
) {
result[shader][key] += config[shader][key] + '\n';
+ } else if (key === 'position') {
+ result[shader][key] = config[shader][key];
} else {
result[shader][key] = {
...result[shader][key],
@@ -536,6 +548,10 @@ function _mergeEffectsData(plane, effects, hasFBO = false, noSource = false) {
result.uniforms.push(...uniforms);
result.textures.push(...textures);
+ if (draw) {
+ result.draw = draw;
+ }
+
Object.assign(result.vertex.varying, varying);
Object.assign(result.fragment.varying, varying);
@@ -634,6 +650,7 @@ function getEffectDefaults(plane, hasFBO, noSource) {
varying: {},
constant: '',
main: '',
+ position: 'vec4(a_position.xy, 0.0, 1.0)',
},
fragment: {
uniform: {},
@@ -648,6 +665,10 @@ function getEffectDefaults(plane, hasFBO, noSource) {
* Default textures
*/
textures: [],
+ draw: {
+ mode: 'TRIANGLES',
+ count: 6 * (plane.xSegments || 1) * (plane.ySegments || 1),
+ },
};
}
@@ -742,6 +763,8 @@ function _createShader(gl, type, source) {
* @param {ArrayBufferView|ImageData|HTMLImageElement|HTMLCanvasElement|HTMLVideoElement|ImageBitmap} config.data
* @param {string} config.format
* @param {Object} config.wrap
+ * @param {string} [config.filter] defaults to 'LINEAR'
+ * @param {string} [config.textureType] defaults to 'UNSIGNED_BYTE'; use 'FLOAT' for float textures (requires OES_texture_float)
* @return {{texture: WebGLTexture, width: number, height: number}}
*/
export function createTexture(
@@ -756,6 +779,14 @@ export function createTexture(
textureType = 'UNSIGNED_BYTE',
} = {},
) {
+ if (textureType === 'FLOAT') {
+ const ext = gl.getExtension('OES_texture_float');
+
+ if (!ext) {
+ throw new Error('kampos: OES_texture_float is not supported on this device');
+ }
+ }
+
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
@@ -774,8 +805,21 @@ export function createTexture(
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl[filter]);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl[filter]);
- if (data) {
- // Upload the image into the texture
+ if (ArrayBuffer.isView(data)) {
+ // Typed array — WebGL requires explicit dimensions for this overload
+ gl.texImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ gl[format],
+ width,
+ height,
+ 0,
+ gl[format],
+ gl[textureType],
+ data,
+ );
+ } else if (data) {
+ // HTMLCanvasElement, HTMLImageElement, ImageBitmap, etc. — dimensions are inferred
gl.texImage2D(
gl.TEXTURE_2D,
0,
@@ -785,7 +829,7 @@ export function createTexture(
data,
);
} else {
- // Create empty texture
+ // Empty texture
gl.texImage2D(
gl.TEXTURE_2D,
0,
@@ -804,6 +848,11 @@ export function createTexture(
function _createBuffer(gl, program, name, data) {
const location = gl.getAttribLocation(program, name);
+
+ if (location === -1) {
+ return { location, buffer: null };
+ }
+
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
@@ -873,6 +922,10 @@ function _enableVertexAttributes(gl, attributes) {
(attributes || []).forEach((attrib) => {
const { location, buffer, size, type } = attrib;
+ if (location === -1 || !buffer) {
+ return;
+ }
+
gl.enableVertexAttribArray(location);
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.vertexAttribPointer(location, size, gl[type], false, 0, 0);
diff --git a/src/effects/particles-gpt.js b/src/effects/particles-gpt.js
new file mode 100644
index 0000000..94a0121
--- /dev/null
+++ b/src/effects/particles-gpt.js
@@ -0,0 +1,423 @@
+const EASING_MODES = ['smooth', 'linear', 'outQuad', 'inOutSine', 'inOutCubic'];
+const particleIdCache = new Map();
+
+function getParticleIds(maxParticles) {
+ if (!particleIdCache.has(maxParticles)) {
+ const particleIds = new Float32Array(maxParticles);
+ for (let i = 0; i < maxParticles; i++) {
+ particleIds[i] = i;
+ }
+ particleIdCache.set(maxParticles, particleIds);
+ }
+
+ return particleIdCache.get(maxParticles);
+}
+
+function createSourceCanvas(width, height) {
+ const canvas = document.createElement('canvas');
+ canvas.width = width;
+ canvas.height = height;
+ return canvas;
+}
+
+function clamp(value, min, max) {
+ return Math.min(max, Math.max(min, value));
+}
+
+/**
+ * Particle image assembly effect rendered as point sprites.
+ *
+ * Requires vertex texture fetch support.
+ *
+ * @function particlesGpt
+ * @param {Object} [params]
+ * @param {number} [params.width=192] active source width / particle columns
+ * @param {number} [params.height=108] active source height / particle rows
+ * @param {number} [params.maxWidth=1024] max source width used to size the particle id buffer
+ * @param {number} [params.maxHeight=576] max source height used to size the particle id buffer
+ * @param {number} [params.duration=6.0] animation duration in seconds
+ * @param {number} [params.hold=1.8] hold duration in seconds
+ * @param {number} [params.pointScale=1.12] point size multiplier
+ * @param {number} [params.spread=1.0] starting spread multiplier
+ * @param {number} [params.wind=1.0] wind multiplier
+ * @param {number} [params.stagger=1.15] random start delay window in seconds
+ * @param {string} [params.easing='smooth'] easing mode
+ * @param {HTMLCanvasElement|ImageBitmap|HTMLImageElement} [params.source] source texture
+ * @returns {particlesGptEffect}
+ */
+export default function particlesGpt({
+ width = 192,
+ height = 108,
+ maxWidth = 1024,
+ maxHeight = 576,
+ duration = 6.0,
+ hold = 1.8,
+ pointScale = 1.12,
+ spread = 1.0,
+ wind = 1.0,
+ stagger = 1.15,
+ easing = EASING_MODES[0],
+ source = createSourceCanvas(width, height),
+} = {}) {
+ const maxParticles = maxWidth * maxHeight;
+
+ if (width * height > maxParticles) {
+ throw new Error('particles-gpt :: width * height exceeds max particle capacity');
+ }
+
+ const draw = {
+ mode: 'POINTS',
+ count: width * height,
+ };
+
+ let holdDuration = Math.max(0, hold);
+
+ const effect = {
+ draw,
+ vertex: {
+ uniform: {
+ u_particlesGptMap: 'sampler2D',
+ u_particlesGptImageSize: 'vec2',
+ u_particlesGptCanvasSize: 'vec2',
+ u_particlesGptTime: 'float',
+ u_particlesGptPhase: 'float',
+ u_particlesGptDuration: 'float',
+ u_particlesGptDelayWindow: 'float',
+ u_particlesGptPointScale: 'float',
+ u_particlesGptSpread: 'float',
+ u_particlesGptWindStrength: 'float',
+ u_particlesGptSeed: 'float',
+ u_particlesGptEaseMode: 'int',
+ },
+ attribute: {
+ a_particlesGptId: 'float',
+ },
+ constant: `
+const float particlesGptTau = 6.283185307179586;
+
+float particlesGptHash11(float p) {
+ p = fract(p * 0.1031);
+ p *= p + 33.33;
+ p *= p + p;
+ return fract(p);
+}
+
+vec2 particlesGptHash21(float p) {
+ vec3 q = fract(vec3(p) * vec3(0.1031, 0.1030, 0.0973));
+ q += dot(q, q.yzx + 33.33);
+ return fract((q.xx + q.yz) * q.zy);
+}
+
+float particlesGptSmoother(float t) {
+ return t * t * t * (t * (t * 6.0 - 15.0) + 10.0);
+}
+
+float particlesGptEase(float t) {
+ if (u_particlesGptEaseMode == 1) {
+ return t;
+ }
+
+ if (u_particlesGptEaseMode == 2) {
+ return 1.0 - pow(1.0 - t, 2.0);
+ }
+
+ if (u_particlesGptEaseMode == 3) {
+ return 0.5 - 0.5 * cos(t * PI);
+ }
+
+ if (u_particlesGptEaseMode == 4) {
+ return t < 0.5
+ ? 4.0 * t * t * t
+ : 1.0 - pow(-2.0 * t + 2.0, 3.0) * 0.5;
+ }
+
+ return particlesGptSmoother(t);
+}
+
+vec2 particlesGptScreenToClip(vec2 pixelPosition) {
+ vec2 clip = pixelPosition / u_particlesGptCanvasSize * 2.0 - 1.0;
+ clip.y *= -1.0;
+ return clip;
+}`,
+ main: `
+ float particlesGptX = mod(a_particlesGptId, u_particlesGptImageSize.x);
+ float particlesGptY = floor(a_particlesGptId / u_particlesGptImageSize.x);
+ vec2 particlesGptUv = (
+ vec2(particlesGptX, particlesGptY) + 0.5
+ ) / u_particlesGptImageSize;
+ v_particlesGptColor = texture2D(u_particlesGptMap, particlesGptUv);
+
+ vec2 particlesGptImageFit = u_particlesGptCanvasSize * 0.58;
+ float particlesGptCell = min(
+ particlesGptImageFit.x / u_particlesGptImageSize.x,
+ particlesGptImageFit.y / u_particlesGptImageSize.y
+ );
+ vec2 particlesGptTargetPosition =
+ (vec2(particlesGptX + 0.5, particlesGptY + 0.5) - u_particlesGptImageSize * 0.5) *
+ particlesGptCell +
+ u_particlesGptCanvasSize * 0.5;
+
+ vec2 particlesGptRandomBox =
+ (particlesGptHash21(a_particlesGptId + 19.7 + u_particlesGptSeed) - 0.5) *
+ u_particlesGptCanvasSize *
+ (1.9 * u_particlesGptSpread);
+ float particlesGptOrbitAngle =
+ particlesGptHash11(a_particlesGptId * 0.173 + 4.1 + u_particlesGptSeed) *
+ particlesGptTau;
+ float particlesGptOrbitRadius = mix(
+ 0.24,
+ 1.12,
+ pow(particlesGptHash11(a_particlesGptId * 0.537 + 7.0 + u_particlesGptSeed), 0.65)
+ );
+ vec2 particlesGptRing =
+ vec2(cos(particlesGptOrbitAngle), sin(particlesGptOrbitAngle)) *
+ particlesGptOrbitRadius *
+ length(u_particlesGptCanvasSize) *
+ 0.48 *
+ u_particlesGptSpread;
+ vec2 particlesGptStartPosition =
+ u_particlesGptCanvasSize * 0.5 +
+ mix(particlesGptRandomBox, particlesGptRing, 0.62);
+
+ float particlesGptDelay =
+ particlesGptHash11(a_particlesGptId * 0.071 + u_particlesGptSeed) *
+ u_particlesGptDelayWindow;
+ float particlesGptT = clamp(
+ (u_particlesGptPhase - particlesGptDelay) /
+ max(u_particlesGptDuration - particlesGptDelay, 0.001),
+ 0.0,
+ 1.0
+ );
+ float particlesGptProgress = particlesGptEase(particlesGptT);
+
+ vec2 particlesGptDelta = particlesGptTargetPosition - particlesGptStartPosition;
+ float particlesGptDistanceToTarget = max(length(particlesGptDelta), 0.0001);
+ vec2 particlesGptForward = particlesGptDelta / particlesGptDistanceToTarget;
+ vec2 particlesGptSide = vec2(-particlesGptForward.y, particlesGptForward.x);
+
+ float particlesGptFlowPhase =
+ particlesGptHash11(a_particlesGptId * 1.91 + 13.0 + u_particlesGptSeed) *
+ particlesGptTau;
+ float particlesGptField =
+ sin(u_particlesGptTime * 1.25 + particlesGptFlowPhase + particlesGptTargetPosition.y * 0.015) +
+ 0.5 * sin(u_particlesGptTime * 2.1 - particlesGptFlowPhase * 1.3 + particlesGptTargetPosition.x * 0.01);
+ vec2 particlesGptGust = vec2(
+ sin(u_particlesGptTime * 0.92 + particlesGptFlowPhase + particlesGptTargetPosition.y * 0.018),
+ cos(u_particlesGptTime * 1.08 - particlesGptFlowPhase + particlesGptTargetPosition.x * 0.014)
+ );
+
+ float particlesGptEnvelope = (1.0 - particlesGptProgress);
+ particlesGptEnvelope *= particlesGptEnvelope;
+ particlesGptEnvelope *= smoothstep(0.0, 0.08, particlesGptT);
+
+ float particlesGptBend =
+ min(particlesGptDistanceToTarget * 0.16, 56.0) * u_particlesGptWindStrength;
+ vec2 particlesGptWindOffset =
+ particlesGptSide * particlesGptField * particlesGptBend * particlesGptEnvelope;
+ particlesGptWindOffset +=
+ particlesGptGust * (10.0 * u_particlesGptWindStrength) * particlesGptEnvelope;
+ particlesGptWindOffset +=
+ particlesGptForward *
+ sin(u_particlesGptTime * 1.7 + particlesGptFlowPhase * 1.7) *
+ (6.0 * u_particlesGptWindStrength) *
+ particlesGptEnvelope;
+
+ vec2 particlesGptPosition =
+ mix(particlesGptStartPosition, particlesGptTargetPosition, particlesGptProgress) +
+ particlesGptWindOffset;
+ vec2 particlesGptClipPosition = particlesGptScreenToClip(particlesGptPosition);
+
+ gl_PointSize = max(1.0, particlesGptCell * u_particlesGptPointScale);`,
+ position: 'vec4(particlesGptClipPosition, 0.0, 1.0)',
+ },
+ fragment: {
+ main: `
+ vec2 particlesGptCentered = abs(gl_PointCoord - 0.5);
+ float particlesGptEdge = max(particlesGptCentered.x, particlesGptCentered.y);
+ float particlesGptAlpha = 1.0 - smoothstep(0.47, 0.5, particlesGptEdge);
+
+ color = v_particlesGptColor.rgb;
+ alpha = v_particlesGptColor.a * particlesGptAlpha;`,
+ },
+ varying: {
+ v_particlesGptColor: 'vec4',
+ },
+ uniforms: [
+ {
+ name: 'u_particlesGptMap',
+ type: 'i',
+ data: [0],
+ },
+ {
+ name: 'u_particlesGptImageSize',
+ type: 'f',
+ data: [width, height],
+ },
+ {
+ name: 'u_particlesGptCanvasSize',
+ type: 'f',
+ data: [1, 1],
+ },
+ {
+ name: 'u_particlesGptTime',
+ type: 'f',
+ data: [0],
+ },
+ {
+ name: 'u_particlesGptPhase',
+ type: 'f',
+ data: [0],
+ },
+ {
+ name: 'u_particlesGptDuration',
+ type: 'f',
+ data: [Math.max(0.001, duration)],
+ },
+ {
+ name: 'u_particlesGptDelayWindow',
+ type: 'f',
+ data: [Math.max(0, stagger)],
+ },
+ {
+ name: 'u_particlesGptPointScale',
+ type: 'f',
+ data: [Math.max(0.1, pointScale)],
+ },
+ {
+ name: 'u_particlesGptSpread',
+ type: 'f',
+ data: [Math.max(0.01, spread)],
+ },
+ {
+ name: 'u_particlesGptWindStrength',
+ type: 'f',
+ data: [Math.max(0, wind)],
+ },
+ {
+ name: 'u_particlesGptSeed',
+ type: 'f',
+ data: [0],
+ },
+ {
+ name: 'u_particlesGptEaseMode',
+ type: 'i',
+ data: [Math.max(0, EASING_MODES.indexOf(easing))],
+ },
+ ],
+ attributes: [
+ {
+ name: 'a_particlesGptId',
+ size: 1,
+ type: 'FLOAT',
+ data: getParticleIds(maxParticles),
+ },
+ ],
+ textures: [
+ {
+ format: 'RGBA',
+ data: source,
+ update: true,
+ },
+ ],
+ get source() {
+ return this.textures[0].data;
+ },
+ set source(media) {
+ this.textures[0].data = media;
+ this.textures[0].update = true;
+ },
+ get sourceSize() {
+ const [currentWidth, currentHeight] = this.uniforms[1].data;
+ return { width: currentWidth, height: currentHeight };
+ },
+ set sourceSize({ width: nextWidth, height: nextHeight }) {
+ const widthValue = typeof nextWidth === 'number' ? Math.max(1, Math.floor(nextWidth)) : this.uniforms[1].data[0];
+ const heightValue = typeof nextHeight === 'number' ? Math.max(1, Math.floor(nextHeight)) : this.uniforms[1].data[1];
+
+ if (widthValue * heightValue > maxParticles) {
+ throw new Error('particles-gpt :: sourceSize exceeds max particle capacity');
+ }
+
+ this.uniforms[1].data[0] = widthValue;
+ this.uniforms[1].data[1] = heightValue;
+ this.draw.count = widthValue * heightValue;
+ },
+ get canvasSize() {
+ const [widthValue, heightValue] = this.uniforms[2].data;
+ return { width: widthValue, height: heightValue };
+ },
+ set canvasSize({ width: nextWidth, height: nextHeight }) {
+ if (typeof nextWidth === 'number') this.uniforms[2].data[0] = nextWidth;
+ if (typeof nextHeight === 'number') this.uniforms[2].data[1] = nextHeight;
+ },
+ get time() {
+ return this.uniforms[3].data[0];
+ },
+ set time(value) {
+ this.uniforms[3].data[0] = Number(value) || 0;
+ },
+ get phase() {
+ return this.uniforms[4].data[0];
+ },
+ set phase(value) {
+ this.uniforms[4].data[0] = Number(value) || 0;
+ },
+ get duration() {
+ return this.uniforms[5].data[0];
+ },
+ set duration(value) {
+ this.uniforms[5].data[0] = Math.max(0.001, Number(value) || 0.001);
+ },
+ get stagger() {
+ return this.uniforms[6].data[0];
+ },
+ set stagger(value) {
+ this.uniforms[6].data[0] = Math.max(0, Number(value) || 0);
+ },
+ get pointScale() {
+ return this.uniforms[7].data[0];
+ },
+ set pointScale(value) {
+ this.uniforms[7].data[0] = Math.max(0.1, Number(value) || 0.1);
+ },
+ get spread() {
+ return this.uniforms[8].data[0];
+ },
+ set spread(value) {
+ this.uniforms[8].data[0] = Math.max(0.01, Number(value) || 0.01);
+ },
+ get wind() {
+ return this.uniforms[9].data[0];
+ },
+ set wind(value) {
+ this.uniforms[9].data[0] = Math.max(0, Number(value) || 0);
+ },
+ get seed() {
+ return this.uniforms[10].data[0];
+ },
+ set seed(value) {
+ this.uniforms[10].data[0] = Number(value) || 0;
+ },
+ get easing() {
+ return EASING_MODES[this.uniforms[11].data[0]] || EASING_MODES[0];
+ },
+ set easing(value) {
+ const easingIndex = EASING_MODES.indexOf(value);
+ this.uniforms[11].data[0] = easingIndex === -1 ? 0 : easingIndex;
+ },
+ get hold() {
+ return holdDuration;
+ },
+ set hold(value) {
+ holdDuration = Math.max(0, Number(value) || 0);
+ },
+ get cycleDuration() {
+ return this.duration + this.hold;
+ },
+ get maxParticleCount() {
+ return maxParticles;
+ },
+ };
+
+ return effect;
+}
diff --git a/src/effects/particles-sonnet.js b/src/effects/particles-sonnet.js
new file mode 100644
index 0000000..da45b23
--- /dev/null
+++ b/src/effects/particles-sonnet.js
@@ -0,0 +1,331 @@
+/**
+ * Build a Float32Array encoding (startX, startY, endU, endV) for each particle.
+ *
+ * startX/Y – uniformly random in [-1, 1]; the vertex shader scales these by
+ * the spread factor and canvas aspect ratio to produce NDC start positions.
+ * endU/V – pixel-centre UV within the image grid [0, 1]; the vertex shader
+ * converts these to canvas-size-aware NDC end positions at draw time.
+ *
+ * @private
+ * @param {number} size
+ * @returns {Float32Array}
+ */
+function buildPData(size) {
+ const count = size * size;
+ const data = new Float32Array(count * 4);
+
+ for (let i = 0; i < count; i++) {
+ const col = i % size;
+ const row = Math.floor(i / size);
+ data[i * 4 + 0] = Math.random() * 2.0 - 1.0; // startX ∈ [-1, 1]
+ data[i * 4 + 1] = Math.random() * 2.0 - 1.0; // startY ∈ [-1, 1]
+ data[i * 4 + 2] = (col + 0.5) / size; // endU ∈ [0, 1]
+ data[i * 4 + 3] = (row + 0.5) / size; // endV ∈ [0, 1]
+ }
+
+ return data;
+}
+
+/**
+ * Build a Float32Array of sequential particle IDs [0, 1, …, count-1].
+ *
+ * @private
+ * @param {number} count
+ * @returns {Float32Array}
+ */
+function buildParticleIds(count) {
+ const ids = new Float32Array(count);
+ for (let i = 0; i < count; i++) ids[i] = i;
+ return ids;
+}
+
+/**
+ * Particle image-assembly effect using a pre-computed RGBA FLOAT data texture.
+ *
+ * Structural differences from {@link particlesGpt}:
+ *
+ * 1. **Data texture** – start positions are baked on the CPU into an
+ * `OES_texture_float` texture at construction time, giving each particle a
+ * truly independent random scatter position without vertex-shader hash bias.
+ *
+ * 2. **JS-side easing** – the caller passes an already-eased `t` value via
+ * `effect.t`. This decouples easing from the shader and enables physically-
+ * inspired curves (elastic overshoot, bounce) that are impractical as integer
+ * mode switches.
+ *
+ * 3. **Proportional wind arc** – lateral wind is scaled by each particle's
+ * individual travel distance so long-path particles arc expressively while
+ * short-path ones settle without unnecessary oscillation.
+ *
+ * Requires `OES_texture_float` and at least one vertex texture image unit.
+ *
+ * @function particlesSonnet
+ * @param {Object} [params]
+ * @param {number} [params.gridSize=256] initial particle grid dimension N (N × N particles)
+ * @param {number} [params.maxGridSize=512] maximum grid size — sets the attribute buffer size;
+ * `rebuild()` cannot exceed this value
+ * @param {number} [params.spread=1.8] scatter radius multiplier (NDC, aspect-corrected)
+ * @param {number} [params.windStr=0.30] wind displacement strength
+ * @param {HTMLCanvasElement|ImageData|HTMLImageElement|ImageBitmap} [params.source]
+ * @returns {particlesSonnetEffect}
+ *
+ * @example
+ * const effect = particlesSonnet({ gridSize: 192, maxGridSize: 512, source: myCanvas });
+ * const kampos = new Kampos({ target, effects: [effect], noSource: true });
+ * // in animation loop:
+ * effect.t = easingFn(rawT); // already-eased progress [0, 1]
+ * effect.time = windTime;
+ * effect.canvasSize = { width: canvas.width, height: canvas.height };
+ */
+export default function particlesSonnet({
+ gridSize = 256,
+ maxGridSize = 512,
+ spread = 1.8,
+ windStr = 0.30,
+ source = null,
+} = {}) {
+ if (gridSize > maxGridSize) {
+ throw new Error('particles-sonnet :: gridSize exceeds maxGridSize');
+ }
+
+ const maxCount = maxGridSize * maxGridSize;
+ const count = gridSize * gridSize;
+
+ const draw = {
+ mode: 'POINTS',
+ count,
+ };
+
+ /**
+ * @typedef {Object} particlesSonnetEffect
+ * @property {number} t already-eased animation progress (set by caller)
+ * @property {number} time wind time (continuously advancing)
+ * @property {{width:number,height:number}} canvasSize canvas pixel dimensions
+ * @property {number} spread scatter radius multiplier
+ * @property {number} windStr wind displacement strength
+ * @property {number} pointSize point diameter in pixels
+ * @property {*} source source image (canvas / element)
+ */
+ const effect = {
+ draw,
+
+ // ── Vertex shader ──────────────────────────────────────────────────────
+ vertex: {
+ uniform: {
+ u_psnData: 'sampler2D', // RGBA FLOAT data texture (unit 0)
+ u_psnGridW: 'float', // grid width (= gridSize)
+ u_psnGridH: 'float', // grid height (= gridSize)
+ u_psnCanvasSize: 'vec2', // canvas pixel dimensions
+ u_psnT: 'float', // already-eased animation progress
+ u_psnWt: 'float', // wind time (continuous)
+ u_psnSpread: 'float', // scatter radius multiplier
+ u_psnWindStr: 'float', // wind strength multiplier
+ u_psnPtSz: 'float', // point size in pixels
+ },
+ attribute: {
+ a_psnId: 'float', // sequential particle index
+ },
+ main: `
+ // ── Fetch this particle's data from the RGBA FLOAT position texture ──────
+ float psnId = a_psnId;
+ float psnTxC = mod(psnId, u_psnGridW);
+ float psnTxR = floor(psnId / u_psnGridW);
+ vec2 psnDataTc = (vec2(psnTxC, psnTxR) + 0.5) / u_psnGridW;
+ vec4 psnPd = texture2D(u_psnData, psnDataTc);
+ // psnPd.xy = pre-baked random start position ∈ [-1, 1]
+ // psnPd.zw = grid-end UV ∈ [0, 1] (u = col/W, v = row/H, top-left origin)
+
+ // ── End position: fit image grid to 58 % of canvas, centered ─────────────
+ vec2 psnImgFit = u_psnCanvasSize * 0.58;
+ float psnCell = min(psnImgFit.x / u_psnGridW, psnImgFit.y / u_psnGridH);
+ // Negate H so row-0 (v = 0) maps to the TOP of the screen (positive NDC y).
+ vec2 psnEpPx = (psnPd.zw - 0.5) * vec2(u_psnGridW, -u_psnGridH) * psnCell
+ + u_psnCanvasSize * 0.5;
+ vec2 psnEp = psnEpPx / u_psnCanvasSize * 2.0 - 1.0;
+
+ // ── Start position: scale random [-1,1] by spread and aspect ratio ────────
+ float psnAspect = u_psnCanvasSize.x / u_psnCanvasSize.y;
+ vec2 psnSp = psnPd.xy * u_psnSpread * vec2(psnAspect, 1.0);
+
+ // ── u_psnT is already eased by JS; elastic overshoot (t > 1) is supported ─
+ float psnT = u_psnT;
+ vec2 psnBase = mix(psnSp, psnEp, psnT);
+
+ // ── Wind: per-particle phase from pre-baked startX (truly random) ─────────
+ float psnPhase = psnPd.x * PI;
+ float psnField = sin(u_psnWt * 1.24 + psnPhase + psnEp.y * 2.1)
+ + 0.5 * sin(u_psnWt * 2.07 - psnPhase * 1.3 + psnEp.x * 1.8);
+ vec2 psnGust = vec2(
+ sin(u_psnWt * 0.91 + psnPhase + psnEp.y * 1.9),
+ cos(u_psnWt * 1.11 - psnPhase + psnEp.x * 1.5)
+ );
+
+ // Bell envelope: 0 at both endpoints, peak at t = 0.5.
+ // Clamp before sin so elastic overshoot (t > 1) still produces zero wind.
+ float psnEnv = sin(PI * clamp(psnT, 0.0, 1.0));
+ float psnDist = length(psnEp - psnSp);
+ vec2 psnFwd = psnDist > 0.001 ? (psnEp - psnSp) / psnDist : vec2(1.0, 0.0);
+ vec2 psnSide = vec2(-psnFwd.y, psnFwd.x);
+
+ // Lateral arc proportional to travel distance; gust adds perpendicular noise.
+ float psnBend = min(psnDist * 0.28, 0.40) * u_psnWindStr;
+ vec2 psnWindOff = psnSide * psnField * psnBend * psnEnv;
+ psnWindOff += psnGust * (0.07 * u_psnWindStr) * psnEnv;
+
+ vec2 psnPos = psnBase + psnWindOff;
+ gl_PointSize = max(1.0, u_psnPtSz);
+
+ // Pass end UV to fragment shader for colour sampling.
+ // No V-flip: the source canvas is stored without UNPACK_FLIP_Y_WEBGL,
+ // so v = 0 already maps to the top of the canvas in GL texture space.
+ v_psnUv = psnPd.zw;`,
+ position: 'vec4(psnPos, 0.0, 1.0)',
+ },
+
+ // ── Fragment shader ────────────────────────────────────────────────────
+ fragment: {
+ uniform: {
+ u_psnImg: 'sampler2D', // source image (unit 1)
+ },
+ main: `
+ // Circular point-sprite clip
+ vec2 psnPc = gl_PointCoord - 0.5;
+ if (dot(psnPc, psnPc) > 0.25) discard;
+
+ vec4 psnCol = texture2D(u_psnImg, v_psnUv);
+ if (psnCol.a < 0.04) discard; // cull transparent-background pixels (text mode)
+
+ color = psnCol.rgb;
+ alpha = psnCol.a;`,
+ },
+
+ varying: {
+ v_psnUv: 'vec2',
+ },
+
+ // ── Uniforms ───────────────────────────────────────────────────────────
+ uniforms: [
+ { name: 'u_psnData', type: 'i', data: [0] }, // 0 TEXTURE0
+ { name: 'u_psnGridW', type: 'f', data: [gridSize] }, // 1
+ { name: 'u_psnGridH', type: 'f', data: [gridSize] }, // 2
+ { name: 'u_psnCanvasSize', type: 'f', data: [1, 1] }, // 3
+ { name: 'u_psnT', type: 'f', data: [0] }, // 4
+ { name: 'u_psnWt', type: 'f', data: [0] }, // 5
+ { name: 'u_psnSpread', type: 'f', data: [spread] }, // 6
+ { name: 'u_psnWindStr', type: 'f', data: [windStr] }, // 7
+ { name: 'u_psnPtSz', type: 'f', data: [1] }, // 8
+ { name: 'u_psnImg', type: 'i', data: [1] }, // 9 TEXTURE1
+ ],
+
+ // ── Attributes ─────────────────────────────────────────────────────────
+ // Pre-allocate maxCount IDs so rebuild() can grow up to maxGridSize without
+ // overflowing the buffer. drawArrays only reads the first draw.count entries.
+ attributes: [
+ {
+ name: 'a_psnId',
+ size: 1,
+ type: 'FLOAT',
+ data: buildParticleIds(maxCount),
+ },
+ ],
+
+ // ── Textures ───────────────────────────────────────────────────────────
+ // TEXTURE0: RGBA FLOAT data texture — particle (startX, startY, endU, endV)
+ // TEXTURE1: RGBA UNSIGNED_BYTE source image
+ textures: [
+ {
+ format: 'RGBA',
+ textureType: 'FLOAT',
+ filter: 'NEAREST',
+ wrap: 'stretch',
+ width: gridSize,
+ height: gridSize,
+ data: buildPData(gridSize),
+ update: false, // baked once; rebuilt only via effect.rebuild()
+ },
+ {
+ format: 'RGBA',
+ data: source,
+ update: source !== null,
+ },
+ ],
+
+ // ── Property accessors ─────────────────────────────────────────────────
+ /** Already-eased animation progress. Set this to `easingFn(rawT)` each frame. */
+ get t() { return this.uniforms[4].data[0]; },
+ set t(v) { this.uniforms[4].data[0] = Number(v) || 0; },
+
+ /** Continuously advancing wind time. */
+ get time() { return this.uniforms[5].data[0]; },
+ set time(v) { this.uniforms[5].data[0] = Number(v) || 0; },
+
+ get canvasSize() {
+ const [w, h] = this.uniforms[3].data;
+ return { width: w, height: h };
+ },
+ set canvasSize({ width: w, height: h }) {
+ if (typeof w === 'number') this.uniforms[3].data[0] = w;
+ if (typeof h === 'number') this.uniforms[3].data[1] = h;
+ },
+
+ get spread() { return this.uniforms[6].data[0]; },
+ set spread(v) { this.uniforms[6].data[0] = Math.max(0.01, Number(v) || 0.01); },
+
+ get windStr() { return this.uniforms[7].data[0]; },
+ set windStr(v) { this.uniforms[7].data[0] = Math.max(0, Number(v) || 0); },
+
+ get pointSize() { return this.uniforms[8].data[0]; },
+ set pointSize(v) { this.uniforms[8].data[0] = Math.max(1, Number(v) || 1); },
+
+ get source() { return this.textures[1].data; },
+ set source(media) {
+ this.textures[1].data = media;
+ this.textures[1].update = true;
+ },
+
+ /** Current particle count. */
+ get particleCount() { return this.draw.count; },
+
+ /** Maximum grid size that `rebuild()` will accept. */
+ get maxGridSize() { return maxGridSize; },
+
+ /**
+ * Rebuild the float data texture for a new grid size.
+ * Call this whenever the particle count needs to change dynamically.
+ * The caller is responsible for updating `effect.pointSize` afterward.
+ *
+ * @param {WebGLRenderingContext} gl from `kampos.gl`
+ * @param {number} newSize new N for an N × N grid (must be ≤ maxGridSize)
+ */
+ rebuild(gl, newSize) {
+ if (newSize * newSize > maxCount) {
+ throw new Error(`particles-sonnet :: rebuild size ${newSize} exceeds maxGridSize ${maxGridSize}`);
+ }
+
+ // Delete the old float texture that Kampos created
+ if (this.textures[0].texture) {
+ gl.deleteTexture(this.textures[0].texture);
+ }
+
+ const pData = buildPData(newSize);
+
+ // OES_texture_float was already acquired during _createTextures
+ const newTex = gl.createTexture();
+ gl.activeTexture(gl.TEXTURE0);
+ gl.bindTexture(gl.TEXTURE_2D, newTex);
+ 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, gl.RGBA, newSize, newSize, 0, gl.RGBA, gl.FLOAT, pData);
+
+ // Swap in the new texture so the next draw call uses it
+ this.textures[0].texture = newTex;
+ this.uniforms[1].data[0] = newSize; // u_psnGridW
+ this.uniforms[2].data[0] = newSize; // u_psnGridH
+ this.draw.count = newSize * newSize;
+ },
+ };
+
+ return effect;
+}
diff --git a/src/kampos.js b/src/kampos.js
index ec7db24..ae437ee 100644
--- a/src/kampos.js
+++ b/src/kampos.js
@@ -151,6 +151,10 @@ export class Kampos {
this.data = data;
this.fboData = fboData;
+ if (noSource && data.textures && data.textures.length) {
+ this._createTextures();
+ }
+
// cache for restoring context
this.config = config;
@@ -391,16 +395,21 @@ export class Kampos {
}
_createTextures() {
+ const dimensions = this.dimensions || {};
+
this.data &&
this.data.textures.forEach((texture, i) => {
const data = this.data.textures[i];
data.texture = core.createTexture(this.gl, {
- width: this.dimensions.width,
- height: this.dimensions.height,
+ // Effect configs may declare their own explicit dimensions (e.g. float data textures)
+ width: texture.width || dimensions.width,
+ height: texture.height || dimensions.height,
format: texture.format,
data: texture.data,
wrap: texture.wrap,
+ filter: texture.filter,
+ textureType: texture.textureType,
}).texture;
data.format = texture.format;