@autumnsgrove/gossamer 0.1.0 → 0.1.1

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.
package/src/patterns.ts CHANGED
@@ -211,6 +211,270 @@ export function seededNoise2D(x: number, y: number, seed: number = 0): number {
211
211
  return n - Math.floor(n);
212
212
  }
213
213
 
214
+ /**
215
+ * Clouds pattern - soft, billowy fbm with gentle movement
216
+ * The signature Gossamer effect!
217
+ *
218
+ * @param x - X coordinate
219
+ * @param y - Y coordinate
220
+ * @param time - Time value for animation
221
+ * @param config - Pattern configuration
222
+ * @returns Value between -1 and 1
223
+ */
224
+ export function cloudsPattern(
225
+ x: number,
226
+ y: number,
227
+ time: number,
228
+ config: PatternConfig = DEFAULT_PATTERN_CONFIG
229
+ ): number {
230
+ const { frequency, amplitude, speed } = config;
231
+
232
+ // Slow, drifting movement
233
+ const drift = time * speed * 0.02;
234
+ const nx = x * frequency * 0.5 + drift;
235
+ const ny = y * frequency * 0.5 + drift * 0.7;
236
+
237
+ // Layer multiple octaves with high persistence for soft, puffy look
238
+ const base = fbmNoise(nx, ny, 5, 0.6);
239
+
240
+ // Add subtle secondary drift layer
241
+ const detail = fbmNoise(nx * 2 + drift * 0.5, ny * 2 - drift * 0.3, 3, 0.5) * 0.3;
242
+
243
+ // Combine and bias toward lighter values (more sky, less dense cloud)
244
+ const combined = base + detail;
245
+ return Math.tanh(combined * 1.5) * amplitude;
246
+ }
247
+
248
+ /**
249
+ * Plasma pattern - classic demoscene effect
250
+ * Combines sine waves at different frequencies and phases
251
+ *
252
+ * @param x - X coordinate
253
+ * @param y - Y coordinate
254
+ * @param time - Time value for animation
255
+ * @param config - Pattern configuration
256
+ * @returns Value between -1 and 1
257
+ */
258
+ export function plasmaPattern(
259
+ x: number,
260
+ y: number,
261
+ time: number,
262
+ config: PatternConfig = DEFAULT_PATTERN_CONFIG
263
+ ): number {
264
+ const { frequency, amplitude, speed } = config;
265
+ const t = time * speed;
266
+
267
+ // Classic plasma: sum of sines at different scales and rotations
268
+ const v1 = Math.sin(x * frequency + t);
269
+ const v2 = Math.sin(y * frequency + t * 0.7);
270
+ const v3 = Math.sin((x + y) * frequency * 0.5 + t * 0.5);
271
+ const v4 = Math.sin(Math.sqrt(x * x + y * y) * frequency * 0.3 + t * 0.8);
272
+
273
+ // Add some swirl
274
+ const cx = x - 40;
275
+ const cy = y - 20;
276
+ const v5 = Math.sin(Math.atan2(cy, cx) * 3 + t * 0.4);
277
+
278
+ return ((v1 + v2 + v3 + v4 + v5) / 5) * amplitude;
279
+ }
280
+
281
+ /**
282
+ * Vortex pattern - swirling spiral around center
283
+ *
284
+ * @param x - X coordinate
285
+ * @param y - Y coordinate
286
+ * @param centerX - Vortex center X
287
+ * @param centerY - Vortex center Y
288
+ * @param time - Time value for animation
289
+ * @param config - Pattern configuration
290
+ * @returns Value between -1 and 1
291
+ */
292
+ export function vortexPattern(
293
+ x: number,
294
+ y: number,
295
+ centerX: number,
296
+ centerY: number,
297
+ time: number,
298
+ config: PatternConfig = DEFAULT_PATTERN_CONFIG
299
+ ): number {
300
+ const { frequency, amplitude, speed } = config;
301
+
302
+ const dx = x - centerX;
303
+ const dy = y - centerY;
304
+ const distance = Math.sqrt(dx * dx + dy * dy);
305
+ const angle = Math.atan2(dy, dx);
306
+
307
+ // Spiral: angle + distance creates the swirl
308
+ const spiral = angle + distance * frequency * 0.1 - time * speed;
309
+
310
+ // Add some turbulence based on distance
311
+ const turbulence = perlinNoise2D(distance * 0.1, time * speed * 0.5) * 0.3;
312
+
313
+ return (Math.sin(spiral * 3) + turbulence) * amplitude;
314
+ }
315
+
316
+ /**
317
+ * Matrix pattern - falling columns like digital rain
318
+ *
319
+ * @param x - X coordinate (column)
320
+ * @param y - Y coordinate (row)
321
+ * @param time - Time value for animation
322
+ * @param config - Pattern configuration
323
+ * @returns Value between -1 and 1
324
+ */
325
+ export function matrixPattern(
326
+ x: number,
327
+ y: number,
328
+ time: number,
329
+ config: PatternConfig = DEFAULT_PATTERN_CONFIG
330
+ ): number {
331
+ const { frequency, amplitude, speed } = config;
332
+
333
+ // Each column has its own speed and phase based on x position
334
+ const columnSeed = seededNoise2D(x, 0, 42);
335
+ const columnSpeed = 0.5 + columnSeed * 1.5;
336
+ const columnPhase = columnSeed * 100;
337
+
338
+ // Calculate falling position
339
+ const fallPosition = (y * frequency + time * speed * columnSpeed + columnPhase) % 20;
340
+
341
+ // Create "head" of the rain drop (brightest) with trailing fade
342
+ const headBrightness = fallPosition < 1 ? 1 : 0;
343
+ const trailLength = 8;
344
+ const trailBrightness =
345
+ fallPosition < trailLength ? Math.pow(1 - fallPosition / trailLength, 2) : 0;
346
+
347
+ // Add some randomness for flickering effect
348
+ const flicker = seededNoise2D(x, Math.floor(time * 10), y) * 0.2;
349
+
350
+ const value = Math.max(headBrightness, trailBrightness) + flicker;
351
+ return (value * 2 - 1) * amplitude;
352
+ }
353
+
354
+ /**
355
+ * Gradient pattern - smooth animated gradients
356
+ *
357
+ * @param x - X coordinate
358
+ * @param y - Y coordinate
359
+ * @param cols - Total columns (for normalization)
360
+ * @param rows - Total rows (for normalization)
361
+ * @param time - Time value for animation
362
+ * @param config - Pattern configuration
363
+ * @returns Value between -1 and 1
364
+ */
365
+ export function gradientPattern(
366
+ x: number,
367
+ y: number,
368
+ cols: number,
369
+ rows: number,
370
+ time: number,
371
+ config: PatternConfig = DEFAULT_PATTERN_CONFIG
372
+ ): number {
373
+ const { frequency, amplitude, speed } = config;
374
+
375
+ // Normalize coordinates to 0-1
376
+ const nx = x / cols;
377
+ const ny = y / rows;
378
+
379
+ // Animated angle for gradient direction
380
+ const angle = time * speed * 0.2;
381
+ const cos = Math.cos(angle);
382
+ const sin = Math.sin(angle);
383
+
384
+ // Rotate the gradient
385
+ const rotated = nx * cos + ny * sin;
386
+
387
+ // Add some wave distortion
388
+ const distortion = Math.sin(ny * Math.PI * 2 * frequency + time * speed) * 0.1;
389
+
390
+ const value = (rotated + distortion) * 2 - 1;
391
+ return Math.sin(value * Math.PI) * amplitude;
392
+ }
393
+
394
+ /**
395
+ * Diamond pattern - interference creating diamond shapes
396
+ *
397
+ * @param x - X coordinate
398
+ * @param y - Y coordinate
399
+ * @param time - Time value for animation
400
+ * @param config - Pattern configuration
401
+ * @returns Value between -1 and 1
402
+ */
403
+ export function diamondPattern(
404
+ x: number,
405
+ y: number,
406
+ time: number,
407
+ config: PatternConfig = DEFAULT_PATTERN_CONFIG
408
+ ): number {
409
+ const { frequency, amplitude, speed } = config;
410
+ const t = time * speed;
411
+
412
+ // Diamond pattern using absolute value (Manhattan distance creates diamonds)
413
+ const wave1 = Math.sin((Math.abs(x - 40) + Math.abs(y - 20)) * frequency + t);
414
+ const wave2 = Math.sin((Math.abs(x - 40) - Math.abs(y - 20)) * frequency * 0.7 - t * 0.8);
415
+
416
+ // Add some rotation over time
417
+ const angle = t * 0.1;
418
+ const rx = x * Math.cos(angle) - y * Math.sin(angle);
419
+ const ry = x * Math.sin(angle) + y * Math.cos(angle);
420
+ const wave3 = Math.sin((Math.abs(rx) + Math.abs(ry)) * frequency * 0.5 + t * 0.5);
421
+
422
+ return ((wave1 + wave2 + wave3) / 3) * amplitude;
423
+ }
424
+
425
+ /**
426
+ * Fractal pattern - animated Mandelbrot/Julia set
427
+ *
428
+ * @param x - X coordinate
429
+ * @param y - Y coordinate
430
+ * @param cols - Total columns (for centering)
431
+ * @param rows - Total rows (for centering)
432
+ * @param time - Time value for animation
433
+ * @param config - Pattern configuration
434
+ * @returns Value between -1 and 1
435
+ */
436
+ export function fractalPattern(
437
+ x: number,
438
+ y: number,
439
+ cols: number,
440
+ rows: number,
441
+ time: number,
442
+ config: PatternConfig = DEFAULT_PATTERN_CONFIG
443
+ ): number {
444
+ const { frequency, amplitude, speed } = config;
445
+
446
+ // Map to complex plane, centered and scaled
447
+ const scale = 3.5 * frequency;
448
+ const zx = ((x / cols) * scale - scale / 2) + Math.sin(time * speed * 0.1) * 0.2;
449
+ const zy = ((y / rows) * scale - scale / 2) + Math.cos(time * speed * 0.1) * 0.2;
450
+
451
+ // Julia set with animated c parameter
452
+ const cx = -0.7 + Math.sin(time * speed * 0.05) * 0.1;
453
+ const cy = 0.27 + Math.cos(time * speed * 0.07) * 0.1;
454
+
455
+ let zrx = zx;
456
+ let zry = zy;
457
+ const maxIter = 20;
458
+ let iter = 0;
459
+
460
+ // Iterate z = z² + c
461
+ while (zrx * zrx + zry * zry < 4 && iter < maxIter) {
462
+ const tmp = zrx * zrx - zry * zry + cx;
463
+ zry = 2 * zrx * zry + cy;
464
+ zrx = tmp;
465
+ iter++;
466
+ }
467
+
468
+ // Smooth coloring
469
+ if (iter === maxIter) {
470
+ return -1 * amplitude; // Inside the set
471
+ }
472
+
473
+ // Normalize iteration count to [-1, 1]
474
+ const smooth = iter - Math.log2(Math.log2(zrx * zrx + zry * zry));
475
+ return ((smooth / maxIter) * 2 - 1) * amplitude;
476
+ }
477
+
214
478
  /**
215
479
  * Generate a brightness grid for pattern rendering
216
480
  *
@@ -224,7 +488,7 @@ export function seededNoise2D(x: number, y: number, seed: number = 0): number {
224
488
  export function generateBrightnessGrid(
225
489
  cols: number,
226
490
  rows: number,
227
- pattern: 'perlin' | 'waves' | 'static' | 'ripple' | 'fbm',
491
+ pattern: PatternType,
228
492
  time: number = 0,
229
493
  config: PatternConfig = DEFAULT_PATTERN_CONFIG
230
494
  ): number[][] {
@@ -262,10 +526,41 @@ export function generateBrightnessGrid(
262
526
  break;
263
527
 
264
528
  case 'static':
265
- default:
266
529
  // For static, use time as seed for animated static
267
530
  value = seededNoise2D(col, row, Math.floor(time * speed * 10)) * 2 - 1;
268
531
  break;
532
+
533
+ case 'clouds':
534
+ value = cloudsPattern(col, row, time, config);
535
+ break;
536
+
537
+ case 'plasma':
538
+ value = plasmaPattern(col, row, time, config);
539
+ break;
540
+
541
+ case 'vortex':
542
+ value = vortexPattern(col, row, cols / 2, rows / 2, time, config);
543
+ break;
544
+
545
+ case 'matrix':
546
+ value = matrixPattern(col, row, time, config);
547
+ break;
548
+
549
+ case 'gradient':
550
+ value = gradientPattern(col, row, cols, rows, time, config);
551
+ break;
552
+
553
+ case 'diamond':
554
+ value = diamondPattern(col, row, time, config);
555
+ break;
556
+
557
+ case 'fractal':
558
+ value = fractalPattern(col, row, cols, rows, time, config);
559
+ break;
560
+
561
+ default:
562
+ value = seededNoise2D(col, row, Math.floor(time * speed * 10)) * 2 - 1;
563
+ break;
269
564
  }
270
565
 
271
566
  // Normalize from [-1, 1] to [0, 255] with amplitude
@@ -313,4 +608,153 @@ export function gridToImageData(grid: number[][], cellWidth: number, cellHeight:
313
608
  return new ImageData(data, width, height);
314
609
  }
315
610
 
316
- export type PatternType = 'perlin' | 'waves' | 'static' | 'ripple' | 'fbm';
611
+ export type PatternType =
612
+ | 'perlin'
613
+ | 'waves'
614
+ | 'static'
615
+ | 'ripple'
616
+ | 'fbm'
617
+ | 'clouds'
618
+ | 'plasma'
619
+ | 'vortex'
620
+ | 'matrix'
621
+ | 'gradient'
622
+ | 'diamond'
623
+ | 'fractal';
624
+
625
+ // ============================================================================
626
+ // PERFORMANCE-OPTIMIZED API
627
+ // ============================================================================
628
+
629
+ /**
630
+ * Reusable brightness buffer using flat Uint8Array
631
+ * ~30% faster than number[][] due to contiguous memory and no GC pressure
632
+ */
633
+ export interface BrightnessBuffer {
634
+ /** Flat array of brightness values (0-255), row-major order */
635
+ data: Uint8Array;
636
+ /** Number of columns */
637
+ cols: number;
638
+ /** Number of rows */
639
+ rows: number;
640
+ }
641
+
642
+ /**
643
+ * Create a reusable brightness buffer
644
+ * Call once at init, then reuse with fillBrightnessBuffer
645
+ *
646
+ * @param cols - Number of columns
647
+ * @param rows - Number of rows
648
+ * @returns Reusable buffer object
649
+ */
650
+ export function createBrightnessBuffer(cols: number, rows: number): BrightnessBuffer {
651
+ return {
652
+ data: new Uint8Array(cols * rows),
653
+ cols,
654
+ rows,
655
+ };
656
+ }
657
+
658
+ /**
659
+ * Fill an existing brightness buffer with pattern data (zero allocation)
660
+ * Use this in animation loops for best performance
661
+ *
662
+ * @param buffer - Pre-allocated buffer from createBrightnessBuffer
663
+ * @param pattern - Pattern type to generate
664
+ * @param time - Current time in seconds
665
+ * @param config - Pattern configuration
666
+ */
667
+ export function fillBrightnessBuffer(
668
+ buffer: BrightnessBuffer,
669
+ pattern: PatternType,
670
+ time: number = 0,
671
+ config: PatternConfig = DEFAULT_PATTERN_CONFIG
672
+ ): void {
673
+ const { data, cols, rows } = buffer;
674
+ const { frequency, amplitude, speed } = config;
675
+
676
+ let idx = 0;
677
+ for (let row = 0; row < rows; row++) {
678
+ for (let col = 0; col < cols; col++) {
679
+ let value: number;
680
+
681
+ switch (pattern) {
682
+ case 'perlin':
683
+ value = perlinNoise2D(
684
+ col * frequency + time * speed * 0.1,
685
+ row * frequency + time * speed * 0.05
686
+ );
687
+ break;
688
+
689
+ case 'fbm':
690
+ value = fbmNoise(
691
+ col * frequency + time * speed * 0.1,
692
+ row * frequency + time * speed * 0.05,
693
+ 4,
694
+ 0.5
695
+ );
696
+ break;
697
+
698
+ case 'waves':
699
+ value = wavePattern(col, row, time, config);
700
+ break;
701
+
702
+ case 'ripple':
703
+ value = ripplePattern(col, row, cols / 2, rows / 2, time, config);
704
+ break;
705
+
706
+ case 'static':
707
+ value = seededNoise2D(col, row, Math.floor(time * speed * 10)) * 2 - 1;
708
+ break;
709
+
710
+ case 'clouds':
711
+ value = cloudsPattern(col, row, time, config);
712
+ break;
713
+
714
+ case 'plasma':
715
+ value = plasmaPattern(col, row, time, config);
716
+ break;
717
+
718
+ case 'vortex':
719
+ value = vortexPattern(col, row, cols / 2, rows / 2, time, config);
720
+ break;
721
+
722
+ case 'matrix':
723
+ value = matrixPattern(col, row, time, config);
724
+ break;
725
+
726
+ case 'gradient':
727
+ value = gradientPattern(col, row, cols, rows, time, config);
728
+ break;
729
+
730
+ case 'diamond':
731
+ value = diamondPattern(col, row, time, config);
732
+ break;
733
+
734
+ case 'fractal':
735
+ value = fractalPattern(col, row, cols, rows, time, config);
736
+ break;
737
+
738
+ default:
739
+ value = seededNoise2D(col, row, Math.floor(time * speed * 10)) * 2 - 1;
740
+ break;
741
+ }
742
+
743
+ // Normalize from [-1, 1] to [0, 255] with amplitude
744
+ // Using bitwise OR for fast floor: (x | 0) is faster than Math.floor(x)
745
+ const normalized = (value + 1) * 0.5 * amplitude;
746
+ data[idx++] = normalized * 255 | 0;
747
+ }
748
+ }
749
+ }
750
+
751
+ /**
752
+ * Get brightness value from buffer at (col, row)
753
+ * @param buffer - Brightness buffer
754
+ * @param col - Column index
755
+ * @param row - Row index
756
+ * @returns Brightness value 0-255
757
+ */
758
+ export function getBufferValue(buffer: BrightnessBuffer, col: number, row: number): number {
759
+ return buffer.data[row * buffer.cols + col];
760
+ }
package/src/renderer.ts CHANGED
@@ -60,6 +60,10 @@ export class GossamerRenderer {
60
60
  private lastFrameTime: number = 0;
61
61
  private isRunning: boolean = false;
62
62
 
63
+ // Performance: Character texture atlas
64
+ private charAtlas: OffscreenCanvas | HTMLCanvasElement | null = null;
65
+ private atlasCharacters: string = '';
66
+
63
67
  constructor(canvas: HTMLCanvasElement, config: Partial<Omit<RenderConfig, 'canvas'>> = {}) {
64
68
  const context = canvas.getContext('2d');
65
69
  if (!context) {
@@ -74,6 +78,57 @@ export class GossamerRenderer {
74
78
  };
75
79
 
76
80
  this.setupCanvas();
81
+ this.buildCharacterAtlas();
82
+ }
83
+
84
+ /**
85
+ * Build character texture atlas for fast rendering
86
+ * Pre-renders all characters to an offscreen canvas, then uses drawImage
87
+ * instead of fillText for 5-10x faster rendering
88
+ */
89
+ private buildCharacterAtlas(): void {
90
+ const { characters, cellWidth, cellHeight, color, fontFamily } = this.config;
91
+
92
+ // Skip if atlas already built with same characters
93
+ if (this.atlasCharacters === characters && this.charAtlas) {
94
+ return;
95
+ }
96
+
97
+ // Create offscreen canvas (use OffscreenCanvas if available for better perf)
98
+ const atlasWidth = characters.length * cellWidth;
99
+ const atlasHeight = cellHeight;
100
+
101
+ if (typeof OffscreenCanvas !== 'undefined') {
102
+ this.charAtlas = new OffscreenCanvas(atlasWidth, atlasHeight);
103
+ } else {
104
+ this.charAtlas = document.createElement('canvas');
105
+ this.charAtlas.width = atlasWidth;
106
+ this.charAtlas.height = atlasHeight;
107
+ }
108
+
109
+ const ctx = this.charAtlas.getContext('2d');
110
+ if (!ctx) {
111
+ this.charAtlas = null;
112
+ return;
113
+ }
114
+
115
+
116
+ // Clear with transparent background
117
+ ctx.clearRect(0, 0, atlasWidth, atlasHeight);
118
+
119
+ // Render each character
120
+ ctx.fillStyle = color;
121
+ ctx.font = `${cellHeight}px ${fontFamily}`;
122
+ ctx.textBaseline = 'top';
123
+
124
+ for (let i = 0; i < characters.length; i++) {
125
+ const char = characters[i];
126
+ if (char !== ' ') {
127
+ ctx.fillText(char, i * cellWidth, 0);
128
+ }
129
+ }
130
+
131
+ this.atlasCharacters = characters;
77
132
  }
78
133
 
79
134
  /**
@@ -95,8 +150,20 @@ export class GossamerRenderer {
95
150
  * Update the renderer configuration
96
151
  */
97
152
  updateConfig(config: Partial<Omit<RenderConfig, 'canvas'>>): void {
153
+ const needsAtlasRebuild =
154
+ config.characters !== undefined ||
155
+ config.color !== undefined ||
156
+ config.cellWidth !== undefined ||
157
+ config.cellHeight !== undefined ||
158
+ config.fontFamily !== undefined;
159
+
98
160
  this.config = { ...this.config, ...config };
99
161
  this.setupCanvas();
162
+
163
+ if (needsAtlasRebuild) {
164
+ this.atlasCharacters = ''; // Force rebuild
165
+ this.buildCharacterAtlas();
166
+ }
100
167
  }
101
168
 
102
169
  /**
@@ -205,6 +272,100 @@ export class GossamerRenderer {
205
272
  }
206
273
  }
207
274
 
275
+ /**
276
+ * PERFORMANCE: Render from BrightnessBuffer using texture atlas
277
+ *
278
+ * Uses pre-rendered character sprites instead of fillText calls.
279
+ * 5-10x faster than renderFromBrightnessGrid for large canvases.
280
+ *
281
+ * @param buffer - BrightnessBuffer from fillBrightnessBuffer
282
+ */
283
+ renderFromBuffer(buffer: { data: Uint8Array; cols: number; rows: number }): void {
284
+ const { characters, cellWidth, cellHeight } = this.config;
285
+
286
+ this.clear();
287
+
288
+ // Fall back to fillText if atlas not available
289
+ if (!this.charAtlas) {
290
+ this.ctx.fillStyle = this.config.color;
291
+ const charLen = characters.length - 1;
292
+ let idx = 0;
293
+ for (let row = 0; row < buffer.rows; row++) {
294
+ for (let col = 0; col < buffer.cols; col++) {
295
+ const brightness = buffer.data[idx++];
296
+ const charIndex = (brightness / 255 * charLen) | 0;
297
+ const char = characters[Math.min(charIndex, charLen)];
298
+ if (char !== ' ') {
299
+ this.ctx.fillText(char, col * cellWidth, row * cellHeight);
300
+ }
301
+ }
302
+ }
303
+ return;
304
+ }
305
+
306
+ // Use atlas for fast rendering via drawImage
307
+ const charLen = characters.length - 1;
308
+ let idx = 0;
309
+
310
+ for (let row = 0; row < buffer.rows; row++) {
311
+ const y = row * cellHeight;
312
+ for (let col = 0; col < buffer.cols; col++) {
313
+ const brightness = buffer.data[idx++];
314
+ const charIndex = (brightness / 255 * charLen) | 0;
315
+
316
+ // Skip space characters (index 0 in most charsets)
317
+ if (charIndex === 0 && characters[0] === ' ') {
318
+ continue;
319
+ }
320
+
321
+ // Draw from atlas: source is the character's position in the atlas
322
+ this.ctx.drawImage(
323
+ this.charAtlas,
324
+ charIndex * cellWidth, 0, cellWidth, cellHeight, // source
325
+ col * cellWidth, y, cellWidth, cellHeight // destination
326
+ );
327
+ }
328
+ }
329
+ }
330
+
331
+ /**
332
+ * PERFORMANCE: Render brightness grid using atlas (legacy grid format)
333
+ *
334
+ * @param grid - 2D array of brightness values
335
+ */
336
+ renderGridFast(grid: number[][]): void {
337
+ const { characters, cellWidth, cellHeight } = this.config;
338
+
339
+ this.clear();
340
+
341
+ if (!this.charAtlas) {
342
+ // Fallback to standard method
343
+ this.renderFromBrightnessGrid(grid);
344
+ return;
345
+ }
346
+
347
+ const charLen = characters.length - 1;
348
+
349
+ for (let row = 0; row < grid.length; row++) {
350
+ const y = row * cellHeight;
351
+ const rowData = grid[row];
352
+ for (let col = 0; col < rowData.length; col++) {
353
+ const brightness = rowData[col];
354
+ const charIndex = (brightness / 255 * charLen) | 0;
355
+
356
+ if (charIndex === 0 && characters[0] === ' ') {
357
+ continue;
358
+ }
359
+
360
+ this.ctx.drawImage(
361
+ this.charAtlas,
362
+ charIndex * cellWidth, 0, cellWidth, cellHeight,
363
+ col * cellWidth, y, cellWidth, cellHeight
364
+ );
365
+ }
366
+ }
367
+ }
368
+
208
369
  /**
209
370
  * Calculate average brightness for a cell region
210
371
  */
@@ -67,7 +67,7 @@
67
67
  let animationId: number | null = null;
68
68
 
69
69
  // Apply preset if specified
70
- const config = $derived(() => {
70
+ const config = $derived.by(() => {
71
71
  if (preset && PRESETS[preset]) {
72
72
  const p = PRESETS[preset];
73
73
  return {
@@ -93,7 +93,7 @@
93
93
 
94
94
  const elapsed = (currentTime - startTime) / 1000;
95
95
  const { cols, rows } = renderer.getCellCount();
96
- const cfg = config();
96
+ const cfg = config;
97
97
 
98
98
  const grid = generateBrightnessGrid(
99
99
  cols,
@@ -128,7 +128,7 @@
128
128
  if (!renderer) return;
129
129
 
130
130
  const { cols, rows } = renderer.getCellCount();
131
- const cfg = config();
131
+ const cfg = config;
132
132
 
133
133
  const grid = generateBrightnessGrid(
134
134
  cols,
@@ -148,7 +148,7 @@
148
148
  function setupRenderer(width: number, height: number): void {
149
149
  if (!canvas) return;
150
150
 
151
- const cfg = config();
151
+ const cfg = config;
152
152
 
153
153
  // Create or update renderer
154
154
  if (renderer) {
@@ -220,7 +220,7 @@
220
220
  // React to prop changes
221
221
  $effect(() => {
222
222
  if (renderer) {
223
- const cfg = config();
223
+ const cfg = config;
224
224
  renderer.updateConfig({
225
225
  characters: cfg.characters,
226
226
  color,
@@ -241,7 +241,7 @@
241
241
  <div
242
242
  bind:this={container}
243
243
  class="gossamer-clouds {className}"
244
- style:opacity={config().opacity}
244
+ style:opacity={config.opacity}
245
245
  >
246
246
  <canvas
247
247
  bind:this={canvas}