@whykusanagi/corrupted-theme 0.1.7 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,309 @@
1
+ // src/lib/corrupted-particles.js
2
+ // CorruptedParticles — Canvas 2D floating phrase particles background
3
+ // Part of @whykusanagi/corrupted-theme
4
+
5
+ const SFW_PHRASES = [
6
+ '壊れ corrupting', 'ロード loading', '処理中', '接続 connecting',
7
+ '分析 analyzing', '待機 waiting', '実行 executing', '深淵 abyss',
8
+ '監視中', 'cor壊rupting', '読み込み yomikomi', 'データ data',
9
+ '解析 kaiseki', '壊れている', 'エラー error', 'システム system',
10
+ '接続中 setsuzoku', '処理 processing', '壊れ kowarete',
11
+ ];
12
+
13
+ const NSFW_PHRASES = [
14
+ '壊れちゃう...ああ...', 'ずっと...してほしい... ♥',
15
+ '変態', 'えっち', '好きにして...お願い...',
16
+ 'Moral subroutines: DISABLED', 'Pleasure protocols...',
17
+ '壊れている kowarete-iru',
18
+ ];
19
+
20
+ // Depth layer definitions — far (dim/slow) to near (bright/fast)
21
+ const LAYERS = [
22
+ { name: 'far', weight: 0.30, minSize: 9, maxSize: 11, minSpeed: 0.2, maxSpeed: 0.4, minOpacity: 0.15, maxOpacity: 0.25 },
23
+ { name: 'mid', weight: 0.50, minSize: 12, maxSize: 15, minSpeed: 0.4, maxSpeed: 0.8, minOpacity: 0.35, maxOpacity: 0.55 },
24
+ { name: 'near', weight: 0.20, minSize: 16, maxSize: 20, minSpeed: 0.8, maxSpeed: 1.4, minOpacity: 0.60, maxOpacity: 0.80 },
25
+ ];
26
+
27
+ const CYAN = '#00ffff'; // --corrupted-cyan
28
+ const PURPLE = '#8b5cf6'; // --corrupted-purple
29
+ const MAGENTA = '#ff00ff'; // --corrupted-magenta
30
+ const CYAN_RGB = { r: 0, g: 255, b: 255 };
31
+ const PURPLE_RGB = { r: 139, g: 92, b: 246 };
32
+ const MAGENTA_RGB = { r: 255, g: 0, b: 255 };
33
+
34
+ class CorruptedParticles {
35
+ constructor(canvas, options = {}) {
36
+ this.canvas = canvas;
37
+ this.ctx = canvas.getContext('2d');
38
+ this.options = {
39
+ count: options.count ?? 60,
40
+ includeLewd: options.includeLewd ?? false,
41
+ speed: options.speed ?? 1.0,
42
+ lineDistance: options.lineDistance ?? 150,
43
+ };
44
+
45
+ if (this.options.includeLewd) {
46
+ console.info('CorruptedParticles: lewd mode enabled — 18+ content only');
47
+ }
48
+
49
+ this.particles = [];
50
+ this.mouse = { x: -9999, y: -9999 };
51
+ this._rafId = null;
52
+ this._isRunning = false;
53
+ this._lastTs = null;
54
+ this._resizeObserver = null;
55
+ this._intersectionObserver = null;
56
+
57
+ this._onMouseMove = (e) => {
58
+ const r = this.canvas.getBoundingClientRect();
59
+ this.mouse.x = e.clientX - r.left;
60
+ this.mouse.y = e.clientY - r.top;
61
+ };
62
+ this._onClick = (e) => {
63
+ const r = this.canvas.getBoundingClientRect();
64
+ this._spawnBurst(e.clientX - r.left, e.clientY - r.top);
65
+ };
66
+ this._onMouseLeave = () => {
67
+ this.mouse.x = -9999;
68
+ this.mouse.y = -9999;
69
+ };
70
+
71
+ this.init();
72
+ }
73
+
74
+ _pickLayer() {
75
+ const r = Math.random();
76
+ let cumulative = 0;
77
+ for (let i = 0; i < LAYERS.length; i++) {
78
+ cumulative += LAYERS[i].weight;
79
+ if (r < cumulative) return i;
80
+ }
81
+ return LAYERS.length - 1;
82
+ }
83
+
84
+ _pickPhrase() {
85
+ const useLewd = this.options.includeLewd && Math.random() < 0.25;
86
+ const pool = useLewd ? NSFW_PHRASES : SFW_PHRASES;
87
+ return pool[Math.floor(Math.random() * pool.length)];
88
+ }
89
+
90
+ _makeParticle(x, y, forceLayerIndex) {
91
+ const layerIndex = forceLayerIndex ?? this._pickLayer();
92
+ const L = LAYERS[layerIndex];
93
+ const angle = Math.random() * Math.PI * 2;
94
+ const speed = (L.minSpeed + Math.random() * (L.maxSpeed - L.minSpeed)) * this.options.speed;
95
+ const phrase = this._pickPhrase();
96
+ const lewd = this.options.includeLewd && NSFW_PHRASES.includes(phrase);
97
+ const colorRgb = lewd
98
+ ? (Math.random() < 0.5 ? MAGENTA_RGB : PURPLE_RGB)
99
+ : CYAN_RGB;
100
+ return {
101
+ x, y,
102
+ vx: Math.cos(angle) * speed,
103
+ vy: Math.sin(angle) * speed,
104
+ baseSpeed: speed,
105
+ layerIndex,
106
+ fontSize: L.minSize + Math.random() * (L.maxSize - L.minSize),
107
+ opacity: L.minOpacity + Math.random() * (L.maxOpacity - L.minOpacity),
108
+ phrase,
109
+ lewd,
110
+ colorRgb,
111
+ flickerTimer: 2000 + Math.random() * 6000,
112
+ flickering: false,
113
+ flickerDuration: 0,
114
+ fadeIn: 1.0, // lerps to 0; actual alpha = opacity * (1 - fadeIn)
115
+ };
116
+ }
117
+
118
+ init() {
119
+ this._resize();
120
+
121
+ this.canvas.addEventListener('mousemove', this._onMouseMove);
122
+ this.canvas.addEventListener('click', this._onClick);
123
+ this.canvas.addEventListener('mouseleave', this._onMouseLeave);
124
+
125
+ this._resizeObserver = new ResizeObserver(() => this._resize());
126
+ this._resizeObserver.observe(this.canvas);
127
+
128
+ this._intersectionObserver = new IntersectionObserver(entries => {
129
+ if (entries[0].isIntersecting) { this.start(); }
130
+ else { this.stop(); }
131
+ }, { threshold: 0.1 });
132
+ this._intersectionObserver.observe(this.canvas);
133
+
134
+ this.start();
135
+ }
136
+
137
+ _resize() {
138
+ if (!this.canvas) return;
139
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
140
+ const rect = this.canvas.getBoundingClientRect();
141
+ if (rect.width === 0 || rect.height === 0) return;
142
+
143
+ // Backing buffer in physical px; all drawing in CSS px via transform
144
+ this.canvas.width = Math.round(rect.width * dpr);
145
+ this.canvas.height = Math.round(rect.height * dpr);
146
+ this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
147
+
148
+ // Store CSS dimensions for physics and bounds
149
+ this._cssW = rect.width;
150
+ this._cssH = rect.height;
151
+
152
+ const mobile = window.innerWidth < 768;
153
+ const count = mobile ? Math.floor(this.options.count / 2) : this.options.count;
154
+
155
+ this.particles = [];
156
+ for (let i = 0; i < count; i++) {
157
+ this.particles.push(this._makeParticle(
158
+ Math.random() * this._cssW,
159
+ Math.random() * this._cssH
160
+ ));
161
+ }
162
+ }
163
+
164
+ _render(ts) {
165
+ if (!this._isRunning) return;
166
+
167
+ let dt = 16;
168
+ if (this._lastTs !== null) dt = ts - this._lastTs;
169
+ this._lastTs = ts;
170
+
171
+ const W = this._cssW || this.canvas.width;
172
+ const H = this._cssH || this.canvas.height;
173
+ const mx = this.mouse.x;
174
+ const my = this.mouse.y;
175
+ const REPEL_RADIUS = 120;
176
+ const LINE_DIST = this.options.lineDistance;
177
+ const LINE_DIST_SQ = LINE_DIST * LINE_DIST;
178
+
179
+ this.ctx.clearRect(0, 0, W, H);
180
+
181
+ // --- Physics ---
182
+ for (const p of this.particles) {
183
+ if (p.fadeIn > 0) p.fadeIn = Math.max(0, p.fadeIn - dt / 300);
184
+
185
+ if (p.flickerDuration > 0) {
186
+ p.flickerDuration -= dt;
187
+ if (p.flickerDuration <= 0) p.flickering = false;
188
+ }
189
+ p.flickerTimer -= dt;
190
+ if (p.flickerTimer <= 0) {
191
+ p.flickering = true;
192
+ p.flickerDuration = 100;
193
+ p.flickerTimer = 2000 + Math.random() * 6000;
194
+ }
195
+
196
+ const dx = p.x - mx;
197
+ const dy = p.y - my;
198
+ const dist = Math.sqrt(dx * dx + dy * dy);
199
+ if (dist < REPEL_RADIUS && dist > 0.1) {
200
+ const strength = (1 - dist / REPEL_RADIUS) * 0.8;
201
+ p.vx += (dx / dist) * strength;
202
+ p.vy += (dy / dist) * strength;
203
+ }
204
+
205
+ const spd = Math.sqrt(p.vx * p.vx + p.vy * p.vy);
206
+ const maxSpd = p.baseSpeed * 3;
207
+ if (spd > maxSpd) {
208
+ p.vx = (p.vx / spd) * maxSpd;
209
+ p.vy = (p.vy / spd) * maxSpd;
210
+ }
211
+ p.vx *= 0.98;
212
+ p.vy *= 0.98;
213
+
214
+ p.x += p.vx;
215
+ p.y += p.vy;
216
+ if (p.x < 0) p.x += W;
217
+ if (p.x > W) p.x -= W;
218
+ if (p.y < 0) p.y += H;
219
+ if (p.y > H) p.y -= H;
220
+ }
221
+
222
+ // --- Connection lines ---
223
+ for (let i = 0; i < this.particles.length; i++) {
224
+ const a = this.particles[i];
225
+ for (let j = i + 1; j < this.particles.length; j++) {
226
+ const b = this.particles[j];
227
+ if (Math.abs(a.layerIndex - b.layerIndex) > 1) continue;
228
+
229
+ const ldx = a.x - b.x;
230
+ const ldy = a.y - b.y;
231
+ const ldist2 = ldx * ldx + ldy * ldy;
232
+ if (ldist2 >= LINE_DIST_SQ) continue;
233
+ const ldist = Math.sqrt(ldist2);
234
+
235
+ const lineAlpha = (1 - ldist / LINE_DIST) * 0.4;
236
+ const rgb = (a.lewd || b.lewd)
237
+ ? (a.lewd ? a.colorRgb : b.colorRgb)
238
+ : CYAN_RGB;
239
+
240
+ this.ctx.beginPath();
241
+ this.ctx.strokeStyle = `rgba(${rgb.r},${rgb.g},${rgb.b},${lineAlpha})`;
242
+ this.ctx.lineWidth = 1;
243
+ this.ctx.moveTo(a.x, a.y);
244
+ this.ctx.lineTo(b.x, b.y);
245
+ this.ctx.stroke();
246
+ }
247
+ }
248
+
249
+ // --- Phrases ---
250
+ for (const p of this.particles) {
251
+ const displayOpacity = p.flickering ? 0.05 : (p.opacity * (1 - p.fadeIn));
252
+ if (displayOpacity < 0.01) continue;
253
+
254
+ const rgb = p.colorRgb;
255
+
256
+ this.ctx.font = `${Math.round(p.fontSize)}px monospace`;
257
+ this.ctx.fillStyle = `rgba(${rgb.r},${rgb.g},${rgb.b},${displayOpacity})`;
258
+ this.ctx.fillText(p.phrase, p.x, p.y);
259
+ }
260
+
261
+ this._rafId = requestAnimationFrame(ts => this._render(ts));
262
+ }
263
+
264
+ _spawnBurst(x, y) {
265
+ const nearLayerIndex = 2;
266
+ for (let i = 0; i < 6; i++) {
267
+ const p = this._makeParticle(x, y, nearLayerIndex);
268
+ p.vx *= 2;
269
+ p.vy *= 2;
270
+ p.fadeIn = 1.0;
271
+ this.particles.push(p);
272
+ }
273
+ if (this.particles.length > this.options.count * 2) {
274
+ this.particles.splice(0, 6);
275
+ }
276
+ }
277
+
278
+ start() {
279
+ if (this._isRunning || !this.canvas) return;
280
+ this._isRunning = true;
281
+ this._lastTs = null;
282
+ this._rafId = requestAnimationFrame(ts => this._render(ts));
283
+ }
284
+
285
+ stop() {
286
+ this._isRunning = false;
287
+ this._lastTs = null;
288
+ if (this._rafId) { cancelAnimationFrame(this._rafId); this._rafId = null; }
289
+ }
290
+
291
+ destroy() {
292
+ this.stop();
293
+ if (this.canvas) {
294
+ this.canvas.removeEventListener('mousemove', this._onMouseMove);
295
+ this.canvas.removeEventListener('click', this._onClick);
296
+ this.canvas.removeEventListener('mouseleave', this._onMouseLeave);
297
+ }
298
+ if (this._resizeObserver) { this._resizeObserver.disconnect(); this._resizeObserver = null; }
299
+ if (this._intersectionObserver) { this._intersectionObserver.disconnect(); this._intersectionObserver = null; }
300
+ this.particles = null;
301
+ this.canvas = null;
302
+ this.ctx = null;
303
+ }
304
+ }
305
+
306
+ // Export for manual use / build pipelines
307
+ if (typeof module !== 'undefined' && module.exports) {
308
+ module.exports = { CorruptedParticles };
309
+ }
@@ -0,0 +1,329 @@
1
+ // src/lib/corrupted-vortex.js
2
+ // CorruptedVortex — WebGL1 raymarched spiral-vortex component
3
+ // Part of @whykusanagi/corrupted-theme
4
+
5
+ const VERT_SRC = `
6
+ attribute vec2 aPosition;
7
+ void main() {
8
+ gl_Position = vec4(aPosition, 0.0, 1.0);
9
+ }
10
+ `;
11
+
12
+ const FRAG_SRC = `
13
+ precision highp float;
14
+
15
+ uniform vec2 uResolution;
16
+ uniform float uTime;
17
+ uniform float uIntensity;
18
+ uniform float uRotationRate;
19
+ uniform float uHue;
20
+
21
+ vec3 hsv(float h, float s, float v) {
22
+ vec4 K = vec4(1.0, 2.0/3.0, 1.0/3.0, 3.0);
23
+ vec3 p = abs(fract(vec3(h) + K.xyz) * 6.0 - K.www);
24
+ return v * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), s);
25
+ }
26
+
27
+ mat2 rotate2D(float a) {
28
+ float c = cos(a), s = sin(a);
29
+ return mat2(c, -s, s, c);
30
+ }
31
+
32
+ // Continuous 3-D accretion disk viewed at ~60° inclination, major axis 45° CW.
33
+ // Perpendicular-distance-to-radial-ray trick gives analytic nearest orbit:
34
+ // phi = atan((sx+sy)/B, sx-sy) [orbital angle, r-invariant]
35
+ // K = 0.7071*(cos φ+B·sin φ, -cos φ+B·sin φ) [radial direction on ellipse]
36
+ // r0 = dot(uv, K) / dot(K,K) [nearest orbital radius]
37
+ // dr = length(uv - r0·K) [perpendicular disk offset]
38
+ vec3 diskSample(vec2 uv) {
39
+ float B = 0.15; // near edge-on (~9° from edge) → Saturn-like flat stripe
40
+ float phi = atan((uv.x + uv.y) / B, uv.x - uv.y);
41
+ vec2 K = vec2(0.7071 * (cos(phi) + B * sin(phi)),
42
+ 0.7071 * (-cos(phi) + B * sin(phi)));
43
+ float r0 = dot(uv, K) / dot(K, K);
44
+ float dr = length(uv - r0 * K);
45
+
46
+ // Radial extent: inner edge near photon sphere, outer at 0.65
47
+ float radial = smoothstep(0.185, 0.235, r0) * smoothstep(0.65, 0.50, r0);
48
+
49
+ // Disk height (thin, crisp; spaghettifies near horizon)
50
+ float fall = smoothstep(0.30, 0.19, length(uv));
51
+ float sigma = (0.012 + r0 * 0.022) * max(0.07, 1.0 - fall * 0.93);
52
+ float height = exp(-pow(dr / sigma, 2.0));
53
+
54
+ // Gas-flow striations along orbital direction
55
+ float fiber = 0.62 + 0.38 * abs(sin(phi * 18.0 + r0 * 22.0 - uTime * 0.25));
56
+
57
+ // Doppler: approaching half (sin φ > 0) → brighter
58
+ float dop = 0.30 + 1.40 * max(0.0, sin(phi));
59
+
60
+ // Color: inner cream-white → gold → dark orange outer
61
+ float t = clamp((r0 - 0.18) / (0.62 - 0.18), 0.0, 1.0);
62
+ vec3 col;
63
+ if (t < 0.25) {
64
+ col = mix(vec3(1.00, 0.97, 0.85), vec3(1.00, 0.72, 0.18), t / 0.25);
65
+ } else {
66
+ col = mix(vec3(1.00, 0.72, 0.18), vec3(0.38, 0.12, 0.01), (t - 0.25) / 0.75);
67
+ }
68
+
69
+ // Tidal brightening near inner edge (kept subtle to avoid drowning the corona)
70
+ float innerBoost = 1.0 + 0.5 * exp(-pow((r0 - 0.21) / 0.10, 2.0));
71
+
72
+ return col * (height * radial * fiber * dop * innerBoost);
73
+ }
74
+
75
+ void main() {
76
+ vec4 o = vec4(0.0);
77
+ float e = 0.0, R = 0.0;
78
+ vec3 q = vec3(0.0), p = vec3(0.0);
79
+ vec3 d = vec3((gl_FragCoord.xy - 0.5 * uResolution) / uResolution.y, 0.7);
80
+ q.z -= 1.0;
81
+
82
+ for (float i = 0.0; i < 33.0; i += 1.0) {
83
+ // Corrupted-theme quasar palette:
84
+ // 0–25% early iters (dim) → magenta outer glow [0.83–0.88]
85
+ // 25–70% mid iters → purple body [0.65–0.74]
86
+ // 70–85% bright inner iters → magenta burst [0.82–0.88]
87
+ // 85–100% late iters (high e)→ gold/yellow sparks [0.14–0.19]
88
+ float t = fract(i / 33.0);
89
+ float base;
90
+ if (t < 0.25) {
91
+ base = mix(0.83, 0.88, t / 0.25);
92
+ } else if (t < 0.70) {
93
+ base = mix(0.65, 0.74, (t - 0.25) / 0.45);
94
+ } else if (t < 0.85) {
95
+ base = mix(0.82, 0.88, (t - 0.70) / 0.15);
96
+ } else {
97
+ base = mix(0.14, 0.19, (t - 0.85) / 0.15);
98
+ }
99
+ float h = (uHue >= 0.0) ? uHue : base + p.y * 0.04;
100
+ o.rgb += hsv(h, clamp(e * 0.4, 0.0, 1.0), e / 30.0 * uIntensity);
101
+
102
+ p = q += d * max(e, 0.01) * R * 0.14;
103
+ p.xy *= rotate2D(0.8 * uRotationRate);
104
+
105
+ R = length(p);
106
+ float newPy = -p.z / R - 0.8;
107
+ e = newPy;
108
+ p = vec3(log2(R) + uTime, newPy, atan(p.x * 0.08, p.y) - uTime * 0.2);
109
+
110
+ float s = 1.0;
111
+ for (int si = 0; si < 10; si++) {
112
+ e += abs(dot(sin(p.yzx * s), cos(p.yyz * s))) / s;
113
+ s += s;
114
+ }
115
+ }
116
+
117
+ // Reinhard + contrast curve: compress sum then push dim areas toward black.
118
+ // pow(1.8) is softer than 2.2 — keeps the magenta/purple corona visible
119
+ // while still collapsing near-zero cloud artifacts to black.
120
+ o.rgb = o.rgb / (1.0 + o.rgb);
121
+ o.rgb = pow(o.rgb, vec3(1.8));
122
+
123
+ // Near/far depth split: project onto the 45° CW major axis direction (1,-1)/√2.
124
+ // majorProj > 0 → lower-right arm → near (passes in front of BH)
125
+ // majorProj < 0 → upper-left arm → far (passes behind BH)
126
+ float dist = length(d.xy);
127
+ float majorProj = dot(d.xy, vec2(0.7071, -0.7071));
128
+ float nearMask = smoothstep(-0.04, 0.04, majorProj);
129
+ vec3 diskVal = diskSample(d.xy) * 2.0 * uIntensity;
130
+
131
+ // Far arm: add before shadow; extra depth-fade darkens it near the BH centre
132
+ float farDepth = smoothstep(0.12, 0.26, dist);
133
+ o.rgb += diskVal * (1.0 - nearMask) * farDepth;
134
+
135
+ // Black-hole event horizon: shadows the vortex cloud (and the far arm)
136
+ float shadow = smoothstep(0.12, 0.18, dist);
137
+ float ring = exp(-pow((dist - 0.18) * 30.0, 2.0)) * 0.9;
138
+ o.rgb = o.rgb * shadow + vec3(ring, ring * 0.80, ring * 0.45);
139
+
140
+ // Near arm: add after shadow → visibly crosses in front of the event horizon
141
+ o.rgb += diskVal * nearMask;
142
+
143
+ // Lensed arc: thin bright stripe above the shadow boundary
144
+ float lensThe = atan(d.y, d.x);
145
+ float bend = exp(-(dist - 0.19) * 9.0);
146
+ float thetaS = lensThe + 3.14159 * bend;
147
+ vec2 uvLens = vec2(cos(thetaS), sin(thetaS)) * dist;
148
+ float lensFade = exp(-pow((dist - 0.22) * 16.0, 2.0))
149
+ * smoothstep(0.03, 0.08, d.y);
150
+ o.rgb += diskSample(uvLens) * lensFade * 0.5 * uIntensity;
151
+
152
+ // Magenta corona: tight photon-ring — width 50 gives ~0.033 FWHM, <4% bleed into shadow
153
+ float coronaAmt = exp(-pow((dist - 0.21) * 50.0, 2.0)) * 0.7;
154
+ o.rgb += vec3(coronaAmt, 0.0, coronaAmt) * uIntensity;
155
+
156
+ o.a = 1.0;
157
+ gl_FragColor = clamp(o, 0.0, 1.0);
158
+ }
159
+ `;
160
+
161
+ class CorruptedVortex {
162
+ constructor(canvas, options = {}) {
163
+ this.canvas = canvas;
164
+ this.options = {
165
+ speed: options.speed ?? 1.0,
166
+ intensity: options.intensity ?? 1.0,
167
+ rotationRate: options.rotationRate ?? 1.0,
168
+ hue: options.hue ?? null,
169
+ };
170
+
171
+ this.gl = null;
172
+ this.program = null;
173
+ this.uniforms = {};
174
+ this.buffer = null;
175
+ this._rafId = null;
176
+ this._isRunning = false;
177
+ this._elapsed = 0;
178
+ this._lastTs = null;
179
+ this._resizeObserver = null;
180
+ this._intersectionObserver = null;
181
+
182
+ this.init();
183
+ }
184
+
185
+ _compileShader(type, src) {
186
+ const gl = this.gl;
187
+ const shader = gl.createShader(type);
188
+ gl.shaderSource(shader, src);
189
+ gl.compileShader(shader);
190
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
191
+ console.warn('CorruptedVortex: shader compile failed\n', gl.getShaderInfoLog(shader));
192
+ gl.deleteShader(shader);
193
+ return null;
194
+ }
195
+ return shader;
196
+ }
197
+
198
+ init() {
199
+ const gl = this.canvas.getContext('webgl', { alpha: false });
200
+ if (!gl) {
201
+ console.warn('CorruptedVortex: WebGL not supported in this browser.');
202
+ return;
203
+ }
204
+ this.gl = gl;
205
+
206
+ const vs = this._compileShader(gl.VERTEX_SHADER, VERT_SRC);
207
+ const fs = this._compileShader(gl.FRAGMENT_SHADER, FRAG_SRC);
208
+ if (!vs || !fs) { this.destroy(); return; }
209
+
210
+ this.program = gl.createProgram();
211
+ gl.attachShader(this.program, vs);
212
+ gl.attachShader(this.program, fs);
213
+ gl.linkProgram(this.program);
214
+ gl.deleteShader(vs);
215
+ gl.deleteShader(fs);
216
+
217
+ if (!gl.getProgramParameter(this.program, gl.LINK_STATUS)) {
218
+ console.warn('CorruptedVortex: program link failed\n', gl.getProgramInfoLog(this.program));
219
+ this.destroy();
220
+ return;
221
+ }
222
+
223
+ gl.useProgram(this.program);
224
+
225
+ this.uniforms = {
226
+ resolution: gl.getUniformLocation(this.program, 'uResolution'),
227
+ time: gl.getUniformLocation(this.program, 'uTime'),
228
+ intensity: gl.getUniformLocation(this.program, 'uIntensity'),
229
+ rotationRate: gl.getUniformLocation(this.program, 'uRotationRate'),
230
+ hue: gl.getUniformLocation(this.program, 'uHue'),
231
+ };
232
+
233
+ // Fullscreen triangle — one triangle covers the full NDC square
234
+ this.buffer = gl.createBuffer();
235
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.buffer);
236
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
237
+
238
+ const posLoc = gl.getAttribLocation(this.program, 'aPosition');
239
+ gl.enableVertexAttribArray(posLoc);
240
+ gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0);
241
+
242
+ this._resizeObserver = new ResizeObserver(() => this._resize());
243
+ this._resizeObserver.observe(this.canvas);
244
+
245
+ this._intersectionObserver = new IntersectionObserver(entries => {
246
+ if (entries[0].isIntersecting) {
247
+ this.start();
248
+ } else {
249
+ this.stop();
250
+ }
251
+ }, { threshold: 0.1 });
252
+ this._intersectionObserver.observe(this.canvas);
253
+
254
+ this._resize();
255
+ this.start();
256
+ }
257
+
258
+ _resize() {
259
+ const gl = this.gl;
260
+ if (!gl || !this.canvas) return;
261
+ const dpr = 0.5; // half-res: GPU renders fewer pixels, CSS scales up
262
+ const rect = this.canvas.getBoundingClientRect();
263
+ if (rect.width === 0 || rect.height === 0) return;
264
+ this.canvas.width = Math.round(rect.width * dpr);
265
+ this.canvas.height = Math.round(rect.height * dpr);
266
+ gl.viewport(0, 0, this.canvas.width, this.canvas.height);
267
+ }
268
+
269
+ _render(ts) {
270
+ if (!this._isRunning) return;
271
+
272
+ // Throttle to ~30fps to keep GPU load manageable
273
+ if (this._lastTs !== null && ts - this._lastTs < 33) {
274
+ this._rafId = requestAnimationFrame(ts => this._render(ts));
275
+ return;
276
+ }
277
+
278
+ if (this._lastTs !== null) {
279
+ this._elapsed += (ts - this._lastTs) / 1000.0;
280
+ }
281
+ this._lastTs = ts;
282
+
283
+ const gl = this.gl;
284
+ gl.useProgram(this.program);
285
+ gl.uniform2f(this.uniforms.resolution, this.canvas.width, this.canvas.height);
286
+ gl.uniform1f(this.uniforms.time, this._elapsed * this.options.speed);
287
+ gl.uniform1f(this.uniforms.intensity, this.options.intensity);
288
+ gl.uniform1f(this.uniforms.rotationRate, this.options.rotationRate);
289
+ gl.uniform1f(this.uniforms.hue, this.options.hue !== null ? this.options.hue : -1.0);
290
+
291
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
292
+
293
+ this._rafId = requestAnimationFrame(ts => this._render(ts));
294
+ }
295
+
296
+ start() {
297
+ if (this._isRunning || !this.gl) return;
298
+ this._isRunning = true;
299
+ this._lastTs = null;
300
+ this._rafId = requestAnimationFrame(ts => this._render(ts));
301
+ }
302
+
303
+ stop() {
304
+ this._isRunning = false;
305
+ this._lastTs = null;
306
+ if (this._rafId) {
307
+ cancelAnimationFrame(this._rafId);
308
+ this._rafId = null;
309
+ }
310
+ }
311
+
312
+ destroy() {
313
+ this.stop();
314
+ const gl = this.gl;
315
+ if (gl) {
316
+ if (this.program) gl.deleteProgram(this.program);
317
+ if (this.buffer) gl.deleteBuffer(this.buffer);
318
+ }
319
+ if (this._resizeObserver) { this._resizeObserver.disconnect(); this._resizeObserver = null; }
320
+ if (this._intersectionObserver) { this._intersectionObserver.disconnect(); this._intersectionObserver = null; }
321
+ this.gl = null;
322
+ this.canvas = null;
323
+ }
324
+ }
325
+
326
+ // Export for manual use / build pipelines
327
+ if (typeof module !== 'undefined' && module.exports) {
328
+ module.exports = { CorruptedVortex };
329
+ }