IMP: 192kHz playback, adapted visualizers

This commit is contained in:
Julien Maille 2026-02-21 00:07:00 +01:00
parent 9b269d14fd
commit ae853636ce
6 changed files with 282 additions and 204 deletions

View file

@ -312,14 +312,33 @@ class AudioContextManager {
try {
const AudioContext = window.AudioContext || window.webkitAudioContext;
this.audioContext = new AudioContext();
// "playback" latency hint maximizes buffer size to prevent audio glitches (stuttering),
// which is critical for high-fidelity music listening.
// We also attempt to request 192kHz sample rate for high-res audio support.
const highResOptions = { sampleRate: 192000, latencyHint: 'playback' };
try {
this.audioContext = new AudioContext(highResOptions);
console.log(`[AudioContext] Created with high-res settings: ${this.audioContext.sampleRate}Hz`);
} catch (e) {
console.warn('[AudioContext] 192kHz/playback init failed, falling back to system defaults:', e);
// Fallback: Try just playback latency preference without forcing sample rate
try {
this.audioContext = new AudioContext({ latencyHint: 'playback' });
console.log(`[AudioContext] Created with system default rate: ${this.audioContext.sampleRate}Hz`);
} catch (e2) {
console.warn('[AudioContext] Playback latency hint failed, using defaults:', e2);
this.audioContext = new AudioContext();
}
}
// Create the media element source
this.source = this.audioContext.createMediaElementSource(audioElement);
// Create analyser for visualizer
this.analyser = this.audioContext.createAnalyser();
this.analyser.fftSize = 512;
this.analyser.fftSize = 1024;
this.analyser.smoothingTimeConstant = 0.7;
// Create biquad filters for EQ with dynamic band count
@ -411,7 +430,6 @@ class AudioContextManager {
lastNode.connect(this.analyser);
this.analyser.connect(this.volumeNode);
this.volumeNode.connect(this.audioContext.destination);
console.log('[AudioContext] EQ bypassed');
}
// Notify visualizers that graph has been reconnected

View file

@ -260,6 +260,8 @@ export const themeManager = {
this.applyCustomTheme(customTheme);
}
}
window.dispatchEvent(new CustomEvent('theme-changed', { detail: { theme } }));
},
getCustomTheme() {

View file

@ -96,6 +96,11 @@ export class UIRenderer {
window.addEventListener('reset-dynamic-color', () => {
this.resetVibrantColor();
});
// Listen for theme changes to re-apply vibrant colors
window.addEventListener('theme-changed', () => {
this.updateGlobalTheme();
});
}
// Helper for Heart Icon

View file

@ -190,10 +190,24 @@ export class Visualizer {
// ===== AUDIO ANALYSIS =====
this.analyser.getByteFrequencyData(this.dataArray);
// Bass (first bins only — cheap)
// Bass (dynamic bins based on sample rate)
const volume = 10 * Math.max(this.audio.volume, 0.1);
let bass =
((this.dataArray[0] + this.dataArray[1] + this.dataArray[2] + this.dataArray[3]) * 0.000980392) / volume;
// Robust bass detection: sum bins up to ~250Hz
const binSize = this.audioContext.sampleRate / this.analyser.fftSize;
const startBin = 1; // Skip DC offset
// Calculate how many bins cover the bass range (up to 250Hz)
let numBins = Math.floor(250 / binSize);
if (numBins < 1) numBins = 1; // Ensure at least one bin is checked
let maxVal = 0;
for (let i = 0; i < numBins && (startBin + i) < this.dataArray.length; i++) {
const val = this.dataArray[startBin + i];
if (val > maxVal) maxVal = val;
}
// Normalize: (Max / 255) / Volume
let bass = (maxVal) / 255 / volume;
const intensity = bass * bass * 10;
const stats = this.stats;

View file

@ -200,7 +200,7 @@ export class LCDPreset {
}
// --- Audio Data Processing ---
const data = this.processAudio(dataArray);
const data = this.processAudio(dataArray, analyser);
// --- Perspective Constants ---
const centerX = width / 2;
@ -279,28 +279,47 @@ export class LCDPreset {
}
// Process audio with improved dynamics
processAudio(dataArray) {
processAudio(dataArray, analyser) {
const result = new Float32Array(this.gridCols);
const center = Math.floor(this.gridCols / 2);
const totalBins = dataArray.length;
let peakVal = 0;
// Sample rate and bin size
const sampleRate = analyser?.context?.sampleRate || 48000;
const binSize = sampleRate / (totalBins * 2);
// Define frequency range to map
const minFreq = 40; // Start at 40Hz
const maxFreq = 22000; // End at 22kHz
for (let i = 0; i < center; i++) {
const p = i / (center - 1);
// Logarithmic frequency mapping: F = min * (max/min)^p
const targetStartFreq = minFreq * Math.pow(maxFreq / minFreq, p);
// Calculate next frequency to determine bandwidth of this bar
const pNext = (i + 1) / (center - 1);
const targetEndFreq = minFreq * Math.pow(maxFreq / minFreq, pNext); // Use pNext for end freq
// Logarithmic frequency mapping
const minBin = 2;
const maxBin = totalBins * 0.65;
const startBin = Math.floor(minBin * Math.pow(maxBin / minBin, p));
const endBin = Math.max(startBin + 1, Math.floor(minBin * Math.pow(maxBin / minBin, p + 1 / center)));
// Convert frequencies to bin indices
const startBin = Math.max(1, Math.floor(targetStartFreq / binSize));
const endBin = Math.max(startBin + 1, Math.floor(targetEndFreq / binSize));
let sum = 0,
count = 0;
// Sum bins for this column
for (let k = startBin; k < endBin && k < totalBins; k++) {
sum += dataArray[k];
count++;
}
let val = count > 0 ? sum / count : 0;
// Fallback: if range was too narrow (startBin >= endBin or count=0), sample the startBin directly
if (count === 0 && startBin < totalBins) {
val = dataArray[startBin];
}
// Pink noise compensation (boost highs)
val *= 1 + p * 1.8;

View file

@ -13,8 +13,9 @@ export class UnknownPleasuresWebGL {
static PROPAGATION_SPEED = 0.7;
// Glow intensity: controls how strong the glow effect is
// Lower = subtler glow (0.5 = subtle, 1.0 = normal, 2.0 = strong)
static GLOW_INTENSITY = 0.7;
static GLOW_INTENSITY = 5.0;
static NOISE_STRENGTH = 0.04;
constructor() {
this.name = 'Unknown Pleasures';
@ -92,28 +93,34 @@ export class UnknownPleasuresWebGL {
if (this.lineProgram) return;
this.gl = gl;
// === LINE SHADER (draws thick colored lines as quads) ===
// === LINE SHADER (draws thick colored lines as quads with AA edges) ===
const lineVS = `
attribute vec2 a_position;
attribute vec3 a_posEdge; // xy = position, z = edge distance (-1 to +1)
varying float v_edge;
void main() {
gl_Position = vec4(a_position, 0.0, 1.0);
gl_Position = vec4(a_posEdge.xy, 0.0, 1.0);
v_edge = a_posEdge.z;
}
`;
const lineFS = `
precision mediump float;
uniform vec3 u_color;
varying float v_edge;
void main() {
gl_FragColor = vec4(u_color, 1.0);
// Smooth antialiasing at edges
float edge = abs(v_edge);
float aa = 1.0 - smoothstep(0.6, 1.0, edge);
gl_FragColor = vec4(u_color * aa, aa);
}
`;
this.lineProgram = this._createProgram(gl, lineVS, lineFS);
if (!this.lineProgram) return;
this.line_a_position = gl.getAttribLocation(this.lineProgram, 'a_position');
this.line_a_posEdge = gl.getAttribLocation(this.lineProgram, 'a_posEdge');
this.line_u_color = gl.getUniformLocation(this.lineProgram, 'u_color');
// === BRIGHTNESS EXTRACTION SHADER ===
@ -136,30 +143,10 @@ export class UnknownPleasuresWebGL {
uniform float u_isDarkTheme;
void main() {
vec4 color = texture2D(u_texture, v_uv);
float contribution;
float outputMult;
if (u_isDarkTheme > 0.5) {
// Dark mode: use brightness (bright lines on dark background)
float brightness = max(color.r, max(color.g, color.b));
contribution = max(0.0, brightness - u_threshold) / (1.0 - u_threshold);
outputMult = 0.75;
} else {
// Light mode: use saturation (colored lines on gray background)
float maxC = max(color.r, max(color.g, color.b));
float minC = min(color.r, min(color.g, color.b));
float saturation = maxC > 0.0 ? (maxC - minC) / maxC : 0.0;
// Lower threshold to capture more of the line, boost output
contribution = max(0.0, saturation - 0.15) / 0.85;
// Boost contribution with power curve for stronger glow
contribution = pow(contribution, 0.7);
outputMult = 1.5;
}
// Output the glowing parts
gl_FragColor = vec4(color.rgb * contribution * outputMult, 1.0);
// Since Pass 1 now clears to transparent, the scene texture only contains the isolated lines.
// We don't need to extract brightness by darkening the background anymore.
// Just pass the lines through so they can be blurred.
gl_FragColor = texture2D(u_texture, v_uv);
}
`;
@ -190,27 +177,21 @@ export class UnknownPleasuresWebGL {
uniform sampler2D u_texture;
uniform vec2 u_resolution;
uniform vec2 u_direction;
uniform float u_radius;
uniform float u_spread; // Used instead of u_radius
// 9-tap Gaussian with expanding offsets
void main() {
vec2 texelSize = 1.0 / u_resolution;
// Fixed small step (1.5 pixels) for smooth gradient
// Multiple passes will extend the blur
vec2 step = u_direction * texelSize * 1.5;
// Expanding offsets for stronger glow (Thread Ripper Style)
vec2 off1 = vec2(1.3846153846) * u_direction * u_spread;
vec2 off2 = vec2(3.2307692308) * u_direction * u_spread;
// 9-tap Gaussian weights (sum = 1.0)
vec4 result =
texture2D(u_texture, v_uv - 4.0 * step) * 0.0162 +
texture2D(u_texture, v_uv - 3.0 * step) * 0.0540 +
texture2D(u_texture, v_uv - 2.0 * step) * 0.1216 +
texture2D(u_texture, v_uv - 1.0 * step) * 0.1945 +
texture2D(u_texture, v_uv) * 0.2270 +
texture2D(u_texture, v_uv + 1.0 * step) * 0.1945 +
texture2D(u_texture, v_uv + 2.0 * step) * 0.1216 +
texture2D(u_texture, v_uv + 3.0 * step) * 0.0540 +
texture2D(u_texture, v_uv + 4.0 * step) * 0.0162;
vec4 color = texture2D(u_texture, v_uv) * 0.2270270270;
color += texture2D(u_texture, v_uv + (off1 / u_resolution)) * 0.3162162162;
color += texture2D(u_texture, v_uv - (off1 / u_resolution)) * 0.3162162162;
color += texture2D(u_texture, v_uv + (off2 / u_resolution)) * 0.0702702703;
color += texture2D(u_texture, v_uv - (off2 / u_resolution)) * 0.0702702703;
gl_FragColor = result;
gl_FragColor = color;
}
`;
@ -221,39 +202,65 @@ export class UnknownPleasuresWebGL {
this.blur_u_texture = gl.getUniformLocation(this.blurProgram, 'u_texture');
this.blur_u_resolution = gl.getUniformLocation(this.blurProgram, 'u_resolution');
this.blur_u_direction = gl.getUniformLocation(this.blurProgram, 'u_direction');
this.blur_u_radius = gl.getUniformLocation(this.blurProgram, 'u_radius');
this.blur_u_spread = gl.getUniformLocation(this.blurProgram, 'u_spread');
// === COMPOSITE SHADER (combines original + blurred glow) ===
// === COMPOSITE SHADER (exact copy from Thread Ripper) ===
const compositeFS = `
precision mediump float;
varying vec2 v_uv;
uniform sampler2D u_scene;
uniform sampler2D u_blur;
uniform float u_glowStrength;
uniform float u_isDarkTheme;
uniform float u_noiseStrength;
uniform float u_isDarkTheme; // Kept for compatibility but unused in logic below
uniform float u_time;
float rand(vec2 co) {
return fract(sin(dot(co.xy ,vec2(12.9898,78.233))) * 43758.5453);
}
void main() {
vec4 original = texture2D(u_scene, v_uv);
vec4 blur = texture2D(u_blur, v_uv);
vec3 finalColor;
// Additive glow on top of original lines
vec3 rgb = original.rgb + blur.rgb * u_glowStrength;
if (u_isDarkTheme > 0.5) {
// Dark mode: additive glow (adds brightness to dark background)
vec3 glow = blur.rgb * u_glowStrength;
finalColor = original.rgb + glow;
} else {
// Light mode: TINT toward glow color instead of adding
// This shifts the gray background toward the line color
float glowIntensity = max(blur.r, max(blur.g, blur.b));
float tintStrength = glowIntensity * u_glowStrength * 0.8; // Boosted from 0.4
// Mix original with glow color based on intensity
vec3 glowColor = blur.rgb / max(glowIntensity, 0.001); // Normalize to get pure color
finalColor = mix(original.rgb, glowColor, tintStrength);
}
// Vignette: blur edges for depth
float dist = distance(v_uv, vec2(0.5));
float vignette = smoothstep(0.4, 0.8, dist);
// We handle scaling in the final mix later to avoid breaking the HDR mapping above.
// The rgb here is the base scene before the final exponential glow math.
float noise = rand(v_uv * 10.0);
float noiseStrength = 0.06;
rgb += (noise - 0.5) * noiseStrength;
// In light mode (u_isDarkTheme == 0.0), the additive glow effect naturally appears weaker
// against the bright background. We apply a 1.5x perceptual boost to match dark mode intensity.
float themeBoost = mix(1.5, 1.0, u_isDarkTheme);
// Using 1.0 - exp(-x) gives butter-smooth HDR-like falloff, eliminating harsh banding.
// We square the intensity (gamma 2.0) to dramatically increase the "core" opacity of the glow
// making it much more visible while preserving the smooth edges.
vec3 rawGlow = blur.rgb * (u_glowStrength * themeBoost);
float glowIntensity = max(rawGlow.r, max(rawGlow.g, rawGlow.b));
// Preserve alpha from scene (needed for semi-transparent backgrounds)
gl_FragColor = vec4(finalColor, original.a);
// Boost density significantly before applying HDR curve
float density = glowIntensity * glowIntensity * 1.5;
float smoothGlowAlpha = 1.0 - exp(-density);
// Keep the color strictly within valid premultiplied alpha bounds (rgb <= alpha)
vec3 safeGlowRgb = glowIntensity > 0.0 ? (rawGlow / glowIntensity) * smoothGlowAlpha : vec3(0.0);
// Additive over the core lines
rgb = original.rgb + safeGlowRgb;
// Final alpha is the line's alpha plus the glow's alpha
float finalAlpha = clamp(original.a + smoothGlowAlpha, 0.0, 1.0);
// Output RGB and Alpha for PREMULTIPLIED alpha blending
gl_FragColor = vec4(rgb, finalAlpha);
}
`;
@ -264,7 +271,9 @@ export class UnknownPleasuresWebGL {
this.composite_u_scene = gl.getUniformLocation(this.compositeProgram, 'u_scene');
this.composite_u_blur = gl.getUniformLocation(this.compositeProgram, 'u_blur');
this.composite_u_glowStrength = gl.getUniformLocation(this.compositeProgram, 'u_glowStrength');
this.composite_u_noiseStrength = gl.getUniformLocation(this.compositeProgram, 'u_noiseStrength');
this.composite_u_isDarkTheme = gl.getUniformLocation(this.compositeProgram, 'u_isDarkTheme');
this.composite_u_time = gl.getUniformLocation(this.compositeProgram, 'u_time');
// === FULLSCREEN QUAD BUFFER ===
this.quadBuffer = gl.createBuffer();
@ -333,7 +342,7 @@ export class UnknownPleasuresWebGL {
this.blurTexture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, this.blurTexture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); // LINEAR!
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
@ -354,6 +363,11 @@ export class UnknownPleasuresWebGL {
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this.blurFinalTexture, 0);
const status = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
if (status !== gl.FRAMEBUFFER_COMPLETE) {
console.error('Framebuffer incomplete:', status);
}
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
}
@ -393,62 +407,70 @@ export class UnknownPleasuresWebGL {
}
/**
* Generate quad vertices for a thick line segment with round joints
* Returns triangles for each segment + circles at joints
* Generate quad vertices for a thick line with proper miter joints.
* Precomputes averaged normals at shared vertices so segments connect seamlessly.
*/
_generateLineQuads(points, thickness, width, height) {
if (points.length < 2) return new Float32Array(0);
const vertices = [];
// Convert to clip space helper
const toClip = (x, y) => [(x / width) * 2 - 1, 1 - (y / height) * 2];
const n = points.length;
// Generate circle at a point (for round joints/caps)
const addCircle = (px, py, radius, segments = 8) => {
const [cx, cy] = toClip(px, py);
const rw = (radius / width) * 2;
const rh = (radius / height) * 2;
for (let s = 0; s < segments; s++) {
const a1 = (s / segments) * Math.PI * 2;
const a2 = ((s + 1) / segments) * Math.PI * 2;
vertices.push(cx, cy);
vertices.push(cx + Math.cos(a1) * rw, cy + Math.sin(a1) * rh);
vertices.push(cx + Math.cos(a2) * rw, cy + Math.sin(a2) * rh);
// Precompute per-segment normals
const segNx = new Float32Array(n - 1);
const segNy = new Float32Array(n - 1);
for (let i = 0; i < n - 1; i++) {
const dx = points[i + 1].x - points[i].x;
const dy = points[i + 1].y - points[i].y;
const len = Math.sqrt(dx * dx + dy * dy);
if (len < 0.001) {
segNx[i] = 0;
segNy[i] = -1;
} else {
segNx[i] = -dy / len;
segNy[i] = dx / len;
}
};
// Add start cap
if (points.length > 0) {
addCircle(points[0].x, points[0].y, thickness);
}
for (let i = 0; i < points.length - 1; i++) {
// Compute miter normals at each point (average of adjacent segment normals)
const miterNx = new Float32Array(n);
const miterNy = new Float32Array(n);
// First point: use first segment normal
miterNx[0] = segNx[0];
miterNy[0] = segNy[0];
// Last point: use last segment normal
miterNx[n - 1] = segNx[n - 2];
miterNy[n - 1] = segNy[n - 2];
// Interior points: average
for (let i = 1; i < n - 1; i++) {
let mx = segNx[i - 1] + segNx[i];
let my = segNy[i - 1] + segNy[i];
const ml = Math.sqrt(mx * mx + my * my);
if (ml < 0.001) {
mx = segNx[i];
my = segNy[i];
} else {
mx /= ml;
my /= ml;
}
miterNx[i] = mx;
miterNy[i] = my;
}
// Build quads using miter normals
for (let i = 0; i < n - 1; i++) {
const p1 = points[i];
const p2 = points[i + 1];
// Direction vector
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const len = Math.sqrt(dx * dx + dy * dy);
if (len < 0.001) continue;
const [x1a, y1a] = toClip(p1.x - miterNx[i] * thickness, p1.y - miterNy[i] * thickness);
const [x1b, y1b] = toClip(p1.x + miterNx[i] * thickness, p1.y + miterNy[i] * thickness);
const [x2a, y2a] = toClip(p2.x - miterNx[i + 1] * thickness, p2.y - miterNy[i + 1] * thickness);
const [x2b, y2b] = toClip(p2.x + miterNx[i + 1] * thickness, p2.y + miterNy[i + 1] * thickness);
// Perpendicular (normal) vector
const nx = (-dy / len) * thickness;
const ny = (dx / len) * thickness;
const [x1a, y1a] = toClip(p1.x - nx, p1.y - ny);
const [x1b, y1b] = toClip(p1.x + nx, p1.y + ny);
const [x2a, y2a] = toClip(p2.x - nx, p2.y - ny);
const [x2b, y2b] = toClip(p2.x + nx, p2.y + ny);
// Triangle 1
vertices.push(x1a, y1a, x1b, y1b, x2a, y2a);
// Triangle 2
vertices.push(x1b, y1b, x2b, y2b, x2a, y2a);
// Add round joint at p2 (connection point)
addCircle(p2.x, p2.y, thickness);
// Each vertex: [x, y, edge] where edge = -1 (bottom) or +1 (top)
vertices.push(x1a, y1a, -1.0, x1b, y1b, 1.0, x2a, y2a, -1.0);
vertices.push(x1b, y1b, 1.0, x2b, y2b, 1.0, x2a, y2a, -1.0);
}
return new Float32Array(vertices);
@ -459,15 +481,8 @@ export class UnknownPleasuresWebGL {
const { width, height } = canvas;
const isDark = document.documentElement.getAttribute('data-theme') !== 'white';
// Set CSS blend mode based on mode and theme
// Solid: normal (opaque background)
// Blended + Dark: screen (black=transparent, bright=visible)
// Blended + Light: normal (semi-transparent background overlay)
if (params.mode === 'blended' && isDark) {
canvas.style.mixBlendMode = 'screen';
} else {
canvas.style.mixBlendMode = 'normal';
}
// FORCE Normal blending as requested - no more screen blend tricks
canvas.style.mixBlendMode = 'normal';
// Initialize WebGL on first draw
if (!this.lineProgram) {
@ -491,7 +506,12 @@ export class UnknownPleasuresWebGL {
if (this._propagationAccum >= 1.0) {
this._propagationAccum -= 1.0;
const len = dataArray.length | 0;
const sampleRate = analyser.context.sampleRate;
const nyquist = sampleRate / 2;
const targetFreq = 22000; // Visualizing up to 22kHz
const scale = Math.min(1.0, targetFreq / nyquist);
const len = Math.floor(dataArray.length * scale);
const line = this.history[this.writeIndex];
if (line) {
for (let i = 0; i < pts; i++) {
@ -511,24 +531,11 @@ export class UnknownPleasuresWebGL {
const rotatedH = Math.abs(width * this._sin) + Math.abs(height * this._cos);
const size = Math.max(rotatedW, rotatedH) * 1.15;
// === PASS 1: Render lines to framebuffer ===
// === PASS 1: Scene ===
// We render lines to a transparent texture so we can composite them properly later
gl.bindFramebuffer(gl.FRAMEBUFFER, this.framebuffer);
gl.viewport(0, 0, width, height);
// Clear color based on mode and theme
// Solid: dark/light solid background
// Blended + Dark (screen): black (black=transparent in screen blend)
// Blended + Light: semi-transparent light (album art shows through)
if (params.mode !== 'blended') {
const bg = isDark ? [0.02, 0.02, 0.02, 1] : [0.9, 0.9, 0.9, 1];
gl.clearColor(bg[0], bg[1], bg[2], bg[3]);
} else if (isDark) {
// Dark: black for screen blend (black=transparent)
gl.clearColor(0, 0, 0, 1);
} else {
// Light: semi-transparent white overlay (frosted glass effect)
gl.clearColor(0.92, 0.92, 0.92, 0.85);
}
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
// Perspective constants - extended for better corner coverage
@ -539,9 +546,15 @@ export class UnknownPleasuresWebGL {
const B = totalH / (1 - 1 / (1 + depth));
const A = frontY - B;
// Enable blending for anti-aliased edges and proper alpha
// Lines output premultiplied alpha (color * aa, aa).
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
if (isDark) {
// Additive premultiplied
gl.blendFunc(gl.ONE, gl.ONE);
} else {
// Standard premultiplied
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
}
gl.useProgram(this.lineProgram);
@ -591,18 +604,23 @@ export class UnknownPleasuresWebGL {
gl.bindBuffer(gl.ARRAY_BUFFER, this.lineBuffer);
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.DYNAMIC_DRAW);
gl.enableVertexAttribArray(this.line_a_position);
gl.vertexAttribPointer(this.line_a_position, 2, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(this.line_a_posEdge);
gl.vertexAttribPointer(this.line_a_posEdge, 3, gl.FLOAT, false, 0, 0);
// Set color
// Set raw palette color
const color = this._paletteRGB[i] || [1, 1, 1];
gl.uniform3f(this.line_u_color, color[0], color[1], color[2]);
// Draw
gl.drawArrays(gl.TRIANGLES, 0, vertices.length / 2);
// Draw (vertices.length / 3 because each vertex is [x, y, edge])
gl.drawArrays(gl.TRIANGLES, 0, vertices.length / 3);
}
// === PASS 2: Extract bright pixels ===
// MUST DISABLE BLEND for post-processing passes so we strictly overwrite FBO contents!
gl.disable(gl.BLEND);
// === PASS 2: Bloom ===
gl.bindFramebuffer(gl.FRAMEBUFFER, this.blurFramebuffer);
gl.viewport(0, 0, width, height);
gl.clearColor(0, 0, 0, 0);
@ -613,7 +631,8 @@ export class UnknownPleasuresWebGL {
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.sceneTexture);
gl.uniform1i(this.brightness_u_texture, 0);
gl.uniform1f(this.brightness_u_threshold, 0.1); // Low threshold for dark mode
// NO THRESHOLD - EVERYTHING GLOWS (Thread Ripper Style)
gl.uniform1f(this.brightness_u_threshold, 0.0);
gl.uniform1f(this.brightness_u_isDarkTheme, isDark ? 1.0 : 0.0);
gl.bindBuffer(gl.ARRAY_BUFFER, this.quadBuffer);
@ -621,74 +640,72 @@ export class UnknownPleasuresWebGL {
gl.vertexAttribPointer(this.brightness_a_position, 2, gl.FLOAT, false, 0, 0);
gl.drawArrays(gl.TRIANGLES, 0, 6);
// === PASS 3: Horizontal Gaussian blur ===
gl.bindFramebuffer(gl.FRAMEBUFFER, this.blurFinalFramebuffer);
gl.viewport(0, 0, width, height);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
// === PASS 3: Gaussian Blur (Ping Pong) ===
gl.useProgram(this.blurProgram);
// Multiple blur passes with increasing step sizes for smooth wide blur
// This prevents banding by using overlapping samples
const numPasses = 3;
for (let pass = 0; pass < numPasses; pass++) {
const stepMultiplier = Math.pow(2, pass); // 1, 2, 4
// More iterations for wider, smoother glow (Thread Ripper uses 8 * 2 passes)
// We have 2 framebuffers: blurFramebuffer (holds brightness extract), blurFinalFramebuffer (temp)
// thread_ripper uses ping-pong. Let's adapt.
// Horizontal blur
gl.bindFramebuffer(gl.FRAMEBUFFER, this.blurFinalFramebuffer);
// We start with 'blurFramebuffer' having the bright pixels.
// We want to ping-pong between blurFramebuffer and blurFinalFramebuffer.
const iterations = 8;
let horizontal = true;
for (let i = 0; i < iterations * 2; i++) {
// Thread Ripper ping-pong: horizontal toggles each iteration
const destFBO = horizontal ? this.blurFinalFramebuffer : this.blurFramebuffer;
const srcTex = horizontal ? this.blurTexture : this.blurFinalTexture;
// Thread Ripper spread: grows linearly with i (not i/2)
// Increased by 50% from 0.375 to 0.5625 for wider glow
const spread = 1.0 + i * 0.5625;
gl.bindFramebuffer(gl.FRAMEBUFFER, destFBO);
gl.viewport(0, 0, width, height);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.blurTexture);
gl.bindTexture(gl.TEXTURE_2D, srcTex);
gl.uniform1i(this.blur_u_texture, 0);
gl.uniform2f(this.blur_u_resolution, width, height);
gl.uniform2f(this.blur_u_direction, stepMultiplier, 0.0); // Horizontal with scaled step
gl.uniform1f(this.blur_u_radius, 32.0);
gl.uniform2f(this.blur_u_direction, horizontal ? 1.0 : 0.0, horizontal ? 0.0 : 1.0);
gl.uniform1f(this.blur_u_spread, spread);
gl.bindBuffer(gl.ARRAY_BUFFER, this.quadBuffer);
gl.enableVertexAttribArray(this.blur_a_position);
gl.vertexAttribPointer(this.blur_a_position, 2, gl.FLOAT, false, 0, 0);
gl.drawArrays(gl.TRIANGLES, 0, 6);
// Vertical blur
gl.bindFramebuffer(gl.FRAMEBUFFER, this.blurFramebuffer);
gl.viewport(0, 0, width, height);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.blurFinalTexture);
gl.uniform1i(this.blur_u_texture, 0);
gl.uniform2f(this.blur_u_direction, 0.0, stepMultiplier); // Vertical with scaled step
gl.drawArrays(gl.TRIANGLES, 0, 6);
horizontal = !horizontal;
}
// === PASS 4: Composite original + blur ===
// Final result is in the LAST written framebuffer.
// iter 0 -> writes Final
// iter 1 -> writes Blur
// ...
// iter 15 -> writes Blur
// So 'blurTexture' holds the final blurred result.
// === PASS 4: Composite ===
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.viewport(0, 0, width, height);
// Clear color based on mode and theme
// Solid: opaque background
// Blended + Dark: black (for screen blend)
// Blended + Light: transparent (composite has the semi-transparent background in it)
// Clear color for MAIN canvas
if (params.mode !== 'blended') {
const bg = isDark ? [0.02, 0.02, 0.02, 1] : [0.9, 0.9, 0.9, 1];
gl.clearColor(bg[0], bg[1], bg[2], bg[3]);
} else if (isDark) {
gl.clearColor(0, 0, 0, 1);
gl.clearColor(0, 0, 0, 0.4); // Dark blended
} else {
// Light blended: composite will output semi-transparent, clear is transparent
gl.clearColor(0, 0, 0, 0);
gl.clearColor(0.95, 0.95, 0.95, 0.4); // Light frosted
}
gl.clear(gl.COLOR_BUFFER_BIT);
// Blending for final composite
// Classic normal blending for the final composite quad over the canvas background!
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
gl.useProgram(this.compositeProgram);
@ -697,13 +714,16 @@ export class UnknownPleasuresWebGL {
gl.uniform1i(this.composite_u_scene, 0);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, this.blurTexture); // V-blur result
// Use last output: horizontal toggles, so pick the right texture (Thread Ripper pattern)
gl.bindTexture(gl.TEXTURE_2D, horizontal ? this.blurTexture : this.blurFinalTexture);
gl.uniform1i(this.composite_u_blur, 1);
// Glow strength reacts to kick, scaled by GLOW_INTENSITY
const baseGlow = 1.8 + params.kick * 2.5;
const glowStrength = baseGlow * UnknownPleasuresWebGL.GLOW_INTENSITY;
// Glow strength - EXACT Thread Ripper formula
const glowBoost = 1.0 + params.kick; // Pulse with kick
const glowStrength = UnknownPleasuresWebGL.GLOW_INTENSITY * glowBoost;
gl.uniform1f(this.composite_u_glowStrength, glowStrength);
gl.uniform1f(this.composite_u_noiseStrength, UnknownPleasuresWebGL.NOISE_STRENGTH);
gl.uniform1f(this.composite_u_isDarkTheme, isDark ? 1.0 : 0.0);
gl.bindBuffer(gl.ARRAY_BUFFER, this.quadBuffer);