File size: 24,902 Bytes
521f25e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
/* ================================================================
   WebGL Orb Renderer β€” ported from React/OGL to vanilla JS
   ================================================================
   This file renders the glowing, animated orb that serves as the
   visual centerpiece / background element of the N.Y.R.A AI assistant
   UI. The orb is drawn entirely on the GPU using WebGL and GLSL
   shaders β€” no images or SVGs are involved.
   HOW IT WORKS (high-level):
   1. A full-screen <canvas> is created inside a container element.
   2. A WebGL context is obtained on that canvas.
   3. A vertex shader positions a single full-screen triangle, and a
      fragment shader runs *per pixel* to compute the orb's color
      using 3D simplex noise, hue-shifting math, and procedural
      lighting.
   4. An animation loop (requestAnimationFrame) feeds the shader a
      steadily increasing time value each frame, which makes the orb
      swirl, pulse, and react to state changes (e.g. "speaking").
   KEY CONCEPTS FOR LEARNERS:
   - **Vertex shader**: runs once per vertex. Here it just maps our
     triangle so it covers the whole screen.
   - **Fragment shader**: runs once per *pixel*. This is where all the
     visual magic happens β€” noise, lighting, color mixing.
   - **Uniforms**: values we send from JavaScript into the shader each
     frame (time, resolution, color settings, etc.).
   - **Simplex noise** (snoise3): a smooth random function that gives
     the orb its organic, cloud-like movement.
   The class exposes a simple API:
     new OrbRenderer(containerEl, options)   – start rendering
     .setActive(true/false)                  – pulse the orb (e.g. TTS speaking)
     .destroy()                              – tear everything down
   ================================================================ */

class OrbRenderer {
   /**
    * Creates a new OrbRenderer and immediately begins animating.
    *
    * @param {HTMLElement} container  – the DOM element the canvas will fill.
    * @param {Object}      opts      – optional tweaks:
    *   @param {number}   opts.hue             – base hue rotation in degrees (default 0).
    *   @param {number}   opts.hoverIntensity  – strength of the wavy hover/active distortion (default 0.2).
    *   @param {number[]} opts.backgroundColor – RGB triplet [r,g,b] each 0-1 (default dark navy).
    */
   constructor(container, opts = {}) {
      this.container = container;
      this.hue = opts.hue ?? 0;
      this.hoverIntensity = opts.hoverIntensity ?? 0.2;
      this.bgColor = opts.backgroundColor ?? [0.02, 0.02, 0.06];

      // Animation state β€” these are smoothly interpolated each frame
      // to avoid jarring jumps when setActive() is called.
      this.targetHover = 0;   // where we want hover to be (0 or 1)
      this.currentHover = 0;  // smoothly chases targetHover
      this.currentRot = 0;    // cumulative rotation (radians) applied while active
      this.lastTs = 0;        // timestamp of previous frame for delta-time calculation

      // Create and insert the drawing surface
      this.canvas = document.createElement('canvas');
      this.canvas.style.width = '100%';
      this.canvas.style.height = '100%';
      this.container.appendChild(this.canvas);

      // Acquire a WebGL 1 context.
      // alpha:true lets the orb float over whatever is behind the canvas.
      // premultipliedAlpha:false keeps our alpha blending straightforward.
      this.gl = this.canvas.getContext('webgl', { alpha: true, premultipliedAlpha: false, antialias: false });
      if (!this.gl) { console.warn('WebGL not available'); return; }

      // Compile shaders, create buffers, look up uniform locations
      this._build();
      // Set the canvas resolution to match its CSS size Γ— devicePixelRatio
      this._resize();
      // Re-adjust whenever the browser window changes size
      this._onResize = this._resize.bind(this);
      window.addEventListener('resize', this._onResize);
      // Kick off the animation loop
      this._raf = requestAnimationFrame(this._loop.bind(this));
   }

   /* =============================================================
      VERTEX SHADER (GLSL)
      =============================================================
      The vertex shader runs once for each vertex we send to the GPU
      (in our case just 3 β€” a single triangle that covers the whole
      screen).
      Inputs (attributes):
        position – the XY clip-space coordinate of this vertex.
        uv       – a texture coordinate we pass through to the
                    fragment shader so it knows where on the
                    "screen rectangle" each pixel is.
      Output:
        gl_Position – the final clip-space position (vec4).
        vUv         – passed to the fragment shader via a "varying".
      ============================================================= */
   static VERT = `
    precision highp float;
    attribute vec2 position;
    attribute vec2 uv;
    varying vec2 vUv;
    void main(){ vUv=uv; gl_Position=vec4(position,0.0,1.0); }`;

   /* =============================================================
      FRAGMENT SHADER (GLSL)
      =============================================================
      The fragment shader runs once for every pixel on screen. It
      receives the interpolated UV coordinate from the vertex shader
      and computes the final RGBA color for that pixel.
      UNIFORMS (values supplied from JavaScript every frame):
        iTime           – elapsed time in seconds; drives all animation.
        iResolution     – vec3(canvasWidth, canvasHeight, aspectRatio).
        hue             – degree offset applied to the base palette via
                          YIQ color-space rotation (lets you recolor the
                          whole orb without changing any other code).
        hover           – 0.0 β†’ 1.0 interpolation: how "active" the orb
                          is right now. Drives the wavy UV distortion.
        rot             – current rotation angle (radians). Accumulated
                          on the JS side while the orb is active.
        hoverIntensity  – multiplier for the wavy UV distortion amplitude.
        backgroundColor – the scene's background color (RGB 0-1). The
                          shader blends toward this so the orb sits
                          naturally on any background.
      The shader contains several helper functions (explained inline
      below) and a main draw() routine that assembles the orb.
      ============================================================= */
   static FRAG = `
    precision highp float;
    uniform float iTime;
    uniform vec3  iResolution;
    uniform float hue;
    uniform float hover;
    uniform float rot;
    uniform float hoverIntensity;
    uniform vec3  backgroundColor;
    varying vec2  vUv;
    /* ----- Color-space conversion: RGB ↔ YIQ ----- */
    // YIQ is the color model used by NTSC television. Converting to
    // YIQ lets us rotate the hue of any color by simply rotating the
    // I and Q components, then converting back to RGB.
    vec3 rgb2yiq(vec3 c){float y=dot(c,vec3(.299,.587,.114));float i=dot(c,vec3(.596,-.274,-.322));float q=dot(c,vec3(.211,-.523,.312));return vec3(y,i,q);}
    vec3 yiq2rgb(vec3 c){return vec3(c.x+.956*c.y+.621*c.z,c.x-.272*c.y-.647*c.z,c.x-1.106*c.y+1.703*c.z);}
    // adjustHue: rotate a color's hue by 'hueDeg' degrees.
    // 1. Convert RGB β†’ YIQ.
    // 2. Rotate the (I, Q) pair by the hue angle (2D rotation matrix).
    // 3. Convert YIQ β†’ RGB.
    vec3 adjustHue(vec3 color,float hueDeg){float h=hueDeg*3.14159265/180.0;vec3 yiq=rgb2yiq(color);float cosA=cos(h);float sinA=sin(h);float i2=yiq.y*cosA-yiq.z*sinA;float q2=yiq.y*sinA+yiq.z*cosA;yiq.y=i2;yiq.z=q2;return yiq2rgb(yiq);}
    /* ----- 3D Simplex Noise (snoise3) ----- */
    // Simplex noise is a smooth, natural-looking pseudo-random function
    // invented by Ken Perlin. Given a 3D coordinate it returns a value
    // roughly in [-1, 1]. By feeding (uv, time) we get animated,
    // organic-looking variation that drives the orb's wobbly edge.
    //
    // hash33: a cheap hash that maps a vec3 to a pseudo-random vec3 in
    //         [-1, 1]. Used internally by the noise to create random
    //         gradient vectors at each lattice point.
    vec3 hash33(vec3 p3){p3=fract(p3*vec3(.1031,.11369,.13787));p3+=dot(p3,p3.yxz+19.19);return -1.0+2.0*fract(vec3(p3.x+p3.y,p3.x+p3.z,p3.y+p3.z)*p3.zyx);}
    // snoise3: the actual 3D simplex noise implementation.
    // K1 and K2 are the skew/unskew constants for a 3D simplex grid.
    // The function:
    //   1. Skews the input into simplex (tetrahedral) space.
    //   2. Determines which simplex cell the point falls in.
    //   3. Computes distance vectors to each of the cell's 4 corners.
    //   4. For each corner, evaluates a radial falloff kernel multiplied
    //      by the dot product of a pseudo-random gradient and the
    //      distance vector.
    //   5. Sums the contributions and scales to roughly [-1, 1].
    float snoise3(vec3 p){const float K1=.333333333;const float K2=.166666667;vec3 i=floor(p+(p.x+p.y+p.z)*K1);vec3 d0=p-(i-(i.x+i.y+i.z)*K2);vec3 e=step(vec3(0.0),d0-d0.yzx);vec3 i1=e*(1.0-e.zxy);vec3 i2=1.0-e.zxy*(1.0-e);vec3 d1=d0-(i1-K2);vec3 d2=d0-(i2-K1);vec3 d3=d0-0.5;vec4 h=max(0.6-vec4(dot(d0,d0),dot(d1,d1),dot(d2,d2),dot(d3,d3)),0.0);vec4 n=h*h*h*h*vec4(dot(d0,hash33(i)),dot(d1,hash33(i+i1)),dot(d2,hash33(i+i2)),dot(d3,hash33(i+1.0)));return dot(vec4(31.316),n);}
    // extractAlpha: the orb is rendered on a transparent background.
    // This helper takes an RGB color and derives an alpha from the
    // brightest channel. That way fully-black areas become transparent
    // and bright areas become opaque β€” giving us a soft-edged glow
    // without needing a separate alpha mask.
    vec4 extractAlpha(vec3 c){float a=max(max(c.r,c.g),c.b);return vec4(c/(a+1e-5),a);}
    /* ----- Palette & geometry constants ----- */
    // Three base colors that define the orb's purple-cyan palette.
    // They get hue-shifted at runtime by the 'hue' uniform.
    const vec3 baseColor1=vec3(.611765,.262745,.996078);   // vivid purple
    const vec3 baseColor2=vec3(.298039,.760784,.913725);   // cyan / teal
    const vec3 baseColor3=vec3(.062745,.078431,.600000);   // deep indigo
    const float innerRadius=0.6;   // normalized radius of the orb's inner core
    const float noiseScale=0.65;   // how zoomed-in the noise pattern is
    /* ----- Procedural light falloff helpers ----- */
    // light1: inverse-distance falloff  β†’  I / (1 + dΒ·a)
    // light2: inverse-square falloff    β†’  I / (1 + dΒ²Β·a)
    // 'i' = intensity, 'a' = attenuation, 'd' = distance.
    // These give the orb its glowing highlight spots.
    float light1(float i,float a,float d){return i/(1.0+d*a);}
    float light2(float i,float a,float d){return i/(1.0+d*d*a);}
    /* ----- draw(): the core orb rendering routine ----- */
    // Given a UV coordinate (centered, normalized so the short axis
    // spans -1 to 1), this function returns an RGBA color for that
    // pixel.
    //
    // Step-by-step:
    //   1. Hue-shift the three base colors.
    //   2. Convert the UV to polar-ish helpers (angle and length).
    //   3. Sample 3D simplex noise at (uv, time) to create organic,
    //      time-varying distortion.
    //   4. Compute a wobbly radius (r0) from the noise β€” this is what
    //      makes the edge of the orb undulate.
    //   5. Calculate multiple light/glow terms:
    //        v0 – main glow field (radial, noise-modulated)
    //        v1 – an orbiting highlight point
    //        v2, v3 – radial fade masks that confine color to the orb
    //   6. Blend the base colors using the angular position (cl) so
    //      the orb shifts between purple and cyan as you go around it.
    //   7. Compose a "dark" version and a "light" version of the orb,
    //      then blend between them based on background luminance so
    //      the orb looks good on both dark and light UIs.
    //   8. Pass the result through extractAlpha to get proper
    //      transparency for compositing.
    vec4 draw(vec2 uv){
        vec3 c1=adjustHue(baseColor1,hue);vec3 c2=adjustHue(baseColor2,hue);vec3 c3=adjustHue(baseColor3,hue);
        float ang=atan(uv.y,uv.x);float len=length(uv);float invLen=len>0.0?1.0/len:0.0;
        float bgLum=dot(backgroundColor,vec3(.299,.587,.114));  // perceptual luminance of the bg
        float n0=snoise3(vec3(uv*noiseScale,iTime*0.5))*0.5+0.5;  // noise remapped to [0,1]
        float r0=mix(mix(innerRadius,1.0,0.4),mix(innerRadius,1.0,0.6),n0);  // wobbly radius
        float d0=distance(uv,(r0*invLen)*uv);  // distance from pixel to the wobbly edge
        float v0=light1(1.0,10.0,d0);          // main radial glow
        v0*=smoothstep(r0*1.05,r0,len);        // hard-ish cutoff just outside the radius
        float innerFade=smoothstep(r0*0.8,r0*0.95,len);  // fade near the center
        v0*=mix(innerFade,1.0,bgLum*0.7);
        float cl=cos(ang+iTime*2.0)*0.5+0.5;  // angular color blend (rotates over time)
        float a2=iTime*-1.0;vec2 pos=vec2(cos(a2),sin(a2))*r0;float d=distance(uv,pos);  // orbiting light
        float v1=light2(1.5,5.0,d);v1*=light1(1.0,50.0,d0);  // highlight with quick falloff
        float v2=smoothstep(1.0,mix(innerRadius,1.0,n0*0.5),len);  // outer fade mask
        float v3=smoothstep(innerRadius,mix(innerRadius,1.0,0.5),len);  // inner→outer ramp
        vec3 colBase=mix(c1,c2,cl);  // angular purple↔cyan blend
        float fadeAmt=mix(1.0,0.1,bgLum);
        // "dark" composite β€” used on dark backgrounds
        vec3 darkCol=mix(c3,colBase,v0);darkCol=(darkCol+v1)*v2*v3;darkCol=clamp(darkCol,0.0,1.0);
        // "light" composite β€” blends toward the background color
        vec3 lightCol=(colBase+v1)*mix(1.0,v2*v3,fadeAmt);lightCol=mix(backgroundColor,lightCol,v0);lightCol=clamp(lightCol,0.0,1.0);
        // final mix: lean toward lightCol when the background is bright
        vec3 fc=mix(darkCol,lightCol,bgLum);
        return extractAlpha(fc);
    }
    /* ----- mainImage(): entry point called by main() ----- */
    // Transforms the raw pixel coordinate into a centered, normalized
    // UV, applies rotation and the wavy hover distortion, then calls
    // draw().
    vec4 mainImage(vec2 fragCoord){
        vec2 center=iResolution.xy*0.5;float sz=min(iResolution.x,iResolution.y);
        vec2 uv=(fragCoord-center)/sz*2.0;  // center and normalize UV to [-1,1] on short axis
        // Apply 2D rotation (accumulated while the orb is "active")
        float s2=sin(rot);float c2=cos(rot);uv=vec2(c2*uv.x-s2*uv.y,s2*uv.x+c2*uv.y);
        // Wavy UV distortion driven by 'hover' (0β†’1 when active)
        uv.x+=hover*hoverIntensity*0.1*sin(uv.y*10.0+iTime);
        uv.y+=hover*hoverIntensity*0.1*sin(uv.x*10.0+iTime);
        return draw(uv);
    }
    /* ----- main(): GLSL entry point ----- */
    // Converts the varying vUv (0-1 range) back to pixel coordinates,
    // calls mainImage(), and writes the final pre-multiplied alpha
    // color to gl_FragColor.
    void main(){
        vec2 fc=vUv*iResolution.xy;vec4 col=mainImage(fc);
        gl_FragColor=vec4(col.rgb*col.a,col.a);
    }`;

   /* =============================================================
      _compile(type, src)
      =============================================================
      Compiles a single GLSL shader (vertex or fragment).
      WebGL shaders are written in GLSL (a C-like language) and must
      be compiled at runtime by the GPU driver. If compilation fails
      (e.g. syntax error in the GLSL), we log the error and return
      null so _build() can bail out gracefully.
      ============================================================= */
   _compile(type, src) {
      const gl = this.gl;
      const s = gl.createShader(type);
      gl.shaderSource(s, src);
      gl.compileShader(s);
      if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
         console.error('Shader compile error:', gl.getShaderInfoLog(s));
         gl.deleteShader(s);
         return null;
      }
      return s;
   }

   /* =============================================================
      _build()
      =============================================================
      Sets up everything the GPU needs to render the orb:
      1. COMPILE both shaders (vertex + fragment).
      2. LINK them into a "program" β€” the GPU pipeline that will run
         every frame.
      3. CREATE VERTEX BUFFERS. We use a single oversized triangle
         (the "full-screen triangle" trick) instead of a quad. Its 3
         vertices at (-1,-1), (3,-1), (-1,3) in clip space cover the
         entire [-1,1]Β² viewport and beyond, so every pixel gets a
         fragment shader invocation. This is faster than two triangles
         because the GPU only processes one primitive.
      4. LOOK UP UNIFORM LOCATIONS. gl.getUniformLocation returns a
         handle we use each frame to send updated values to the shader.
      5. ENABLE ALPHA BLENDING so the orb composites transparently
         over whatever is behind the canvas.
      ============================================================= */
   _build() {
      const gl = this.gl;
      const vs = this._compile(gl.VERTEX_SHADER, OrbRenderer.VERT);
      const fs = this._compile(gl.FRAGMENT_SHADER, OrbRenderer.FRAG);
      if (!vs || !fs) return;

      this.pgm = gl.createProgram();
      gl.attachShader(this.pgm, vs);
      gl.attachShader(this.pgm, fs);
      gl.linkProgram(this.pgm);
      if (!gl.getProgramParameter(this.pgm, gl.LINK_STATUS)) {
         console.error('Program link error:', gl.getProgramInfoLog(this.pgm));
         return;
      }
      gl.useProgram(this.pgm);

      // Get attribute locations from the compiled program
      const posLoc = gl.getAttribLocation(this.pgm, 'position');
      const uvLoc = gl.getAttribLocation(this.pgm, 'uv');

      // Position buffer: a single full-screen triangle in clip space.
      // (-1,-1) is bottom-left, (3,-1) extends far right, (-1,3) extends far up.
      // The GPU clips to the viewport, so the visible area is exactly [-1,1]Β².
      const posBuf = gl.createBuffer();
      gl.bindBuffer(gl.ARRAY_BUFFER, posBuf);
      gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
      gl.enableVertexAttribArray(posLoc);
      gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0);

      // UV buffer: matching texture coordinates for the triangle.
      // (0,0) maps to the bottom-left corner; values > 1 are clipped away.
      const uvBuf = gl.createBuffer();
      gl.bindBuffer(gl.ARRAY_BUFFER, uvBuf);
      gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 0, 2, 0, 0, 2]), gl.STATIC_DRAW);
      gl.enableVertexAttribArray(uvLoc);
      gl.vertexAttribPointer(uvLoc, 2, gl.FLOAT, false, 0, 0);

      // Cache uniform locations so we can efficiently set them each frame
      this.u = {};
      ['iTime', 'iResolution', 'hue', 'hover', 'rot', 'hoverIntensity', 'backgroundColor'].forEach(name => {
         this.u[name] = gl.getUniformLocation(this.pgm, name);
      });

      // Enable standard alpha blending for transparent compositing
      gl.enable(gl.BLEND);
      gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
      gl.clearColor(0, 0, 0, 0);
   }

   /* =============================================================
      _resize()
      =============================================================
      Keeps the canvas resolution in sync with its on-screen size.
      CSS sizes the canvas element (100% Γ— 100%), but the actual
      pixel buffer must be set explicitly via canvas.width/height.
      We multiply by devicePixelRatio so the orb looks sharp on
      HiDPI / Retina displays. The gl.viewport call tells WebGL
      to use the full buffer.
      ============================================================= */
   _resize() {
      const dpr = window.devicePixelRatio || 1;
      const w = this.container.clientWidth;
      const h = this.container.clientHeight;
      this.canvas.width = w * dpr;
      this.canvas.height = h * dpr;
      if (this.gl) this.gl.viewport(0, 0, this.canvas.width, this.canvas.height);
   }

   /* =============================================================
      _loop(ts)
      =============================================================
      The animation frame callback β€” called ~60 times per second by
      the browser via requestAnimationFrame.
      Each frame it:
      1. Schedules the next frame immediately (so animation never
         stops, even if this frame is slow).
      2. Converts the browser's millisecond timestamp to seconds and
         computes the delta-time (dt) since the last frame.
      3. Smoothly interpolates currentHover toward targetHover using
         an exponential ease (lerp with dt-scaled factor). This gives
         a nice fade-in / fade-out when setActive() is toggled.
      4. Accumulates rotation while active (currentHover > 0.5).
      5. Clears the canvas (transparent), uploads all uniform values
         for this frame, and issues a single draw call (3 vertices =
         one triangle that covers the screen).
      ============================================================= */
   _loop(ts) {
      this._raf = requestAnimationFrame(this._loop.bind(this));
      if (!this.pgm) return;
      const gl = this.gl;
      const t = ts * 0.001;                                        // ms β†’ seconds
      const dt = this.lastTs ? t - this.lastTs : 0.016;           // delta time (fallback ~60fps)
      this.lastTs = t;

      // Smooth hover interpolation: exponential ease toward target
      this.currentHover += (this.targetHover - this.currentHover) * Math.min(dt * 4, 1);
      // Slowly rotate the orb while it's in the "active" state
      if (this.currentHover > 0.5) this.currentRot += dt * 0.3;

      gl.clear(gl.COLOR_BUFFER_BIT);
      gl.useProgram(this.pgm);
      gl.uniform1f(this.u.iTime, t);                              // elapsed seconds
      gl.uniform3f(this.u.iResolution, this.canvas.width, this.canvas.height, this.canvas.width / this.canvas.height);
      gl.uniform1f(this.u.hue, this.hue);                         // palette rotation (degrees)
      gl.uniform1f(this.u.hover, this.currentHover);              // 0β†’1 active interpolation
      gl.uniform1f(this.u.rot, this.currentRot);                  // accumulated rotation
      gl.uniform1f(this.u.hoverIntensity, this.hoverIntensity);   // wave distortion strength
      gl.uniform3f(this.u.backgroundColor, this.bgColor[0], this.bgColor[1], this.bgColor[2]);
      gl.drawArrays(gl.TRIANGLES, 0, 3);                          // draw the single full-screen triangle
   }

   /* =============================================================
      setActive(active)
      =============================================================
      Toggles the orb between its idle and active (e.g. "speaking")
      states.
      - When active=true, targetHover is set to 1.0. Over the next
        few frames, _loop() will smoothly ramp currentHover up to 1,
        which makes the shader apply the wavy UV distortion and the
        rotation starts accumulating. The CSS class 'active' can be
        used to style the container (e.g. scale or glow via CSS).
      - When active=false, the reverse happens β€” the distortion and
        rotation smoothly fade out.
      ============================================================= */
   setActive(active) {
      this.targetHover = active ? 1.0 : 0.0;
      const ctn = this.container;
      if (active) ctn.classList.add('active');
      else ctn.classList.remove('active');
   }

   /* =============================================================
      destroy()
      =============================================================
      Cleans up all resources so the renderer can be safely removed:
      1. Cancels the pending animation frame.
      2. Removes the window resize listener.
      3. Detaches the <canvas> element from the DOM.
      4. Asks the browser to release the WebGL context and its GPU
         memory via the WEBGL_lose_context extension.
      Always call this when the orb is no longer needed (e.g. when
      navigating away from the page or unmounting a component).
      ============================================================= */
   destroy() {
      cancelAnimationFrame(this._raf);
      window.removeEventListener('resize', this._onResize);
      if (this.canvas.parentNode) this.canvas.parentNode.removeChild(this.canvas);
      const ext = this.gl.getExtension('WEBGL_lose_context');
      if (ext) ext.loseContext();
   }
}