From 8d7b9447a80e5c3ffb14e8c0fa160eaed1d52aad Mon Sep 17 00:00:00 2001 From: ameerf-wix Date: Wed, 27 May 2026 04:07:52 +0300 Subject: [PATCH 1/2] 2 ways of doing particles swarm --- demo/particles.html | 27 + demo/particles.js | 1180 ++++++++++++++++++++++++++++++++++++++++++ demo/particles2.html | 1032 ++++++++++++++++++++++++++++++++++++ 3 files changed, 2239 insertions(+) create mode 100644 demo/particles.html create mode 100644 demo/particles.js create mode 100644 demo/particles2.html 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..05abeeb --- /dev/null +++ b/demo/particles.js @@ -0,0 +1,1180 @@ +const target = document.querySelector('#target'); + +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 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 vertexShaderSource = `#version 300 es +precision highp float; +precision highp sampler2D; + +uniform sampler2D u_source; +uniform vec2 u_canvasSize; +uniform vec2 u_imageSize; +uniform float u_time; +uniform float u_phase; +uniform float u_motionDuration; +uniform float u_delayWindow; +uniform float u_pointScale; +uniform float u_spread; +uniform float u_windStrength; +uniform float u_seed; +uniform int u_easeMode; + +out vec4 v_color; + +const float TAU = 6.283185307179586; + +float hash11(float p) { + p = fract(p * 0.1031); + p *= p + 33.33; + p *= p + p; + return fract(p); +} + +vec2 hash21(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 smoother(float t) { + return t * t * t * (t * (t * 6.0 - 15.0) + 10.0); +} + +float applyEasing(float t) { + if (u_easeMode == 1) { + return t; + } + + if (u_easeMode == 2) { + return 1.0 - pow(1.0 - t, 2.0); + } + + if (u_easeMode == 3) { + return 0.5 - 0.5 * cos(t * 3.141592653589793); + } + + if (u_easeMode == 4) { + return t < 0.5 + ? 4.0 * t * t * t + : 1.0 - pow(-2.0 * t + 2.0, 3.0) * 0.5; + } + + return smoother(t); +} + +vec2 screenToClip(vec2 pixelPosition) { + vec2 clip = pixelPosition / u_canvasSize * 2.0 - 1.0; + clip.y *= -1.0; + return clip; +} + +void main() { + float particleId = float(gl_VertexID); + float x = mod(particleId, u_imageSize.x); + float y = floor(particleId / u_imageSize.x); + ivec2 samplePixel = ivec2(int(x), int(u_imageSize.y - 1.0 - y)); + + vec4 color = texelFetch(u_source, samplePixel, 0); + v_color = color; + + vec2 imageFit = u_canvasSize * 0.58; + float cell = min(imageFit.x / u_imageSize.x, imageFit.y / u_imageSize.y); + vec2 targetPosition = (vec2(x + 0.5, y + 0.5) - u_imageSize * 0.5) * cell + u_canvasSize * 0.5; + + vec2 randomBox = (hash21(particleId + 19.7 + u_seed) - 0.5) * u_canvasSize * (1.9 * u_spread); + float orbitAngle = hash11(particleId * 0.173 + 4.1 + u_seed) * TAU; + float orbitRadius = mix(0.24, 1.12, pow(hash11(particleId * 0.537 + 7.0 + u_seed), 0.65)); + vec2 ring = vec2(cos(orbitAngle), sin(orbitAngle)) * orbitRadius * length(u_canvasSize) * 0.48 * u_spread; + vec2 startPosition = u_canvasSize * 0.5 + mix(randomBox, ring, 0.62); + + float delay = hash11(particleId * 0.071 + u_seed) * u_delayWindow; + float t = clamp((u_phase - delay) / max(u_motionDuration - delay, 0.001), 0.0, 1.0); + float progress = applyEasing(t); + + vec2 delta = targetPosition - startPosition; + float distanceToTarget = max(length(delta), 0.0001); + vec2 forward = delta / distanceToTarget; + vec2 side = vec2(-forward.y, forward.x); + + float flowPhase = hash11(particleId * 1.91 + 13.0 + u_seed) * TAU; + float field = sin(u_time * 1.25 + flowPhase + targetPosition.y * 0.015) + + 0.5 * sin(u_time * 2.1 - flowPhase * 1.3 + targetPosition.x * 0.01); + vec2 gust = vec2( + sin(u_time * 0.92 + flowPhase + targetPosition.y * 0.018), + cos(u_time * 1.08 - flowPhase + targetPosition.x * 0.014) + ); + + float envelope = (1.0 - progress); + envelope *= envelope; + envelope *= smoothstep(0.0, 0.08, t); + + float bend = min(distanceToTarget * 0.16, 56.0) * u_windStrength; + vec2 windOffset = side * field * bend * envelope; + windOffset += gust * (10.0 * u_windStrength) * envelope; + windOffset += forward * sin(u_time * 1.7 + flowPhase * 1.7) * (6.0 * u_windStrength) * envelope; + + vec2 position = mix(startPosition, targetPosition, progress) + windOffset; + + gl_Position = vec4(screenToClip(position), 0.0, 1.0); + gl_PointSize = max(1.0, cell * u_pointScale); +} +`; + +const fragmentShaderSource = `#version 300 es +precision highp float; + +in vec4 v_color; +out vec4 outColor; + +void main() { + vec2 centered = abs(gl_PointCoord - 0.5); + float edge = max(centered.x, centered.y); + float alpha = 1.0 - smoothstep(0.47, 0.5, edge); + + outColor = vec4(v_color.rgb, v_color.a * alpha); +} +`; + +const instances = []; + +renderSourceCanvas(sourceCanvas, currentPreset.width, currentPreset.height, currentSourceMode); +applyCanvasCount(currentCanvasCount, { restartAnimation: false, syncUrl: false }); +applySourceMode(currentSourceMode, { restartAnimation: false, syncUrl: false }); +applyParticlePreset(currentPreset, { restartAnimation: false, syncUrl: false }); +applyAnimationSettings({}, { restartAnimation: false, syncUrl: false }); + +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(); + } +}); + +requestAnimationFrame(render); + +function render(nowMs) { + const now = nowMs * 0.001; + const phase = (now - cycleStart) % getCycleDuration(); + + instances.forEach((instance) => { + renderInstance(instance, now, phase); + }); + + requestAnimationFrame(render); +} + +function renderInstance(instance, now, phase) { + if (!resizeInstance(instance)) { + return; + } + + const { gl, uniforms } = instance; + + gl.clearColor(0.02, 0.03, 0.06, 1.0); + gl.clear(gl.COLOR_BUFFER_BIT); + + gl.useProgram(instance.program); + gl.uniform2f(uniforms.canvasSize, instance.canvas.width, instance.canvas.height); + gl.uniform1f(uniforms.time, now); + gl.uniform1f(uniforms.phase, phase); + gl.uniform1f(uniforms.seed, globalSeed + instance.seedOffset); + + gl.drawArrays(gl.POINTS, 0, currentParticleCount); +} + +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 gl = canvas.getContext('webgl2', { + antialias: false, + alpha: false, + premultipliedAlpha: false, + }); + + if (!gl) { + throw new Error('This demo requires WebGL2.'); + } + + const program = createProgram(gl, vertexShaderSource, fragmentShaderSource); + const vao = gl.createVertexArray(); + const uniforms = { + source: gl.getUniformLocation(program, 'u_source'), + canvasSize: gl.getUniformLocation(program, 'u_canvasSize'), + imageSize: gl.getUniformLocation(program, 'u_imageSize'), + time: gl.getUniformLocation(program, 'u_time'), + phase: gl.getUniformLocation(program, 'u_phase'), + motionDuration: gl.getUniformLocation(program, 'u_motionDuration'), + delayWindow: gl.getUniformLocation(program, 'u_delayWindow'), + pointScale: gl.getUniformLocation(program, 'u_pointScale'), + spread: gl.getUniformLocation(program, 'u_spread'), + windStrength: gl.getUniformLocation(program, 'u_windStrength'), + seed: gl.getUniformLocation(program, 'u_seed'), + easeMode: gl.getUniformLocation(program, 'u_easeMode'), + }; + const sourceTexture = gl.createTexture(); + + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, sourceTexture); + gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true); + 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.useProgram(program); + gl.uniform1i(uniforms.source, 0); + gl.uniform1f(uniforms.motionDuration, settings.duration); + gl.uniform1f(uniforms.delayWindow, settings.stagger); + gl.uniform1f(uniforms.pointScale, settings.pointScale); + gl.uniform1f(uniforms.spread, settings.spread); + gl.uniform1f(uniforms.windStrength, settings.wind); + gl.uniform1i(uniforms.easeMode, getEasingModeIndex(settings.easing)); + + gl.bindVertexArray(vao); + gl.disable(gl.DEPTH_TEST); + gl.enable(gl.BLEND); + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + + return { + cell, + canvas, + gl, + program, + vao, + uniforms, + sourceTexture, + seedOffset: Math.random() * 4000, + }; +} + +function destroyDemoInstance(instance) { + if (!instance) { + return; + } + + const { gl, sourceTexture, vao, program, cell } = instance; + gl.deleteTexture(sourceTexture); + gl.deleteVertexArray(vao); + gl.deleteProgram(program); + gl.getExtension('WEBGL_lose_context')?.loseContext(); + cell.remove(); +} + +function syncInstancesSource() { + instances.forEach((instance) => { + const { gl, sourceTexture, uniforms } = instance; + + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, sourceTexture); + gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, sourceCanvas); + + gl.useProgram(instance.program); + gl.uniform2f(uniforms.imageSize, currentPreset.width, currentPreset.height); + }); +} + +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.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 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() { + const easeMode = getEasingModeIndex(settings.easing); + + instances.forEach((instance) => { + const { gl, uniforms } = instance; + gl.useProgram(instance.program); + gl.uniform1f(uniforms.motionDuration, settings.duration); + gl.uniform1f(uniforms.delayWindow, settings.stagger); + gl.uniform1f(uniforms.pointScale, settings.pointScale); + gl.uniform1f(uniforms.spread, settings.spread); + gl.uniform1f(uniforms.windStrength, settings.wind); + gl.uniform1i(uniforms.easeMode, easeMode); + }); +} + +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 createProgram(glContext, vertexSource, fragmentSource) { + const vertexShader = createShader(glContext, glContext.VERTEX_SHADER, vertexSource); + const fragmentShader = createShader(glContext, glContext.FRAGMENT_SHADER, fragmentSource); + const program = glContext.createProgram(); + + glContext.attachShader(program, vertexShader); + glContext.attachShader(program, fragmentShader); + glContext.linkProgram(program); + + if (!glContext.getProgramParameter(program, glContext.LINK_STATUS)) { + throw new Error(glContext.getProgramInfoLog(program) || 'Program failed to link.'); + } + + glContext.deleteShader(vertexShader); + glContext.deleteShader(fragmentShader); + + return program; +} + +function createShader(glContext, type, source) { + const shader = glContext.createShader(type); + glContext.shaderSource(shader, source); + glContext.compileShader(shader); + + if (!glContext.getShaderParameter(shader, glContext.COMPILE_STATUS)) { + throw new Error(glContext.getShaderInfoLog(shader) || 'Shader failed to compile.'); + } + + return shader; +} + +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) { + const numericValue = Number(value); + + if (!Number.isFinite(numericValue)) { + return fallback; + } + + return clamp(numericValue, min, max); +} + +function getEasingModeIndex(value) { + const index = EASING_OPTIONS.findIndex((option) => option.value === value); + return index === -1 ? 0 : index; +} + +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 +
+ + + + + From 0f5bf61b737152dfc13fb1c5c48bcd64a9deb663 Mon Sep 17 00:00:00 2001 From: ameerf-wix Date: Wed, 27 May 2026 16:35:35 +0300 Subject: [PATCH 2/2] allegedly connected to kampos infra now --- demo/particles-sonnet.html | 26 ++ demo/particles-sonnet.js | 486 ++++++++++++++++++++++++++++++++ demo/particles.js | 377 +++++++------------------ dist/index.cjs | 469 +++++++++++++++++++++++++++++- index.js | 4 + index.umd.js | 469 +++++++++++++++++++++++++++++- src/core.js | 67 ++++- src/effects/particles-gpt.js | 423 +++++++++++++++++++++++++++ src/effects/particles-sonnet.js | 331 ++++++++++++++++++++++ src/kampos.js | 13 +- 10 files changed, 2364 insertions(+), 301 deletions(-) create mode 100644 demo/particles-sonnet.html create mode 100644 demo/particles-sonnet.js create mode 100644 src/effects/particles-gpt.js create mode 100644 src/effects/particles-sonnet.js 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.js b/demo/particles.js index 05abeeb..637a867 100644 --- a/demo/particles.js +++ b/demo/particles.js @@ -1,4 +1,7 @@ +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; @@ -35,6 +38,7 @@ const PARTICLE_PRESETS = [ { 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')); @@ -197,147 +201,17 @@ applyStyles(replayButton, { }); document.body.appendChild(replayButton); -const vertexShaderSource = `#version 300 es -precision highp float; -precision highp sampler2D; - -uniform sampler2D u_source; -uniform vec2 u_canvasSize; -uniform vec2 u_imageSize; -uniform float u_time; -uniform float u_phase; -uniform float u_motionDuration; -uniform float u_delayWindow; -uniform float u_pointScale; -uniform float u_spread; -uniform float u_windStrength; -uniform float u_seed; -uniform int u_easeMode; - -out vec4 v_color; - -const float TAU = 6.283185307179586; - -float hash11(float p) { - p = fract(p * 0.1031); - p *= p + 33.33; - p *= p + p; - return fract(p); -} - -vec2 hash21(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 smoother(float t) { - return t * t * t * (t * (t * 6.0 - 15.0) + 10.0); -} - -float applyEasing(float t) { - if (u_easeMode == 1) { - return t; - } - - if (u_easeMode == 2) { - return 1.0 - pow(1.0 - t, 2.0); - } - - if (u_easeMode == 3) { - return 0.5 - 0.5 * cos(t * 3.141592653589793); - } - - if (u_easeMode == 4) { - return t < 0.5 - ? 4.0 * t * t * t - : 1.0 - pow(-2.0 * t + 2.0, 3.0) * 0.5; - } - - return smoother(t); -} - -vec2 screenToClip(vec2 pixelPosition) { - vec2 clip = pixelPosition / u_canvasSize * 2.0 - 1.0; - clip.y *= -1.0; - return clip; -} - -void main() { - float particleId = float(gl_VertexID); - float x = mod(particleId, u_imageSize.x); - float y = floor(particleId / u_imageSize.x); - ivec2 samplePixel = ivec2(int(x), int(u_imageSize.y - 1.0 - y)); - - vec4 color = texelFetch(u_source, samplePixel, 0); - v_color = color; - - vec2 imageFit = u_canvasSize * 0.58; - float cell = min(imageFit.x / u_imageSize.x, imageFit.y / u_imageSize.y); - vec2 targetPosition = (vec2(x + 0.5, y + 0.5) - u_imageSize * 0.5) * cell + u_canvasSize * 0.5; - - vec2 randomBox = (hash21(particleId + 19.7 + u_seed) - 0.5) * u_canvasSize * (1.9 * u_spread); - float orbitAngle = hash11(particleId * 0.173 + 4.1 + u_seed) * TAU; - float orbitRadius = mix(0.24, 1.12, pow(hash11(particleId * 0.537 + 7.0 + u_seed), 0.65)); - vec2 ring = vec2(cos(orbitAngle), sin(orbitAngle)) * orbitRadius * length(u_canvasSize) * 0.48 * u_spread; - vec2 startPosition = u_canvasSize * 0.5 + mix(randomBox, ring, 0.62); - - float delay = hash11(particleId * 0.071 + u_seed) * u_delayWindow; - float t = clamp((u_phase - delay) / max(u_motionDuration - delay, 0.001), 0.0, 1.0); - float progress = applyEasing(t); - - vec2 delta = targetPosition - startPosition; - float distanceToTarget = max(length(delta), 0.0001); - vec2 forward = delta / distanceToTarget; - vec2 side = vec2(-forward.y, forward.x); - - float flowPhase = hash11(particleId * 1.91 + 13.0 + u_seed) * TAU; - float field = sin(u_time * 1.25 + flowPhase + targetPosition.y * 0.015) - + 0.5 * sin(u_time * 2.1 - flowPhase * 1.3 + targetPosition.x * 0.01); - vec2 gust = vec2( - sin(u_time * 0.92 + flowPhase + targetPosition.y * 0.018), - cos(u_time * 1.08 - flowPhase + targetPosition.x * 0.014) - ); - - float envelope = (1.0 - progress); - envelope *= envelope; - envelope *= smoothstep(0.0, 0.08, t); - - float bend = min(distanceToTarget * 0.16, 56.0) * u_windStrength; - vec2 windOffset = side * field * bend * envelope; - windOffset += gust * (10.0 * u_windStrength) * envelope; - windOffset += forward * sin(u_time * 1.7 + flowPhase * 1.7) * (6.0 * u_windStrength) * envelope; - - vec2 position = mix(startPosition, targetPosition, progress) + windOffset; - - gl_Position = vec4(screenToClip(position), 0.0, 1.0); - gl_PointSize = max(1.0, cell * u_pointScale); -} -`; - -const fragmentShaderSource = `#version 300 es -precision highp float; - -in vec4 v_color; -out vec4 outColor; - -void main() { - vec2 centered = abs(gl_PointCoord - 0.5); - float edge = max(centered.x, centered.y); - float alpha = 1.0 - smoothstep(0.47, 0.5, edge); - - outColor = vec4(v_color.rgb, v_color.a * alpha); -} -`; - 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)); @@ -364,38 +238,6 @@ window.addEventListener('keydown', (event) => { } }); -requestAnimationFrame(render); - -function render(nowMs) { - const now = nowMs * 0.001; - const phase = (now - cycleStart) % getCycleDuration(); - - instances.forEach((instance) => { - renderInstance(instance, now, phase); - }); - - requestAnimationFrame(render); -} - -function renderInstance(instance, now, phase) { - if (!resizeInstance(instance)) { - return; - } - - const { gl, uniforms } = instance; - - gl.clearColor(0.02, 0.03, 0.06, 1.0); - gl.clear(gl.COLOR_BUFFER_BIT); - - gl.useProgram(instance.program); - gl.uniform2f(uniforms.canvasSize, instance.canvas.width, instance.canvas.height); - gl.uniform1f(uniforms.time, now); - gl.uniform1f(uniforms.phase, phase); - gl.uniform1f(uniforms.seed, globalSeed + instance.seedOffset); - - gl.drawArrays(gl.POINTS, 0, currentParticleCount); -} - function replay() { cycleStart = performance.now() * 0.001; globalSeed = Math.random() * 1000; @@ -531,66 +373,78 @@ function createDemoInstance(existingCanvas) { cell.appendChild(canvas); screenGrid.appendChild(cell); - const gl = canvas.getContext('webgl2', { - antialias: false, - alpha: false, - premultipliedAlpha: false, + 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, }); - if (!gl) { - throw new Error('This demo requires WebGL2.'); + 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.'); } - const program = createProgram(gl, vertexShaderSource, fragmentShaderSource); - const vao = gl.createVertexArray(); - const uniforms = { - source: gl.getUniformLocation(program, 'u_source'), - canvasSize: gl.getUniformLocation(program, 'u_canvasSize'), - imageSize: gl.getUniformLocation(program, 'u_imageSize'), - time: gl.getUniformLocation(program, 'u_time'), - phase: gl.getUniformLocation(program, 'u_phase'), - motionDuration: gl.getUniformLocation(program, 'u_motionDuration'), - delayWindow: gl.getUniformLocation(program, 'u_delayWindow'), - pointScale: gl.getUniformLocation(program, 'u_pointScale'), - spread: gl.getUniformLocation(program, 'u_spread'), - windStrength: gl.getUniformLocation(program, 'u_windStrength'), - seed: gl.getUniformLocation(program, 'u_seed'), - easeMode: gl.getUniformLocation(program, 'u_easeMode'), - }; - const sourceTexture = gl.createTexture(); - - gl.activeTexture(gl.TEXTURE0); - gl.bindTexture(gl.TEXTURE_2D, sourceTexture); - gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true); - 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.useProgram(program); - gl.uniform1i(uniforms.source, 0); - gl.uniform1f(uniforms.motionDuration, settings.duration); - gl.uniform1f(uniforms.delayWindow, settings.stagger); - gl.uniform1f(uniforms.pointScale, settings.pointScale); - gl.uniform1f(uniforms.spread, settings.spread); - gl.uniform1f(uniforms.windStrength, settings.wind); - gl.uniform1i(uniforms.easeMode, getEasingModeIndex(settings.easing)); - - gl.bindVertexArray(vao); gl.disable(gl.DEPTH_TEST); gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); - return { - cell, - canvas, - gl, - program, - vao, - uniforms, - sourceTexture, - seedOffset: Math.random() * 4000, + 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) { @@ -598,25 +452,14 @@ function destroyDemoInstance(instance) { return; } - const { gl, sourceTexture, vao, program, cell } = instance; - gl.deleteTexture(sourceTexture); - gl.deleteVertexArray(vao); - gl.deleteProgram(program); - gl.getExtension('WEBGL_lose_context')?.loseContext(); - cell.remove(); + instance.kampos.destroy(); + instance.cell.remove(); } function syncInstancesSource() { - instances.forEach((instance) => { - const { gl, sourceTexture, uniforms } = instance; - - gl.activeTexture(gl.TEXTURE0); - gl.bindTexture(gl.TEXTURE_2D, sourceTexture); - gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true); - gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, sourceCanvas); - - gl.useProgram(instance.program); - gl.uniform2f(uniforms.imageSize, currentPreset.width, currentPreset.height); + instances.forEach(({ effect }) => { + effect.sourceSize = currentPreset; + effect.source = sourceCanvas; }); } @@ -633,7 +476,7 @@ function resizeInstance(instance) { if (instance.canvas.width !== width || instance.canvas.height !== height) { instance.canvas.width = width; instance.canvas.height = height; - instance.gl.viewport(0, 0, width, height); + instance.kampos.gl.viewport(0, 0, width, height); } return true; @@ -675,6 +518,15 @@ 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); @@ -686,17 +538,14 @@ function syncAnimationControls() { } function syncInstanceSettings() { - const easeMode = getEasingModeIndex(settings.easing); - - instances.forEach((instance) => { - const { gl, uniforms } = instance; - gl.useProgram(instance.program); - gl.uniform1f(uniforms.motionDuration, settings.duration); - gl.uniform1f(uniforms.delayWindow, settings.stagger); - gl.uniform1f(uniforms.pointScale, settings.pointScale); - gl.uniform1f(uniforms.spread, settings.spread); - gl.uniform1f(uniforms.windStrength, settings.wind); - gl.uniform1i(uniforms.easeMode, easeMode); + 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; }); } @@ -1015,37 +864,6 @@ function roundedRect(ctx, x, y, width, height, radius) { ctx.closePath(); } -function createProgram(glContext, vertexSource, fragmentSource) { - const vertexShader = createShader(glContext, glContext.VERTEX_SHADER, vertexSource); - const fragmentShader = createShader(glContext, glContext.FRAGMENT_SHADER, fragmentSource); - const program = glContext.createProgram(); - - glContext.attachShader(program, vertexShader); - glContext.attachShader(program, fragmentShader); - glContext.linkProgram(program); - - if (!glContext.getProgramParameter(program, glContext.LINK_STATUS)) { - throw new Error(glContext.getProgramInfoLog(program) || 'Program failed to link.'); - } - - glContext.deleteShader(vertexShader); - glContext.deleteShader(fragmentShader); - - return program; -} - -function createShader(glContext, type, source) { - const shader = glContext.createShader(type); - glContext.shaderSource(shader, source); - glContext.compileShader(shader); - - if (!glContext.getShaderParameter(shader, glContext.COMPILE_STATUS)) { - throw new Error(glContext.getShaderInfoLog(shader) || 'Shader failed to compile.'); - } - - return shader; -} - function applyStyles(element, styles) { Object.assign(element.style, styles); } @@ -1102,6 +920,10 @@ function resolveEasing(value) { } function resolveNumberSetting(value, fallback, min, max) { + if (value === null || value === '') { + return fallback; + } + const numericValue = Number(value); if (!Number.isFinite(numericValue)) { @@ -1111,11 +933,6 @@ function resolveNumberSetting(value, fallback, min, max) { return clamp(numericValue, min, max); } -function getEasingModeIndex(value) { - const index = EASING_OPTIONS.findIndex((option) => option.value === value); - return index === -1 ? 0 : index; -} - function trimNumber(value, precision = 2) { return Number(value.toFixed(precision)).toString(); } 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;