768 lines
29 KiB
JavaScript
768 lines
29 KiB
JavaScript
/**
|
|
* Unknown Pleasures WebGL Visualizer
|
|
*
|
|
* Uses GPU-accelerated rendering with:
|
|
* - Geometry-based thick lines (quads instead of LINE_STRIP)
|
|
* - Shader-based glow effect (post-processing blur)
|
|
* - Prepared for future ambient haze effects
|
|
*/
|
|
|
|
export class UnknownPleasuresWebGL {
|
|
// Propagation speed: controls how fast waves propagate between lines
|
|
// Higher = faster propagation (1.0 = default, 0.5 = slower, 2.0 = faster)
|
|
static PROPAGATION_SPEED = 0.7;
|
|
|
|
// Glow intensity: controls how strong the glow effect is
|
|
static GLOW_INTENSITY = 5.0;
|
|
|
|
static NOISE_STRENGTH = 0.04;
|
|
|
|
constructor() {
|
|
this.name = 'Unknown Pleasures';
|
|
this.contextType = 'webgl';
|
|
this.historySize = 25;
|
|
this.dataPoints = 96;
|
|
|
|
this.history = [];
|
|
this.writeIndex = 0;
|
|
|
|
this.pLookup = new Float32Array(this.dataPoints);
|
|
this.xLookup = new Float32Array(this.dataPoints);
|
|
|
|
// WebGL state
|
|
this.gl = null;
|
|
this.lineProgram = null;
|
|
this.glowProgram = null;
|
|
this.quadBuffer = null;
|
|
this.framebuffer = null;
|
|
this.sceneTexture = null;
|
|
|
|
// Cached values
|
|
this._paletteColor = '';
|
|
this._paletteRGB = null;
|
|
this.rotationAngle = Math.PI / 6;
|
|
this._cos = Math.cos(this.rotationAngle);
|
|
this._sin = Math.sin(this.rotationAngle);
|
|
|
|
// Propagation timing
|
|
this._propagationAccum = 0;
|
|
|
|
this.reset();
|
|
this._precompute();
|
|
}
|
|
|
|
reset() {
|
|
this.history.length = 0;
|
|
for (let i = 0; i < this.historySize; i++) {
|
|
this.history.push(new Float32Array(this.dataPoints));
|
|
}
|
|
this.writeIndex = 0;
|
|
}
|
|
|
|
resize(width, height) {
|
|
if (this.gl && this.sceneTexture) {
|
|
this._resizeFramebuffer(this.gl, width, height);
|
|
}
|
|
}
|
|
|
|
destroy() {
|
|
this.history.length = 0;
|
|
if (this.gl) {
|
|
if (this.lineProgram) this.gl.deleteProgram(this.lineProgram);
|
|
if (this.glowProgram) this.gl.deleteProgram(this.glowProgram);
|
|
if (this.quadBuffer) this.gl.deleteBuffer(this.quadBuffer);
|
|
if (this.framebuffer) this.gl.deleteFramebuffer(this.framebuffer);
|
|
if (this.sceneTexture) this.gl.deleteTexture(this.sceneTexture);
|
|
}
|
|
this.gl = null;
|
|
this.lineProgram = null;
|
|
this.glowProgram = null;
|
|
}
|
|
|
|
_precompute() {
|
|
const pts = this.dataPoints;
|
|
const inv = 1 / (pts - 1);
|
|
for (let i = 0; i < pts; i++) {
|
|
const p = Math.abs(i * inv - 0.5) * 2;
|
|
this.pLookup[i] = 1 - p * p * p;
|
|
this.xLookup[i] = i * inv;
|
|
}
|
|
}
|
|
|
|
_createBuffers() {
|
|
this.quadBuffer = this.gl.createBuffer();
|
|
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.quadBuffer);
|
|
this.gl.bufferData(
|
|
this.gl.ARRAY_BUFFER,
|
|
new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),
|
|
this.gl.STATIC_DRAW
|
|
);
|
|
|
|
this.lineBuffer = this.gl.createBuffer();
|
|
|
|
// Pre-allocate vertex buffer (max possible size: historySize * dataPoints * 6 vertices * 3 floats)
|
|
const maxVertices = this.historySize * this.dataPoints * 6; // 6 vertices per segment
|
|
this.vertexBuffer = new Float32Array(maxVertices * 3); // 3 floats per vertex (x,y,edge)
|
|
}
|
|
|
|
_initGL(gl, width, height) {
|
|
if (this.lineProgram) return;
|
|
this.gl = gl;
|
|
|
|
// === LINE SHADER (draws thick colored lines as quads with AA edges) ===
|
|
const lineVS = `
|
|
attribute vec3 a_posEdge; // xy = position, z = edge distance (-1 to +1)
|
|
varying float v_edge;
|
|
|
|
void main() {
|
|
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() {
|
|
// 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_posEdge = gl.getAttribLocation(this.lineProgram, 'a_posEdge');
|
|
this.line_u_color = gl.getUniformLocation(this.lineProgram, 'u_color');
|
|
|
|
// === BRIGHTNESS EXTRACTION SHADER ===
|
|
// This is KEY for bloom - extract bright pixels, blur them, add back
|
|
const brightnessVS = `
|
|
attribute vec2 a_position;
|
|
varying vec2 v_uv;
|
|
|
|
void main() {
|
|
v_uv = a_position * 0.5 + 0.5;
|
|
gl_Position = vec4(a_position, 0.0, 1.0);
|
|
}
|
|
`;
|
|
|
|
const brightnessFS = `
|
|
precision mediump float;
|
|
varying vec2 v_uv;
|
|
uniform sampler2D u_texture;
|
|
uniform float u_threshold;
|
|
uniform float u_isDarkTheme;
|
|
|
|
void main() {
|
|
// 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);
|
|
}
|
|
`;
|
|
|
|
this.brightnessProgram = this._createProgram(gl, brightnessVS, brightnessFS);
|
|
if (!this.brightnessProgram) return;
|
|
|
|
this.brightness_a_position = gl.getAttribLocation(this.brightnessProgram, 'a_position');
|
|
this.brightness_u_texture = gl.getUniformLocation(this.brightnessProgram, 'u_texture');
|
|
this.brightness_u_threshold = gl.getUniformLocation(this.brightnessProgram, 'u_threshold');
|
|
this.brightness_u_isDarkTheme = gl.getUniformLocation(this.brightnessProgram, 'u_isDarkTheme');
|
|
|
|
// === BLUR SHADER (two-pass separable Gaussian) ===
|
|
const blurVS = `
|
|
attribute vec2 a_position;
|
|
varying vec2 v_uv;
|
|
|
|
void main() {
|
|
v_uv = a_position * 0.5 + 0.5;
|
|
gl_Position = vec4(a_position, 0.0, 1.0);
|
|
}
|
|
`;
|
|
|
|
// 9-tap Gaussian blur with small fixed steps for smooth gradients
|
|
// Use multiple passes to extend blur radius
|
|
const blurFS = `
|
|
precision mediump float;
|
|
varying vec2 v_uv;
|
|
uniform sampler2D u_texture;
|
|
uniform vec2 u_resolution;
|
|
uniform vec2 u_direction;
|
|
uniform float u_spread; // Used instead of u_radius
|
|
|
|
// 9-tap Gaussian with expanding offsets
|
|
void main() {
|
|
// 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;
|
|
|
|
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 = color;
|
|
}
|
|
`;
|
|
|
|
this.blurProgram = this._createProgram(gl, blurVS, blurFS);
|
|
if (!this.blurProgram) return;
|
|
|
|
this.blur_a_position = gl.getAttribLocation(this.blurProgram, 'a_position');
|
|
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_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_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);
|
|
|
|
// Additive glow on top of original lines
|
|
vec3 rgb = original.rgb + blur.rgb * u_glowStrength;
|
|
|
|
// 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));
|
|
|
|
// 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);
|
|
}
|
|
`;
|
|
|
|
this.compositeProgram = this._createProgram(gl, blurVS, compositeFS);
|
|
if (!this.compositeProgram) return;
|
|
|
|
this.composite_a_position = gl.getAttribLocation(this.compositeProgram, 'a_position');
|
|
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');
|
|
|
|
this._createBuffers(); // Use helper
|
|
this._createFramebuffer(gl, width, height);
|
|
|
|
gl.enable(gl.BLEND);
|
|
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
|
}
|
|
|
|
_createProgram(gl, vsSource, fsSource) {
|
|
const vs = this._compileShader(gl, gl.VERTEX_SHADER, vsSource);
|
|
const fs = this._compileShader(gl, gl.FRAGMENT_SHADER, fsSource);
|
|
if (!vs || !fs) return null;
|
|
|
|
const program = gl.createProgram();
|
|
gl.attachShader(program, vs);
|
|
gl.attachShader(program, fs);
|
|
gl.linkProgram(program);
|
|
|
|
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
|
console.error('WebGL program link failed:', gl.getProgramInfoLog(program));
|
|
return null;
|
|
}
|
|
|
|
return program;
|
|
}
|
|
|
|
_compileShader(gl, type, source) {
|
|
const shader = gl.createShader(type);
|
|
gl.shaderSource(shader, source);
|
|
gl.compileShader(shader);
|
|
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
|
console.error('Shader compile error:', gl.getShaderInfoLog(shader));
|
|
gl.deleteShader(shader);
|
|
return null;
|
|
}
|
|
return shader;
|
|
}
|
|
|
|
_createFramebuffer(gl, width, height) {
|
|
// Framebuffer 1: Scene (lines) - FULL RESOLUTION
|
|
this.framebuffer = gl.createFramebuffer();
|
|
gl.bindFramebuffer(gl.FRAMEBUFFER, this.framebuffer);
|
|
|
|
this.sceneTexture = gl.createTexture();
|
|
gl.bindTexture(gl.TEXTURE_2D, this.sceneTexture);
|
|
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_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);
|
|
|
|
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this.sceneTexture, 0);
|
|
|
|
// Blur Resolution (Half size for performance)
|
|
const blurW = Math.max(1, width >> 1);
|
|
const blurH = Math.max(1, height >> 1);
|
|
|
|
// Framebuffer 2: Blur intermediate
|
|
this.blurFramebuffer = gl.createFramebuffer();
|
|
gl.bindFramebuffer(gl.FRAMEBUFFER, this.blurFramebuffer);
|
|
|
|
this.blurTexture = gl.createTexture();
|
|
gl.bindTexture(gl.TEXTURE_2D, this.blurTexture);
|
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, blurW, blurH, 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_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);
|
|
|
|
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this.blurTexture, 0);
|
|
|
|
// Framebuffer 3: Blur final
|
|
this.blurFinalFramebuffer = gl.createFramebuffer();
|
|
gl.bindFramebuffer(gl.FRAMEBUFFER, this.blurFinalFramebuffer);
|
|
|
|
this.blurFinalTexture = gl.createTexture();
|
|
gl.bindTexture(gl.TEXTURE_2D, this.blurFinalTexture);
|
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, blurW, blurH, 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_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);
|
|
|
|
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this.blurFinalTexture, 0);
|
|
|
|
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
|
}
|
|
|
|
_resizeFramebuffer(gl, width, height) {
|
|
const blurW = Math.max(1, width >> 1);
|
|
const blurH = Math.max(1, height >> 1);
|
|
|
|
gl.bindTexture(gl.TEXTURE_2D, this.sceneTexture);
|
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
|
|
|
|
gl.bindTexture(gl.TEXTURE_2D, this.blurTexture);
|
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, blurW, blurH, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
|
|
|
|
gl.bindTexture(gl.TEXTURE_2D, this.blurFinalTexture);
|
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, blurW, blurH, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
|
|
}
|
|
|
|
_buildPalette(color) {
|
|
// Parse color exactly like Canvas2D version
|
|
const r = parseInt(color.slice(1, 3), 16);
|
|
const g = parseInt(color.slice(3, 5), 16);
|
|
const b = parseInt(color.slice(5, 7), 16);
|
|
|
|
// perceptual grayscale (same weights browsers use)
|
|
const gray = 0.299 * r + 0.587 * g + 0.114 * b;
|
|
|
|
this._paletteRGB = [];
|
|
for (let i = 0; i < this.historySize; i++) {
|
|
const p = i / (this.historySize - 1);
|
|
|
|
// === Saturation gradient (HSL-like) - match Canvas2D exactly ===
|
|
const sat = 3.0 - 2 * p;
|
|
// Clamp to 0-255 like Canvas2D does with | 0
|
|
const rr = Math.max(0, Math.min(255, (gray + (r - gray) * sat) | 0)) / 255;
|
|
const gg = Math.max(0, Math.min(255, (gray + (g - gray) * sat) | 0)) / 255;
|
|
const bb = Math.max(0, Math.min(255, (gray + (b - gray) * sat) | 0)) / 255;
|
|
|
|
this._paletteRGB.push([rr, gg, bb]);
|
|
}
|
|
|
|
this._paletteColor = color;
|
|
}
|
|
|
|
_generateLineQuads(points, thickness, width, height, outBuffer, offset) {
|
|
if (points.length < 2) return 0;
|
|
|
|
const n = points.length;
|
|
let ptr = offset;
|
|
|
|
// Precompute normals (reuse internal arrays if possible, but for now stack var is fine)
|
|
// Optimization: Single pass miter calculation
|
|
|
|
// Helper to clip X,Y
|
|
const wInv = 2 / width;
|
|
const hInv = 2 / height;
|
|
|
|
for (let i = 0; i < n - 1; i++) {
|
|
const p1 = points[i];
|
|
const p2 = points[i + 1];
|
|
|
|
// Calculate segment normal
|
|
let dx = p2.x - p1.x;
|
|
let dy = p2.y - p1.y;
|
|
let len = Math.sqrt(dx * dx + dy * dy);
|
|
let nx, ny;
|
|
|
|
if (len < 0.001) {
|
|
nx = 0;
|
|
ny = -1;
|
|
} else {
|
|
nx = -dy / len;
|
|
ny = dx / len;
|
|
}
|
|
|
|
// Previous normal (for miter)
|
|
let prevNx = nx,
|
|
prevNy = ny;
|
|
if (i > 0) {
|
|
const p0 = points[i - 1];
|
|
const dx0 = p1.x - p0.x;
|
|
const dy0 = p1.y - p0.y;
|
|
const len0 = Math.sqrt(dx0 * dx0 + dy0 * dy0);
|
|
if (len0 >= 0.001) {
|
|
prevNx = -dy0 / len0;
|
|
prevNy = dx0 / len0;
|
|
}
|
|
}
|
|
|
|
// Miter at P1
|
|
let m1x = nx + prevNx;
|
|
let m1y = ny + prevNy;
|
|
let m1l = Math.sqrt(m1x * m1x + m1y * m1y);
|
|
if (m1l > 0.001) {
|
|
m1x /= m1l;
|
|
m1y /= m1l;
|
|
}
|
|
|
|
// Next normal (for P2 miter)
|
|
let nextNx = nx,
|
|
nextNy = ny;
|
|
if (i < n - 2) {
|
|
const p3 = points[i + 2];
|
|
const dx2 = p3.x - p2.x;
|
|
const dy2 = p3.y - p2.y;
|
|
const len2 = Math.sqrt(dx2 * dx2 + dy2 * dy2);
|
|
if (len2 >= 0.001) {
|
|
nextNx = -dy2 / len2;
|
|
nextNy = dx2 / len2;
|
|
}
|
|
}
|
|
|
|
// Miter at P2
|
|
let m2x = nx + nextNx;
|
|
let m2y = ny + nextNy;
|
|
let m2l = Math.sqrt(m2x * m2x + m2y * m2y);
|
|
if (m2l > 0.001) {
|
|
m2x /= m2l;
|
|
m2y /= m2l;
|
|
}
|
|
|
|
// Generate vertices
|
|
// P1 Top
|
|
const x1a = (p1.x - m1x * thickness) * wInv - 1;
|
|
const y1a = 1 - (p1.y - m1y * thickness) * hInv;
|
|
|
|
// P1 Bottom
|
|
const x1b = (p1.x + m1x * thickness) * wInv - 1;
|
|
const y1b = 1 - (p1.y + m1y * thickness) * hInv;
|
|
|
|
// P2 Top
|
|
const x2a = (p2.x - m2x * thickness) * wInv - 1;
|
|
const y2a = 1 - (p2.y - m2y * thickness) * hInv;
|
|
|
|
// P2 Bottom
|
|
const x2b = (p2.x + m2x * thickness) * wInv - 1;
|
|
const y2b = 1 - (p2.y + m2y * thickness) * hInv;
|
|
|
|
// Triangle 1
|
|
outBuffer[ptr++] = x1a;
|
|
outBuffer[ptr++] = y1a;
|
|
outBuffer[ptr++] = -1.0;
|
|
outBuffer[ptr++] = x1b;
|
|
outBuffer[ptr++] = y1b;
|
|
outBuffer[ptr++] = 1.0;
|
|
outBuffer[ptr++] = x2a;
|
|
outBuffer[ptr++] = y2a;
|
|
outBuffer[ptr++] = -1.0;
|
|
|
|
// Triangle 2
|
|
outBuffer[ptr++] = x1b;
|
|
outBuffer[ptr++] = y1b;
|
|
outBuffer[ptr++] = 1.0;
|
|
outBuffer[ptr++] = x2b;
|
|
outBuffer[ptr++] = y2b;
|
|
outBuffer[ptr++] = 1.0;
|
|
outBuffer[ptr++] = x2a;
|
|
outBuffer[ptr++] = y2a;
|
|
outBuffer[ptr++] = -1.0;
|
|
}
|
|
|
|
return ptr - offset;
|
|
}
|
|
|
|
draw(ctx, canvas, analyser, dataArray, params) {
|
|
const gl = ctx;
|
|
const { width, height } = canvas;
|
|
const isDark = document.documentElement.getAttribute('data-theme') !== 'white';
|
|
|
|
canvas.style.mixBlendMode = 'normal';
|
|
|
|
if (!this.lineProgram) {
|
|
this._initGL(gl, width, height);
|
|
}
|
|
|
|
if (this.history.length === 0) {
|
|
this.reset();
|
|
}
|
|
|
|
if (!params.paused) {
|
|
this._propagationAccum += UnknownPleasuresWebGL.PROPAGATION_SPEED;
|
|
const pts = this.dataPoints;
|
|
|
|
if (this._propagationAccum >= 1.0) {
|
|
this._propagationAccum -= 1.0;
|
|
|
|
const sampleRate = analyser.context.sampleRate;
|
|
const nyquist = sampleRate / 2;
|
|
const targetFreq = 22000;
|
|
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++) {
|
|
line[i] = (dataArray[(this.xLookup[i] * len) | 0] / 255) * this.pLookup[i];
|
|
}
|
|
}
|
|
this.writeIndex = (this.writeIndex + 1) % this.historySize;
|
|
}
|
|
}
|
|
|
|
if (this._paletteColor !== params.primaryColor) {
|
|
this._buildPalette(params.primaryColor);
|
|
}
|
|
|
|
// === PASS 1: Scene ===
|
|
gl.bindFramebuffer(gl.FRAMEBUFFER, this.framebuffer);
|
|
gl.viewport(0, 0, width, height);
|
|
gl.clearColor(0, 0, 0, 0);
|
|
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
|
|
// Constants
|
|
const size =
|
|
Math.max(
|
|
Math.abs(width * this._cos) + Math.abs(height * this._sin),
|
|
Math.abs(width * this._sin) + Math.abs(height * this._cos)
|
|
) * 1.15;
|
|
const horizonY = size * 0.05;
|
|
const frontY = size * 0.9;
|
|
const depth = 2.0;
|
|
const totalH = frontY - horizonY;
|
|
const B = totalH / (1 - 1 / (1 + depth));
|
|
const A = frontY - B;
|
|
|
|
// --- BATCH GEOMETRY GENERATION ---
|
|
// Fill the vertex buffer with ALL lines for this frame
|
|
let bufferOffset = 0;
|
|
// Store draw commands to execute later: { start, count, colorIndex }
|
|
const drawCommands = [];
|
|
|
|
// Reuse temporary points array
|
|
if (!this._tempPoints) this._tempPoints = [];
|
|
const points = this._tempPoints;
|
|
const pts = this.dataPoints;
|
|
const cx = width / 2;
|
|
const cy = height / 2;
|
|
const cosR = this._cos;
|
|
const sinR = this._sin;
|
|
const offsetX = -size / 2;
|
|
const offsetY = -size / 2;
|
|
|
|
for (let i = this.historySize - 1; i >= 0; i--) {
|
|
const idx = (this.writeIndex + i) % this.historySize;
|
|
const historyLine = this.history[idx];
|
|
|
|
const p = 1 - i / (this.historySize - 1);
|
|
const z = 1 + p * depth;
|
|
const scale = 1 / z;
|
|
const y = A + B / z;
|
|
|
|
const lw = size * scale * 1.5;
|
|
const margin = (size - lw) * 0.5;
|
|
const amp = 200 * scale;
|
|
const lineWidth = Math.max(1, 8 * scale + params.kick * 3);
|
|
|
|
// Generate points
|
|
points.length = 0;
|
|
for (let j = 0; j < pts; j++) {
|
|
const rx = margin + this.xLookup[j] * lw;
|
|
const ry = y - historyLine[j] * amp;
|
|
const dx = rx + offsetX;
|
|
const dy = ry + offsetY;
|
|
points.push({ x: dx * cosR - dy * sinR + cx, y: dx * sinR + dy * cosR + cy });
|
|
}
|
|
|
|
// Write to buffer
|
|
const vertexCount = this._generateLineQuads(
|
|
points,
|
|
lineWidth / 2,
|
|
width,
|
|
height,
|
|
this.vertexBuffer,
|
|
bufferOffset
|
|
);
|
|
|
|
if (vertexCount > 0) {
|
|
drawCommands.push({
|
|
start: bufferOffset / 3, // Start vertex index
|
|
count: vertexCount / 3, // Number of vertices
|
|
colorIndex: i,
|
|
});
|
|
bufferOffset += vertexCount; // Advance by number of floats
|
|
}
|
|
}
|
|
|
|
// --- UPLOAD ONCE ---
|
|
gl.bindBuffer(gl.ARRAY_BUFFER, this.lineBuffer);
|
|
// Upload only the used portion of the pre-allocated buffer
|
|
gl.bufferData(gl.ARRAY_BUFFER, this.vertexBuffer.subarray(0, bufferOffset), gl.DYNAMIC_DRAW);
|
|
gl.enableVertexAttribArray(this.line_a_posEdge);
|
|
gl.vertexAttribPointer(this.line_a_posEdge, 3, gl.FLOAT, false, 0, 0);
|
|
|
|
// --- DRAW BATCH ---
|
|
gl.useProgram(this.lineProgram);
|
|
gl.enable(gl.BLEND);
|
|
if (isDark) {
|
|
gl.blendFunc(gl.ONE, gl.ONE);
|
|
} else {
|
|
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
|
|
}
|
|
|
|
for (const cmd of drawCommands) {
|
|
const color = this._paletteRGB[cmd.colorIndex] || [1, 1, 1];
|
|
gl.uniform3f(this.line_u_color, color[0], color[1], color[2]);
|
|
gl.drawArrays(gl.TRIANGLES, cmd.start, cmd.count);
|
|
}
|
|
|
|
gl.disable(gl.BLEND);
|
|
|
|
// === PASS 2: Bloom (Half Res) ===
|
|
const blurW = Math.max(1, width >> 1);
|
|
const blurH = Math.max(1, height >> 1);
|
|
|
|
gl.bindFramebuffer(gl.FRAMEBUFFER, this.blurFramebuffer);
|
|
gl.viewport(0, 0, blurW, blurH);
|
|
gl.clearColor(0, 0, 0, 0);
|
|
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
|
|
gl.useProgram(this.brightnessProgram);
|
|
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.0);
|
|
gl.uniform1f(this.brightness_u_isDarkTheme, isDark ? 1.0 : 0.0);
|
|
|
|
gl.bindBuffer(gl.ARRAY_BUFFER, this.quadBuffer);
|
|
gl.enableVertexAttribArray(this.brightness_a_position);
|
|
gl.vertexAttribPointer(this.brightness_a_position, 2, gl.FLOAT, false, 0, 0);
|
|
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
|
|
|
// === PASS 3: Gaussian Blur (Ping Pong) ===
|
|
gl.useProgram(this.blurProgram);
|
|
|
|
const iterations = 4;
|
|
let horizontal = true;
|
|
|
|
for (let i = 0; i < iterations * 2; i++) {
|
|
const destFBO = horizontal ? this.blurFinalFramebuffer : this.blurFramebuffer;
|
|
const srcTex = horizontal ? this.blurTexture : this.blurFinalTexture;
|
|
const spread = 1.0 + i * 0.75;
|
|
|
|
gl.bindFramebuffer(gl.FRAMEBUFFER, destFBO);
|
|
gl.activeTexture(gl.TEXTURE0);
|
|
gl.bindTexture(gl.TEXTURE_2D, srcTex);
|
|
gl.uniform1i(this.blur_u_texture, 0);
|
|
gl.uniform2f(this.blur_u_resolution, blurW, blurH);
|
|
gl.uniform2f(this.blur_u_direction, horizontal ? 1.0 : 0.0, horizontal ? 0.0 : 1.0);
|
|
gl.uniform1f(this.blur_u_spread, spread);
|
|
|
|
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
|
horizontal = !horizontal;
|
|
}
|
|
|
|
// === PASS 4: Composite ===
|
|
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
|
gl.viewport(0, 0, width, height);
|
|
|
|
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, 0.4);
|
|
} else {
|
|
gl.clearColor(0.95, 0.95, 0.95, 0.4);
|
|
}
|
|
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
|
|
gl.enable(gl.BLEND);
|
|
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
|
|
|
|
gl.useProgram(this.compositeProgram);
|
|
gl.activeTexture(gl.TEXTURE0);
|
|
gl.bindTexture(gl.TEXTURE_2D, this.sceneTexture);
|
|
gl.uniform1i(this.composite_u_scene, 0);
|
|
|
|
gl.activeTexture(gl.TEXTURE1);
|
|
gl.bindTexture(gl.TEXTURE_2D, horizontal ? this.blurTexture : this.blurFinalTexture);
|
|
gl.uniform1i(this.composite_u_blur, 1);
|
|
|
|
const glowBoost = 1.0 + params.kick;
|
|
gl.uniform1f(this.composite_u_glowStrength, UnknownPleasuresWebGL.GLOW_INTENSITY * glowBoost);
|
|
gl.uniform1f(this.composite_u_noiseStrength, UnknownPleasuresWebGL.NOISE_STRENGTH);
|
|
gl.uniform1f(this.composite_u_isDarkTheme, isDark ? 1.0 : 0.0);
|
|
gl.uniform1f(this.composite_u_time, performance.now() / 1000.0);
|
|
|
|
gl.bindBuffer(gl.ARRAY_BUFFER, this.quadBuffer);
|
|
gl.enableVertexAttribArray(this.composite_a_position);
|
|
gl.vertexAttribPointer(this.composite_a_position, 2, gl.FLOAT, false, 0, 0);
|
|
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
|
}
|
|
}
|