git-hash-art 0.10.1 → 0.12.0

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/lib/render.ts CHANGED
@@ -38,8 +38,7 @@ import {
38
38
  drawMirroredShape,
39
39
  pickMirrorAxis,
40
40
  pickBlendMode,
41
- pickRenderStyle,
42
- type RenderStyle
41
+ pickRenderStyle, type RenderStyle
43
42
  } from "./canvas/draw";
44
43
  import { shapes } from "./canvas/shapes";
45
44
  import {
@@ -50,7 +49,41 @@ import {
50
49
  } from "./canvas/shapes/affinity";
51
50
  import { createRng, seedFromHash, createSimplexNoise, createFBM } from "./utils";
52
51
  import { DEFAULT_CONFIG, type GenerationConfig } from "../types";
53
- import { selectArchetype, type BackgroundStyle } from "./archetypes";
52
+ import { selectArchetype, type BackgroundStyle, type CompositionMode } from "./archetypes";
53
+
54
+ // ── Render style cost weights (normalized: fill-and-stroke = 1) ─────
55
+ // Based on benchmark measurements. Used by the complexity budget to
56
+ // cap total rendering work and downgrade expensive styles when needed.
57
+ const RENDER_STYLE_COST: Record<RenderStyle, number> = {
58
+ "fill-and-stroke": 1,
59
+ "fill-only": 0.5,
60
+ "stroke-only": 1,
61
+ "double-stroke": 1.5,
62
+ "dashed": 1,
63
+ "watercolor": 7,
64
+ "hatched": 3,
65
+ "incomplete": 1,
66
+ "stipple": 90,
67
+ "stencil": 2,
68
+ "noise-grain": 400,
69
+ "wood-grain": 10,
70
+ "marble-vein": 4,
71
+ "fabric-weave": 6,
72
+ "hand-drawn": 5,
73
+ };
74
+
75
+ function downgradeRenderStyle(style: RenderStyle): RenderStyle {
76
+ switch (style) {
77
+ case "noise-grain": return "hatched";
78
+ case "stipple": return "dashed";
79
+ case "wood-grain": return "hatched";
80
+ case "watercolor": return "fill-and-stroke";
81
+ case "fabric-weave": return "hatched";
82
+ case "hand-drawn": return "fill-and-stroke";
83
+ case "marble-vein": return "stroke-only";
84
+ default: return style;
85
+ }
86
+ }
54
87
 
55
88
 
56
89
  // ── Shape categories for weighted selection (legacy fallback) ───────
@@ -69,15 +102,7 @@ const SACRED_SHAPES = [
69
102
 
70
103
  // ── Composition modes ───────────────────────────────────────────────
71
104
 
72
- type CompositionMode =
73
- | "radial"
74
- | "flow-field"
75
- | "spiral"
76
- | "grid-subdivision"
77
- | "clustered"
78
- | "golden-spiral";
79
-
80
- const COMPOSITION_MODES: CompositionMode[] = [
105
+ const ALL_COMPOSITION_MODES: CompositionMode[] = [
81
106
  "radial",
82
107
  "flow-field",
83
108
  "spiral",
@@ -193,7 +218,80 @@ function isInVoidZone(
193
218
  return false;
194
219
  }
195
220
 
196
- // ── Helper: density check ───────────────────────────────────────────
221
+ // ── Spatial hash grid for O(1) density checks and nearest-neighbor ──
222
+
223
+ class SpatialGrid {
224
+ private cells: Map<string, Array<{ x: number; y: number; size: number; shape: string }>>;
225
+ private cellSize: number;
226
+
227
+ constructor(cellSize: number) {
228
+ this.cells = new Map();
229
+ this.cellSize = cellSize;
230
+ }
231
+
232
+ private key(cx: number, cy: number): string {
233
+ return `${cx},${cy}`;
234
+ }
235
+
236
+ insert(item: { x: number; y: number; size: number; shape: string }): void {
237
+ const cx = Math.floor(item.x / this.cellSize);
238
+ const cy = Math.floor(item.y / this.cellSize);
239
+ const k = this.key(cx, cy);
240
+ const cell = this.cells.get(k);
241
+ if (cell) cell.push(item);
242
+ else this.cells.set(k, [item]);
243
+ }
244
+
245
+ /** Count items within radius of (x, y) */
246
+ countNear(x: number, y: number, radius: number): number {
247
+ const r2 = radius * radius;
248
+ const minCx = Math.floor((x - radius) / this.cellSize);
249
+ const maxCx = Math.floor((x + radius) / this.cellSize);
250
+ const minCy = Math.floor((y - radius) / this.cellSize);
251
+ const maxCy = Math.floor((y + radius) / this.cellSize);
252
+ let count = 0;
253
+ for (let cx = minCx; cx <= maxCx; cx++) {
254
+ for (let cy = minCy; cy <= maxCy; cy++) {
255
+ const cell = this.cells.get(this.key(cx, cy));
256
+ if (!cell) continue;
257
+ for (const p of cell) {
258
+ const dx = x - p.x;
259
+ const dy = y - p.y;
260
+ if (dx * dx + dy * dy < r2) count++;
261
+ }
262
+ }
263
+ }
264
+ return count;
265
+ }
266
+
267
+ /** Find nearest item to (x, y) */
268
+ findNearest(x: number, y: number, searchRadius: number): { x: number; y: number; size: number } | null {
269
+ const minCx = Math.floor((x - searchRadius) / this.cellSize);
270
+ const maxCx = Math.floor((x + searchRadius) / this.cellSize);
271
+ const minCy = Math.floor((y - searchRadius) / this.cellSize);
272
+ const maxCy = Math.floor((y + searchRadius) / this.cellSize);
273
+ let nearest: { x: number; y: number; size: number } | null = null;
274
+ let bestDist2 = Infinity;
275
+ for (let cx = minCx; cx <= maxCx; cx++) {
276
+ for (let cy = minCy; cy <= maxCy; cy++) {
277
+ const cell = this.cells.get(this.key(cx, cy));
278
+ if (!cell) continue;
279
+ for (const p of cell) {
280
+ const dx = x - p.x;
281
+ const dy = y - p.y;
282
+ const d2 = dx * dx + dy * dy;
283
+ if (d2 > 0 && d2 < bestDist2) {
284
+ bestDist2 = d2;
285
+ nearest = p;
286
+ }
287
+ }
288
+ }
289
+ }
290
+ return nearest;
291
+ }
292
+ }
293
+
294
+ // ── Helper: density check (legacy wrapper) ──────────────────────────
197
295
 
198
296
  function localDensity(
199
297
  x: number,
@@ -498,44 +596,45 @@ export function renderHashArt(
498
596
  const patternColor = hexWithAlpha(colorHierarchy.dominant, 0.15);
499
597
 
500
598
  if (bgPatternRoll < 0.2) {
501
- // Dot grid
599
+ // Dot grid — batched into a single path
502
600
  const dotSpacing = Math.max(8, Math.min(width, height) * (0.015 + rng() * 0.015));
503
601
  const dotR = dotSpacing * 0.08;
504
602
  ctx.globalAlpha = patternOpacity;
505
603
  ctx.fillStyle = patternColor;
604
+ ctx.beginPath();
506
605
  for (let px = 0; px < width; px += dotSpacing) {
507
606
  for (let py = 0; py < height; py += dotSpacing) {
508
- ctx.beginPath();
607
+ ctx.moveTo(px + dotR, py);
509
608
  ctx.arc(px, py, dotR, 0, Math.PI * 2);
510
- ctx.fill();
511
609
  }
512
610
  }
611
+ ctx.fill();
513
612
  } else if (bgPatternRoll < 0.4) {
514
- // Diagonal lines
613
+ // Diagonal lines — batched into a single path
515
614
  const lineSpacing = Math.max(6, Math.min(width, height) * (0.02 + rng() * 0.02));
516
615
  ctx.globalAlpha = patternOpacity;
517
616
  ctx.strokeStyle = patternColor;
518
617
  ctx.lineWidth = 0.5 * scaleFactor;
519
618
  const diag = Math.hypot(width, height);
619
+ ctx.beginPath();
520
620
  for (let d = -diag; d < diag; d += lineSpacing) {
521
- ctx.beginPath();
522
621
  ctx.moveTo(d, 0);
523
622
  ctx.lineTo(d + height, height);
524
- ctx.stroke();
525
623
  }
624
+ ctx.stroke();
526
625
  } else {
527
- // Tessellation — hexagonal grid of tiny shapes
626
+ // Tessellation — hexagonal grid, batched into a single path
528
627
  const tessSize = Math.max(10, Math.min(width, height) * (0.025 + rng() * 0.02));
529
628
  const tessH = tessSize * Math.sqrt(3);
530
629
  ctx.globalAlpha = patternOpacity * 0.7;
531
630
  ctx.strokeStyle = patternColor;
532
631
  ctx.lineWidth = 0.4 * scaleFactor;
632
+ ctx.beginPath();
533
633
  for (let row = 0; row * tessH < height + tessH; row++) {
534
634
  const offsetX = (row % 2) * tessSize * 0.75;
535
635
  for (let col = 0; col * tessSize * 1.5 < width + tessSize * 1.5; col++) {
536
636
  const hx = col * tessSize * 1.5 + offsetX;
537
637
  const hy = row * tessH;
538
- ctx.beginPath();
539
638
  for (let s = 0; s < 6; s++) {
540
639
  const angle = (Math.PI / 3) * s - Math.PI / 6;
541
640
  const vx = hx + Math.cos(angle) * tessSize * 0.5;
@@ -544,17 +643,18 @@ export function renderHashArt(
544
643
  else ctx.lineTo(vx, vy);
545
644
  }
546
645
  ctx.closePath();
547
- ctx.stroke();
548
646
  }
549
647
  }
648
+ ctx.stroke();
550
649
  }
551
650
  ctx.restore();
552
651
  }
553
652
  ctx.globalCompositeOperation = "source-over";
554
653
 
555
- // ── 2. Composition mode ────────────────────────────────────────
556
- const compositionMode =
557
- COMPOSITION_MODES[Math.floor(rng() * COMPOSITION_MODES.length)];
654
+ // ── 2. Composition mode — archetype-aware selection ──────────────
655
+ const compositionMode: CompositionMode = rng() < 0.7
656
+ ? archetype.preferredCompositions[Math.floor(rng() * archetype.preferredCompositions.length)]
657
+ : ALL_COMPOSITION_MODES[Math.floor(rng() * ALL_COMPOSITION_MODES.length)];
558
658
 
559
659
  // ── 2b. Symmetry mode — ~25% of hashes trigger mirroring ──────
560
660
  type SymmetryMode = "none" | "bilateral-x" | "bilateral-y" | "quad";
@@ -564,7 +664,7 @@ export function renderHashArt(
564
664
  symRoll < 0.20 ? "bilateral-y" :
565
665
  symRoll < 0.25 ? "quad" : "none";
566
666
 
567
- // ── 3. Focal points + void zones ───────────────────────────────
667
+ // ── 3. Focal points + void zones (archetype-aware) ───────────────
568
668
  const THIRDS_POINTS = [
569
669
  { x: 1 / 3, y: 1 / 3 },
570
670
  { x: 2 / 3, y: 1 / 3 },
@@ -590,14 +690,30 @@ export function renderHashArt(
590
690
  }
591
691
  }
592
692
 
593
- const numVoids = Math.floor(rng() * 2) + 1;
693
+ // Archetype-aware void zones: dense archetypes get fewer/no voids,
694
+ // minimal archetypes get golden-ratio positioned voids
695
+ const PHI = (1 + Math.sqrt(5)) / 2;
696
+ const isMinimalArchetype = archetype.gridSize <= 3;
697
+ const isDenseArchetype = archetype.gridSize >= 8;
698
+ const numVoids = isDenseArchetype ? 0 : (Math.floor(rng() * 2) + 1);
594
699
  const voidZones: Array<{ x: number; y: number; radius: number }> = [];
595
700
  for (let v = 0; v < numVoids; v++) {
596
- voidZones.push({
597
- x: width * (0.15 + rng() * 0.7),
598
- y: height * (0.15 + rng() * 0.7),
599
- radius: Math.min(width, height) * (0.06 + rng() * 0.1),
600
- });
701
+ if (isMinimalArchetype) {
702
+ // Place voids at golden-ratio positions for intentional negative space
703
+ const gx = (v === 0) ? 1 / PHI : 1 - 1 / PHI;
704
+ const gy = (v === 0) ? 1 - 1 / PHI : 1 / PHI;
705
+ voidZones.push({
706
+ x: width * (gx + (rng() - 0.5) * 0.05),
707
+ y: height * (gy + (rng() - 0.5) * 0.05),
708
+ radius: Math.min(width, height) * (0.08 + rng() * 0.08),
709
+ });
710
+ } else {
711
+ voidZones.push({
712
+ x: width * (0.15 + rng() * 0.7),
713
+ y: height * (0.15 + rng() * 0.7),
714
+ radius: Math.min(width, height) * (0.06 + rng() * 0.1),
715
+ });
716
+ }
601
717
  }
602
718
 
603
719
  function applyFocalBias(rx: number, ry: number): [number, number] {
@@ -624,23 +740,27 @@ export function renderHashArt(
624
740
  ctx.arc(zone.x, zone.y, zone.radius, 0, Math.PI * 2);
625
741
  ctx.stroke();
626
742
 
627
- // ~50% chance: scatter tiny dots inside the void
743
+ // ~50% chance: scatter tiny dots inside the void — batched into single path
628
744
  if (rng() < 0.5) {
629
745
  const dotCount = 3 + Math.floor(rng() * 6);
630
746
  ctx.globalAlpha = 0.06 + rng() * 0.04;
631
747
  ctx.fillStyle = hexWithAlpha(colorHierarchy.secondary, 0.15);
748
+ ctx.beginPath();
632
749
  for (let d = 0; d < dotCount; d++) {
633
750
  const angle = rng() * Math.PI * 2;
634
751
  const dist = rng() * zone.radius * 0.7;
635
752
  const dotR = (1 + rng() * 3) * scaleFactor;
636
- ctx.beginPath();
753
+ ctx.moveTo(
754
+ zone.x + Math.cos(angle) * dist + dotR,
755
+ zone.y + Math.sin(angle) * dist,
756
+ );
637
757
  ctx.arc(
638
758
  zone.x + Math.cos(angle) * dist,
639
759
  zone.y + Math.sin(angle) * dist,
640
760
  dotR, 0, Math.PI * 2,
641
761
  );
642
- ctx.fill();
643
762
  }
763
+ ctx.fill();
644
764
  }
645
765
 
646
766
  // ~30% chance: thin concentric ring inside
@@ -681,6 +801,10 @@ export function renderHashArt(
681
801
  // Track all placed shapes for density checks and connecting curves
682
802
  const shapePositions: Array<{ x: number; y: number; size: number; shape: string }> = [];
683
803
 
804
+ // Spatial grid for O(1) density and nearest-neighbor lookups
805
+ const densityCheckRadius = Math.min(width, height) * 0.08;
806
+ const spatialGrid = new SpatialGrid(densityCheckRadius);
807
+
684
808
  // Hero avoidance radius — shapes near the hero orient toward it
685
809
  let heroCenter: { x: number; y: number; size: number } | null = null;
686
810
 
@@ -727,13 +851,35 @@ export function renderHashArt(
727
851
 
728
852
  heroCenter = { x: heroFocal.x, y: heroFocal.y, size: heroSize };
729
853
  shapePositions.push({ x: heroFocal.x, y: heroFocal.y, size: heroSize, shape: heroShape });
854
+ spatialGrid.insert({ x: heroFocal.x, y: heroFocal.y, size: heroSize, shape: heroShape });
730
855
  }
731
856
 
732
857
 
733
858
  // ── 5. Shape layers ────────────────────────────────────────────
734
- const densityCheckRadius = Math.min(width, height) * 0.08;
735
859
  const maxLocalDensity = Math.ceil(shapesPerLayer * 0.15);
736
860
 
861
+ // ── Complexity budget — caps total rendering work ──────────────
862
+ // Budget scales with pixel area so larger canvases get proportionally
863
+ // more headroom. The multiplier extras (glazing, echoes, nesting,
864
+ // constellations, rhythm) are gated behind the budget; when it runs
865
+ // low they are skipped. When it's exhausted, expensive render styles
866
+ // are downgraded to cheaper alternatives.
867
+ //
868
+ // RNG values are always consumed even when skipping, so the
869
+ // deterministic sequence for shapes that *do* render is preserved.
870
+ const pixelArea = width * height;
871
+ const BUDGET_PER_MEGAPIXEL = 6000; // cost units per 1M pixels
872
+ let complexityBudget = (pixelArea / 1_000_000) * BUDGET_PER_MEGAPIXEL;
873
+ const totalBudget = complexityBudget;
874
+ const budgetForExtras = complexityBudget * 0.25; // reserve 25% for multiplier extras
875
+ let extrasSpent = 0;
876
+
877
+ // Hard cap on clip-heavy render styles (stipple, noise-grain).
878
+ // These generate O(size²) fillRect calls per shape and dominate
879
+ // worst-case render time. Cap scales with pixel area.
880
+ const MAX_CLIP_HEAVY_SHAPES = Math.max(4, Math.floor(8 * (pixelArea / 1_000_000)));
881
+ let clipHeavyCount = 0;
882
+
737
883
  for (let layer = 0; layer < layers; layer++) {
738
884
  const layerRatio = layers > 1 ? layer / (layers - 1) : 0;
739
885
  const numShapes =
@@ -792,7 +938,7 @@ export function renderHashArt(
792
938
  if (isInVoidZone(x, y, voidZones)) {
793
939
  if (rng() < 0.85) continue;
794
940
  }
795
- if (localDensity(x, y, shapePositions, densityCheckRadius) > maxLocalDensity) {
941
+ if (spatialGrid.countNear(x, y, densityCheckRadius) > maxLocalDensity) {
796
942
  if (rng() < 0.6) continue;
797
943
  }
798
944
 
@@ -870,7 +1016,30 @@ export function renderHashArt(
870
1016
 
871
1017
  // Organic edge jitter — applied via watercolor style on ~15% of shapes
872
1018
  const useOrganicEdges = rng() < 0.15 && shapeRenderStyle === "fill-and-stroke";
873
- const finalRenderStyle = useOrganicEdges ? "watercolor" as RenderStyle : shapeRenderStyle;
1019
+ let finalRenderStyle = useOrganicEdges ? "watercolor" as RenderStyle : shapeRenderStyle;
1020
+
1021
+ // Budget check: downgrade expensive styles proportionally —
1022
+ // the more expensive the style, the earlier it gets downgraded.
1023
+ // noise-grain (400) downgrades when budget < 20% remaining,
1024
+ // stipple (90) when < 82%, wood-grain (10) when < 98%.
1025
+ let styleCost = RENDER_STYLE_COST[finalRenderStyle] ?? 1;
1026
+ if (styleCost > 3) {
1027
+ const downgradeThreshold = Math.min(0.85, styleCost / 500);
1028
+ if (complexityBudget < totalBudget * (1 - downgradeThreshold)) {
1029
+ finalRenderStyle = downgradeRenderStyle(finalRenderStyle);
1030
+ styleCost = RENDER_STYLE_COST[finalRenderStyle] ?? 1;
1031
+ }
1032
+ }
1033
+ // Hard cap: clip-heavy styles (stipple, noise-grain) are limited
1034
+ // to MAX_CLIP_HEAVY_SHAPES total across the entire render.
1035
+ if ((finalRenderStyle === "stipple" || finalRenderStyle === "noise-grain") &&
1036
+ clipHeavyCount >= MAX_CLIP_HEAVY_SHAPES) {
1037
+ finalRenderStyle = downgradeRenderStyle(finalRenderStyle);
1038
+ styleCost = RENDER_STYLE_COST[finalRenderStyle] ?? 1;
1039
+ }
1040
+ if (finalRenderStyle === "stipple" || finalRenderStyle === "noise-grain") {
1041
+ clipHeavyCount++;
1042
+ }
874
1043
 
875
1044
  // Consistent light direction — subtle shadow offset
876
1045
  const shadowDist = hasGlow ? 0 : (size * 0.02);
@@ -881,17 +1050,11 @@ export function renderHashArt(
881
1050
  let finalX = x;
882
1051
  let finalY = y;
883
1052
  if (shapePositions.length > 0 && rng() < 0.25) {
884
- // Find nearest placed shape
885
- let nearestDist = Infinity;
886
- let nearestPos: { x: number; y: number; size: number } | null = null;
887
- for (const sp of shapePositions) {
888
- const d = Math.hypot(x - sp.x, y - sp.y);
889
- if (d < nearestDist && d > 0) {
890
- nearestDist = d;
891
- nearestPos = sp;
892
- }
893
- }
1053
+ // Use spatial grid for O(1) nearest-neighbor lookup
1054
+ const searchRadius = adjustedMaxSize * 3;
1055
+ const nearestPos = spatialGrid.findNearest(x, y, searchRadius);
894
1056
  if (nearestPos) {
1057
+ const nearestDist = Math.hypot(x - nearestPos.x, y - nearestPos.y);
895
1058
  // Target distance: edges kissing (sum of half-sizes)
896
1059
  const targetDist = (size + nearestPos.size) * 0.5;
897
1060
  if (nearestDist > targetDist * 0.5 && nearestDist < targetDist * 3) {
@@ -935,58 +1098,73 @@ export function renderHashArt(
935
1098
  mirrorAxis: mirrorAxis!,
936
1099
  mirrorGap: size * (0.1 + rng() * 0.3),
937
1100
  });
1101
+ complexityBudget -= styleCost * 2; // mirrored = 2 shapes
938
1102
  } else {
939
1103
  enhanceShapeGeneration(ctx, shape, finalX, finalY, shapeConfig);
1104
+ complexityBudget -= styleCost;
940
1105
  }
941
1106
 
1107
+ // ── Extras budget gate — skip multiplier sections when over budget ──
1108
+ const extrasAllowed = extrasSpent < budgetForExtras;
1109
+
942
1110
  // ── Glazing — luminous multi-pass transparency on ~20% of shapes ──
943
1111
  if (rng() < 0.2 && size > adjustedMinSize * 2) {
944
1112
  const glazePasses = 2 + Math.floor(rng() * 2);
945
- for (let g = 0; g < glazePasses; g++) {
946
- const glazeScale = 1 - (g + 1) * 0.12; // progressively smaller
947
- const glazeAlpha = 0.08 + g * 0.04; // progressively more opaque toward center
948
- ctx.globalAlpha = glazeAlpha;
949
- enhanceShapeGeneration(ctx, shape, finalX, finalY, {
950
- fillColor: hexWithAlpha(fillColor, 0.15 + g * 0.1),
951
- strokeColor: "rgba(0,0,0,0)",
952
- strokeWidth: 0,
953
- size: size * glazeScale,
954
- rotation,
955
- proportionType: "GOLDEN_RATIO",
956
- renderStyle: "fill-only",
957
- rng,
958
- });
1113
+ if (extrasAllowed) {
1114
+ for (let g = 0; g < glazePasses; g++) {
1115
+ const glazeScale = 1 - (g + 1) * 0.12;
1116
+ const glazeAlpha = 0.08 + g * 0.04;
1117
+ ctx.globalAlpha = glazeAlpha;
1118
+ enhanceShapeGeneration(ctx, shape, finalX, finalY, {
1119
+ fillColor: hexWithAlpha(fillColor, 0.15 + g * 0.1),
1120
+ strokeColor: "rgba(0,0,0,0)",
1121
+ strokeWidth: 0,
1122
+ size: size * glazeScale,
1123
+ rotation,
1124
+ proportionType: "GOLDEN_RATIO",
1125
+ renderStyle: "fill-only",
1126
+ rng,
1127
+ });
1128
+ }
1129
+ extrasSpent += glazePasses;
959
1130
  }
1131
+ // RNG consumed by glazePasses calculation above regardless
960
1132
  }
961
1133
 
962
1134
  shapePositions.push({ x: finalX, y: finalY, size, shape });
1135
+ spatialGrid.insert({ x: finalX, y: finalY, size, shape });
963
1136
 
964
1137
  // ── 5c. Size echo — large shapes spawn trailing smaller copies ──
965
1138
  if (size > adjustedMaxSize * 0.5 && rng() < 0.2) {
966
1139
  const echoCount = 2 + Math.floor(rng() * 2);
967
1140
  const echoAngle = rng() * Math.PI * 2;
968
- for (let e = 0; e < echoCount; e++) {
969
- const echoScale = 0.3 - e * 0.08;
970
- const echoDist = size * (0.6 + e * 0.4);
971
- const echoX = finalX + Math.cos(echoAngle) * echoDist;
972
- const echoY = finalY + Math.sin(echoAngle) * echoDist;
973
- const echoSize = size * Math.max(0.1, echoScale);
974
-
975
- if (echoX < 0 || echoX > width || echoY < 0 || echoY > height) continue;
976
-
977
- ctx.globalAlpha = layerOpacity * (0.4 - e * 0.1);
978
- enhanceShapeGeneration(ctx, shape, echoX, echoY, {
979
- fillColor: hexWithAlpha(fillColor, fillAlpha * 0.6),
980
- strokeColor: hexWithAlpha(strokeColor, 0.4),
981
- strokeWidth: strokeWidth * 0.6,
982
- size: echoSize,
983
- rotation: rotation + (e + 1) * 15,
984
- proportionType: "GOLDEN_RATIO",
985
- renderStyle: finalRenderStyle,
986
- rng,
987
- });
988
- shapePositions.push({ x: echoX, y: echoY, size: echoSize, shape });
1141
+ if (extrasAllowed) {
1142
+ for (let e = 0; e < echoCount; e++) {
1143
+ const echoScale = 0.3 - e * 0.08;
1144
+ const echoDist = size * (0.6 + e * 0.4);
1145
+ const echoX = finalX + Math.cos(echoAngle) * echoDist;
1146
+ const echoY = finalY + Math.sin(echoAngle) * echoDist;
1147
+ const echoSize = size * Math.max(0.1, echoScale);
1148
+
1149
+ if (echoX < 0 || echoX > width || echoY < 0 || echoY > height) continue;
1150
+
1151
+ ctx.globalAlpha = layerOpacity * (0.4 - e * 0.1);
1152
+ enhanceShapeGeneration(ctx, shape, echoX, echoY, {
1153
+ fillColor: hexWithAlpha(fillColor, fillAlpha * 0.6),
1154
+ strokeColor: hexWithAlpha(strokeColor, 0.4),
1155
+ strokeWidth: strokeWidth * 0.6,
1156
+ size: echoSize,
1157
+ rotation: rotation + (e + 1) * 15,
1158
+ proportionType: "GOLDEN_RATIO",
1159
+ renderStyle: finalRenderStyle,
1160
+ rng,
1161
+ });
1162
+ shapePositions.push({ x: echoX, y: echoY, size: echoSize, shape });
1163
+ spatialGrid.insert({ x: echoX, y: echoY, size: echoSize, shape });
1164
+ }
1165
+ extrasSpent += echoCount * styleCost;
989
1166
  }
1167
+ // RNG for echoCount + echoAngle consumed above regardless
990
1168
  }
991
1169
 
992
1170
  // ── 5d. Recursive nesting ──────────────────────────────────
@@ -995,36 +1173,51 @@ export function renderHashArt(
995
1173
  const nestingChance = 0.15 + focalProximity * 0.15; // 15-30% near focal
996
1174
  if (size > adjustedMaxSize * 0.4 && rng() < nestingChance) {
997
1175
  const innerCount = 1 + Math.floor(rng() * 3);
998
- for (let n = 0; n < innerCount; n++) {
999
- // Pick inner shape from palette affinities
1000
- const innerSizeFraction = (size * 0.25) / adjustedMaxSize;
1001
- const innerShape = pickShapeFromPalette(shapePalette, rng, innerSizeFraction);
1002
- const innerSize = size * (0.15 + rng() * 0.25);
1003
- const innerOffX = (rng() - 0.5) * size * 0.4;
1004
- const innerOffY = (rng() - 0.5) * size * 0.4;
1005
- const innerRot = rng() * 360;
1006
- const innerFill = hexWithAlpha(
1007
- jitterColorHSL(pickHierarchyColor(colorHierarchy, rng), rng, 10, 0.1),
1008
- 0.3 + rng() * 0.4,
1009
- );
1010
-
1011
- ctx.globalAlpha = layerOpacity * 0.7;
1012
- enhanceShapeGeneration(
1013
- ctx,
1014
- innerShape,
1015
- finalX + innerOffX,
1016
- finalY + innerOffY,
1017
- {
1018
- fillColor: innerFill,
1019
- strokeColor: hexWithAlpha(strokeColor, 0.5),
1020
- strokeWidth: strokeWidth * 0.6,
1021
- size: innerSize,
1022
- rotation: innerRot,
1023
- proportionType: "GOLDEN_RATIO",
1024
- renderStyle: pickStyleForShape(innerShape, layerRenderStyle, rng) as RenderStyle,
1025
- rng,
1026
- },
1027
- );
1176
+ if (extrasAllowed) {
1177
+ for (let n = 0; n < innerCount; n++) {
1178
+ // Pick inner shape from palette affinities
1179
+ const innerSizeFraction = (size * 0.25) / adjustedMaxSize;
1180
+ const innerShape = pickShapeFromPalette(shapePalette, rng, innerSizeFraction);
1181
+ const innerSize = size * (0.15 + rng() * 0.25);
1182
+ const innerOffX = (rng() - 0.5) * size * 0.4;
1183
+ const innerOffY = (rng() - 0.5) * size * 0.4;
1184
+ const innerRot = rng() * 360;
1185
+ const innerFill = hexWithAlpha(
1186
+ jitterColorHSL(pickHierarchyColor(colorHierarchy, rng), rng, 10, 0.1),
1187
+ 0.3 + rng() * 0.4,
1188
+ );
1189
+
1190
+ let innerStyle = pickStyleForShape(innerShape, layerRenderStyle, rng) as RenderStyle;
1191
+ // Apply clip-heavy cap to nested shapes too
1192
+ if ((innerStyle === "stipple" || innerStyle === "noise-grain") &&
1193
+ clipHeavyCount >= MAX_CLIP_HEAVY_SHAPES) {
1194
+ innerStyle = downgradeRenderStyle(innerStyle);
1195
+ }
1196
+ if (innerStyle === "stipple" || innerStyle === "noise-grain") clipHeavyCount++;
1197
+ ctx.globalAlpha = layerOpacity * 0.7;
1198
+ enhanceShapeGeneration(
1199
+ ctx,
1200
+ innerShape,
1201
+ finalX + innerOffX,
1202
+ finalY + innerOffY,
1203
+ {
1204
+ fillColor: innerFill,
1205
+ strokeColor: hexWithAlpha(strokeColor, 0.5),
1206
+ strokeWidth: strokeWidth * 0.6,
1207
+ size: innerSize,
1208
+ rotation: innerRot,
1209
+ proportionType: "GOLDEN_RATIO",
1210
+ renderStyle: innerStyle,
1211
+ rng,
1212
+ },
1213
+ );
1214
+ extrasSpent += RENDER_STYLE_COST[innerStyle] ?? 1;
1215
+ }
1216
+ } else {
1217
+ // Drain RNG to keep determinism — each nested shape consumes ~8 rng calls
1218
+ for (let n = 0; n < innerCount; n++) {
1219
+ rng(); rng(); rng(); rng(); rng(); rng(); rng(); rng();
1220
+ }
1028
1221
  }
1029
1222
  }
1030
1223
 
@@ -1034,41 +1227,109 @@ export function renderHashArt(
1034
1227
  const constellation = CONSTELLATIONS[Math.floor(rng() * CONSTELLATIONS.length)];
1035
1228
  const members = constellation.build(rng, size);
1036
1229
  const groupRotation = rng() * Math.PI * 2;
1037
- const cosR = Math.cos(groupRotation);
1038
- const sinR = Math.sin(groupRotation);
1039
-
1040
- for (const member of members) {
1041
- // Rotate the group offset by the group rotation
1042
- const mx = finalX + member.dx * cosR - member.dy * sinR;
1043
- const my = finalY + member.dx * sinR + member.dy * cosR;
1044
1230
 
1045
- if (mx < 0 || mx > width || my < 0 || my > height) continue;
1046
-
1047
- const memberFill = hexWithAlpha(
1048
- jitterColorHSL(pickHierarchyColor(colorHierarchy, rng), rng, 8, 0.06),
1049
- fillAlpha * 0.8,
1050
- );
1051
- const memberStroke = enforceContrast(
1052
- jitterColorHSL(strokeBase, rng, 5, 0.04), bgLum,
1053
- );
1231
+ if (extrasAllowed) {
1232
+ const cosR = Math.cos(groupRotation);
1233
+ const sinR = Math.sin(groupRotation);
1234
+
1235
+ for (const member of members) {
1236
+ // Rotate the group offset by the group rotation
1237
+ const mx = finalX + member.dx * cosR - member.dy * sinR;
1238
+ const my = finalY + member.dx * sinR + member.dy * cosR;
1239
+
1240
+ if (mx < 0 || mx > width || my < 0 || my > height) continue;
1241
+
1242
+ const memberFill = hexWithAlpha(
1243
+ jitterColorHSL(pickHierarchyColor(colorHierarchy, rng), rng, 8, 0.06),
1244
+ fillAlpha * 0.8,
1245
+ );
1246
+ const memberStroke = enforceContrast(
1247
+ jitterColorHSL(strokeBase, rng, 5, 0.04), bgLum,
1248
+ );
1249
+
1250
+ ctx.globalAlpha = layerOpacity * 0.6;
1251
+ // Use the member's shape if available, otherwise fall back to palette
1252
+ const memberShape = shapeNames.includes(member.shape)
1253
+ ? member.shape
1254
+ : pickShapeFromPalette(shapePalette, rng, member.size / adjustedMaxSize);
1255
+
1256
+ let memberStyle = pickStyleForShape(memberShape, layerRenderStyle, rng) as RenderStyle;
1257
+ // Apply clip-heavy cap to constellation members too
1258
+ if ((memberStyle === "stipple" || memberStyle === "noise-grain") &&
1259
+ clipHeavyCount >= MAX_CLIP_HEAVY_SHAPES) {
1260
+ memberStyle = downgradeRenderStyle(memberStyle);
1261
+ }
1262
+ if (memberStyle === "stipple" || memberStyle === "noise-grain") clipHeavyCount++;
1263
+ enhanceShapeGeneration(ctx, memberShape, mx, my, {
1264
+ fillColor: memberFill,
1265
+ strokeColor: memberStroke,
1266
+ strokeWidth: strokeWidth * 0.7,
1267
+ size: member.size,
1268
+ rotation: member.rotation + (groupRotation * 180) / Math.PI,
1269
+ proportionType: "GOLDEN_RATIO",
1270
+ renderStyle: memberStyle,
1271
+ rng,
1272
+ });
1273
+ shapePositions.push({ x: mx, y: my, size: member.size, shape: memberShape });
1274
+ spatialGrid.insert({ x: mx, y: my, size: member.size, shape: memberShape });
1275
+ extrasSpent += RENDER_STYLE_COST[memberStyle] ?? 1;
1276
+ }
1277
+ } else {
1278
+ // Drain RNG — each member consumes ~6 rng calls for colors/style
1279
+ for (let m = 0; m < members.length; m++) {
1280
+ rng(); rng(); rng(); rng(); rng(); rng();
1281
+ }
1282
+ }
1283
+ }
1054
1284
 
1055
- ctx.globalAlpha = layerOpacity * 0.6;
1056
- // Use the member's shape if available, otherwise fall back to palette
1057
- const memberShape = shapeNames.includes(member.shape)
1058
- ? member.shape
1059
- : pickShapeFromPalette(shapePalette, rng, member.size / adjustedMaxSize);
1060
-
1061
- enhanceShapeGeneration(ctx, memberShape, mx, my, {
1062
- fillColor: memberFill,
1063
- strokeColor: memberStroke,
1064
- strokeWidth: strokeWidth * 0.7,
1065
- size: member.size,
1066
- rotation: member.rotation + (groupRotation * 180) / Math.PI,
1067
- proportionType: "GOLDEN_RATIO",
1068
- renderStyle: pickStyleForShape(memberShape, layerRenderStyle, rng) as RenderStyle,
1069
- rng,
1070
- });
1071
- shapePositions.push({ x: mx, y: my, size: member.size, shape: memberShape });
1285
+ // ── 5f. Rhythm placement deliberate geometric progressions ──
1286
+ // ~12% of medium-large shapes spawn a rhythmic sequence
1287
+ if (size > adjustedMaxSize * 0.25 && rng() < 0.12) {
1288
+ const rhythmCount = 3 + Math.floor(rng() * 4); // 3-6 shapes
1289
+ const rhythmAngle = rng() * Math.PI * 2;
1290
+ const rhythmSpacing = size * (0.8 + rng() * 0.6);
1291
+ const rhythmDecay = 0.7 + rng() * 0.15; // size multiplier per step
1292
+ const rhythmShape = shape; // same shape for visual rhythm
1293
+
1294
+ if (extrasAllowed) {
1295
+ let rhythmSize = size * 0.6;
1296
+ for (let r = 0; r < rhythmCount; r++) {
1297
+ const rx = finalX + Math.cos(rhythmAngle) * rhythmSpacing * (r + 1);
1298
+ const ry = finalY + Math.sin(rhythmAngle) * rhythmSpacing * (r + 1);
1299
+
1300
+ if (rx < 0 || rx > width || ry < 0 || ry > height) break;
1301
+ if (isInVoidZone(rx, ry, voidZones)) break;
1302
+
1303
+ rhythmSize *= rhythmDecay;
1304
+ if (rhythmSize < adjustedMinSize) break;
1305
+
1306
+ const rhythmAlpha = layerOpacity * (0.6 - r * 0.08);
1307
+ ctx.globalAlpha = Math.max(0.1, rhythmAlpha);
1308
+
1309
+ const rhythmFill = hexWithAlpha(
1310
+ jitterColorHSL(pickHierarchyColor(layerHierarchy, rng), rng, 5, 0.04),
1311
+ fillAlpha * 0.7,
1312
+ );
1313
+
1314
+ enhanceShapeGeneration(ctx, rhythmShape, rx, ry, {
1315
+ fillColor: rhythmFill,
1316
+ strokeColor: hexWithAlpha(strokeColor, 0.5),
1317
+ strokeWidth: strokeWidth * 0.7,
1318
+ size: rhythmSize,
1319
+ rotation: rotation + (r + 1) * 12,
1320
+ proportionType: "GOLDEN_RATIO",
1321
+ renderStyle: finalRenderStyle,
1322
+ rng,
1323
+ });
1324
+ shapePositions.push({ x: rx, y: ry, size: rhythmSize, shape: rhythmShape });
1325
+ spatialGrid.insert({ x: rx, y: ry, size: rhythmSize, shape: rhythmShape });
1326
+ }
1327
+ extrasSpent += rhythmCount * styleCost;
1328
+ } else {
1329
+ // Drain RNG — each rhythm step consumes ~3 rng calls for colors
1330
+ for (let r = 0; r < rhythmCount; r++) {
1331
+ rng(); rng(); rng();
1332
+ }
1072
1333
  }
1073
1334
  }
1074
1335
  }
@@ -1077,7 +1338,7 @@ export function renderHashArt(
1077
1338
  // Reset blend mode for post-processing passes
1078
1339
  ctx.globalCompositeOperation = "source-over";
1079
1340
 
1080
- // ── 5f. Layered masking / cutout portals ───────────────────────
1341
+ // ── 5g. Layered masking / cutout portals ───────────────────────
1081
1342
  // ~18% of images get 1-3 portal windows that paint over foreground
1082
1343
  // with a tinted background wash, creating a "peek through" effect.
1083
1344
  if (rng() < 0.18 && shapePositions.length > 3) {
@@ -1145,15 +1406,30 @@ export function renderHashArt(
1145
1406
 
1146
1407
 
1147
1408
  // ── 6. Flow-line pass — variable color, branching, pressure ────
1409
+ // Optimized: collect all segments into width-quantized buckets, then
1410
+ // render each bucket as a single batched path. This reduces
1411
+ // beginPath/stroke calls from O(segments) to O(buckets).
1148
1412
  const baseFlowLines = 6 + Math.floor(rng() * 10);
1149
1413
  const numFlowLines = Math.round(baseFlowLines * archetype.flowLineMultiplier);
1150
1414
 
1415
+ // Width buckets — 6 buckets cover the taper×pressure range
1416
+ const FLOW_WIDTH_BUCKETS = 6;
1417
+ type FlowSeg = { x1: number; y1: number; x2: number; y2: number; color: string; alpha: number };
1418
+ const flowBuckets: Array<FlowSeg[]> = [];
1419
+ for (let b = 0; b < FLOW_WIDTH_BUCKETS; b++) flowBuckets.push([]);
1420
+ // Track the representative width for each bucket
1421
+ const flowBucketWidths: number[] = new Array(FLOW_WIDTH_BUCKETS);
1422
+
1423
+ // Pre-compute max possible width for bucket assignment
1424
+ let globalMaxFlowWidth = 0;
1425
+
1151
1426
  for (let i = 0; i < numFlowLines; i++) {
1152
1427
  let fx = rng() * width;
1153
1428
  let fy = rng() * height;
1154
1429
  const steps = 30 + Math.floor(rng() * 40);
1155
1430
  const stepLen = (3 + rng() * 5) * scaleFactor;
1156
1431
  const startWidth = (1 + rng() * 3) * scaleFactor;
1432
+ if (startWidth > globalMaxFlowWidth) globalMaxFlowWidth = startWidth;
1157
1433
 
1158
1434
  // Variable color: interpolate between two hierarchy colors along the stroke
1159
1435
  const lineColorStart = enforceContrast(pickHierarchyColor(colorHierarchy, rng), bgLum);
@@ -1173,24 +1449,29 @@ export function renderHashArt(
1173
1449
 
1174
1450
  if (fx < 0 || fx > width || fy < 0 || fy > height) break;
1175
1451
 
1452
+ // Skip segments that pass through void zones
1453
+ if (isInVoidZone(fx, fy, voidZones)) {
1454
+ prevX = fx;
1455
+ prevY = fy;
1456
+ continue;
1457
+ }
1458
+
1176
1459
  const t = s / steps;
1177
- // Taper + pressure
1178
1460
  const taper = 1 - t * 0.8;
1179
1461
  const pressure = 0.6 + 0.4 * Math.sin(t * pressureFreq * Math.PI + pressurePhase);
1180
-
1181
- ctx.globalAlpha = lineAlpha * taper;
1182
- // Interpolate color along stroke
1462
+ const segWidth = startWidth * taper * pressure;
1463
+ const segAlpha = lineAlpha * taper;
1183
1464
  const lineColor = t < 0.5
1184
1465
  ? hexWithAlpha(lineColorStart, 0.4 + t * 0.2)
1185
1466
  : hexWithAlpha(lineColorEnd, 0.4 + (1 - t) * 0.2);
1186
- ctx.strokeStyle = lineColor;
1187
- ctx.lineWidth = startWidth * taper * pressure;
1188
- ctx.lineCap = "round";
1189
1467
 
1190
- ctx.beginPath();
1191
- ctx.moveTo(prevX, prevY);
1192
- ctx.lineTo(fx, fy);
1193
- ctx.stroke();
1468
+ // Quantize width into bucket
1469
+ const bucketIdx = Math.min(
1470
+ FLOW_WIDTH_BUCKETS - 1,
1471
+ Math.floor((segWidth / (globalMaxFlowWidth || 1)) * FLOW_WIDTH_BUCKETS),
1472
+ );
1473
+ flowBuckets[bucketIdx].push({ x1: prevX, y1: prevY, x2: fx, y2: fy, color: lineColor, alpha: segAlpha });
1474
+ flowBucketWidths[bucketIdx] = segWidth;
1194
1475
 
1195
1476
  // Branching: ~12% chance per step to spawn a thinner child stroke
1196
1477
  if (rng() < 0.12 && s > 5 && s < steps - 10) {
@@ -1207,12 +1488,14 @@ export function renderHashArt(
1207
1488
  by += Math.sin(bAngle) * stepLen * 0.8;
1208
1489
  if (bx < 0 || bx > width || by < 0 || by > height) break;
1209
1490
  const bTaper = 1 - (bs / branchSteps) * 0.9;
1210
- ctx.globalAlpha = lineAlpha * taper * bTaper * 0.6;
1211
- ctx.lineWidth = branchWidth * bTaper;
1212
- ctx.beginPath();
1213
- ctx.moveTo(bPrevX, bPrevY);
1214
- ctx.lineTo(bx, by);
1215
- ctx.stroke();
1491
+ const bSegWidth = branchWidth * bTaper;
1492
+ const bAlpha = lineAlpha * taper * bTaper * 0.6;
1493
+ const bBucket = Math.min(
1494
+ FLOW_WIDTH_BUCKETS - 1,
1495
+ Math.floor((bSegWidth / (globalMaxFlowWidth || 1)) * FLOW_WIDTH_BUCKETS),
1496
+ );
1497
+ flowBuckets[bBucket].push({ x1: bPrevX, y1: bPrevY, x2: bx, y2: by, color: lineColor, alpha: bAlpha });
1498
+ flowBucketWidths[bBucket] = bSegWidth;
1216
1499
  bPrevX = bx;
1217
1500
  bPrevY = by;
1218
1501
  }
@@ -1223,14 +1506,61 @@ export function renderHashArt(
1223
1506
  }
1224
1507
  }
1225
1508
 
1509
+ // Render flow line buckets — one batched path per width bucket
1510
+ // Within each bucket, further sub-batch by quantized alpha (4 levels)
1511
+ ctx.lineCap = "round";
1512
+ const FLOW_ALPHA_BUCKETS = 4;
1513
+ for (let wb = 0; wb < FLOW_WIDTH_BUCKETS; wb++) {
1514
+ const segs = flowBuckets[wb];
1515
+ if (segs.length === 0) continue;
1516
+ ctx.lineWidth = flowBucketWidths[wb];
1517
+
1518
+ // Sub-bucket by alpha
1519
+ const alphaSubs: FlowSeg[][] = [];
1520
+ for (let a = 0; a < FLOW_ALPHA_BUCKETS; a++) alphaSubs.push([]);
1521
+ let maxAlpha = 0;
1522
+ for (let j = 0; j < segs.length; j++) {
1523
+ if (segs[j].alpha > maxAlpha) maxAlpha = segs[j].alpha;
1524
+ }
1525
+ for (let j = 0; j < segs.length; j++) {
1526
+ const ai = Math.min(
1527
+ FLOW_ALPHA_BUCKETS - 1,
1528
+ Math.floor((segs[j].alpha / (maxAlpha || 1)) * FLOW_ALPHA_BUCKETS),
1529
+ );
1530
+ alphaSubs[ai].push(segs[j]);
1531
+ }
1532
+
1533
+ for (let ai = 0; ai < FLOW_ALPHA_BUCKETS; ai++) {
1534
+ const sub = alphaSubs[ai];
1535
+ if (sub.length === 0) continue;
1536
+ // Use the median segment's alpha and color as representative
1537
+ const rep = sub[Math.floor(sub.length / 2)];
1538
+ ctx.globalAlpha = rep.alpha;
1539
+ ctx.strokeStyle = rep.color;
1540
+ ctx.beginPath();
1541
+ for (let j = 0; j < sub.length; j++) {
1542
+ ctx.moveTo(sub[j].x1, sub[j].y1);
1543
+ ctx.lineTo(sub[j].x2, sub[j].y2);
1544
+ }
1545
+ ctx.stroke();
1546
+ }
1547
+ }
1548
+
1226
1549
  // ── 6b. Motion/energy lines — short directional bursts ─────────
1550
+ // Optimized: collect all burst segments, then batch by quantized alpha
1227
1551
  const energyArchetypes = ["dense-chaotic", "cosmic", "neon-glow", "bold-graphic"];
1228
1552
  const hasEnergyLines = energyArchetypes.some(a => archetype.name.includes(a)) || rng() < 0.25;
1229
1553
  if (hasEnergyLines && shapePositions.length > 0) {
1230
1554
  const energyCount = 5 + Math.floor(rng() * 10);
1231
1555
  ctx.lineCap = "round";
1556
+
1557
+ // Collect all energy segments with their computed state
1558
+ const ENERGY_ALPHA_BUCKETS = 3;
1559
+ const energyBuckets: Array<Array<{ x1: number; y1: number; x2: number; y2: number; color: string; lw: number }>> = [];
1560
+ for (let b = 0; b < ENERGY_ALPHA_BUCKETS; b++) energyBuckets.push([]);
1561
+ const energyAlphas: number[] = new Array(ENERGY_ALPHA_BUCKETS).fill(0);
1562
+
1232
1563
  for (let e = 0; e < energyCount; e++) {
1233
- // Pick a random shape to radiate from
1234
1564
  const source = shapePositions[Math.floor(rng() * shapePositions.length)];
1235
1565
  const burstCount = 2 + Math.floor(rng() * 4);
1236
1566
  const baseAngle = flowAngle(source.x, source.y);
@@ -1244,16 +1574,34 @@ export function renderHashArt(
1244
1574
  const ex = sx + Math.cos(angle) * lineLen;
1245
1575
  const ey = sy + Math.sin(angle) * lineLen;
1246
1576
 
1247
- ctx.globalAlpha = 0.04 + rng() * 0.06;
1248
- ctx.strokeStyle = hexWithAlpha(
1577
+ const eAlpha = 0.04 + rng() * 0.06;
1578
+ const eColor = hexWithAlpha(
1249
1579
  enforceContrast(pickHierarchyColor(colorHierarchy, rng), bgLum), 0.3,
1250
1580
  );
1251
- ctx.lineWidth = (0.5 + rng() * 1.5) * scaleFactor;
1252
- ctx.beginPath();
1253
- ctx.moveTo(sx, sy);
1254
- ctx.lineTo(ex, ey);
1255
- ctx.stroke();
1581
+ const eLw = (0.5 + rng() * 1.5) * scaleFactor;
1582
+
1583
+ // Quantize alpha into bucket
1584
+ const bi = Math.min(ENERGY_ALPHA_BUCKETS - 1, Math.floor((eAlpha - 0.04) / 0.06 * ENERGY_ALPHA_BUCKETS));
1585
+ energyBuckets[bi].push({ x1: sx, y1: sy, x2: ex, y2: ey, color: eColor, lw: eLw });
1586
+ energyAlphas[bi] = eAlpha;
1587
+ }
1588
+ }
1589
+
1590
+ // Render batched energy lines
1591
+ for (let bi = 0; bi < ENERGY_ALPHA_BUCKETS; bi++) {
1592
+ const segs = energyBuckets[bi];
1593
+ if (segs.length === 0) continue;
1594
+ ctx.globalAlpha = energyAlphas[bi];
1595
+ // Use median segment's color and width as representative
1596
+ const rep = segs[Math.floor(segs.length / 2)];
1597
+ ctx.strokeStyle = rep.color;
1598
+ ctx.lineWidth = rep.lw;
1599
+ ctx.beginPath();
1600
+ for (let j = 0; j < segs.length; j++) {
1601
+ ctx.moveTo(segs[j].x1, segs[j].y1);
1602
+ ctx.lineTo(segs[j].x2, segs[j].y2);
1256
1603
  }
1604
+ ctx.stroke();
1257
1605
  }
1258
1606
  }
1259
1607
 
@@ -1279,34 +1627,96 @@ export function renderHashArt(
1279
1627
  }
1280
1628
 
1281
1629
 
1282
- // ── 7. Noise texture overlay ───────────────────────────────────
1630
+ // ── 7. Noise texture overlay — batched via ImageData ─────────────
1631
+ // Optimized: cap density at large sizes (diminishing returns above ~2K dots),
1632
+ // skip inner pixelScale loop when scale=1, use Uint32Array for faster writes.
1283
1633
  const noiseRng = createRng(seedFromHash(gitHash, 777));
1284
- const noiseDensity = Math.floor((width * height) / 800);
1285
- for (let i = 0; i < noiseDensity; i++) {
1286
- const nx = noiseRng() * width;
1287
- const ny = noiseRng() * height;
1288
- const brightness = noiseRng() > 0.5 ? 255 : 0;
1289
- const alpha = 0.01 + noiseRng() * 0.03;
1290
- ctx.globalAlpha = alpha;
1291
- ctx.fillStyle = `rgba(${brightness},${brightness},${brightness},1)`;
1292
- ctx.fillRect(nx, ny, 1 * scaleFactor, 1 * scaleFactor);
1634
+ const rawNoiseDensity = Math.floor((width * height) / 800);
1635
+ // Cap at 2500 dots beyond this the visual effect is indistinguishable
1636
+ // but getImageData/putImageData cost scales with canvas size
1637
+ const noiseDensity = Math.min(rawNoiseDensity, 2500);
1638
+ try {
1639
+ const imageData = ctx.getImageData(0, 0, width, height);
1640
+ const data = imageData.data;
1641
+ const pixelScale = Math.max(1, Math.round(scaleFactor));
1642
+ if (pixelScale === 1) {
1643
+ // Fast path — no inner loop, direct pixel write
1644
+ // Pre-compute alpha blend as integer math (avoid float multiply per channel)
1645
+ for (let i = 0; i < noiseDensity; i++) {
1646
+ const nx = Math.floor(noiseRng() * width);
1647
+ const ny = Math.floor(noiseRng() * height);
1648
+ const brightness = noiseRng() > 0.5 ? 255 : 0;
1649
+ // srcA in range [0.01, 0.04] — multiply by 256 for fixed-point
1650
+ const srcA256 = Math.round((0.01 + noiseRng() * 0.03) * 256);
1651
+ const invA256 = 256 - srcA256;
1652
+ const bSrc = brightness * srcA256; // pre-multiply brightness × alpha
1653
+ const idx = (ny * width + nx) << 2;
1654
+ data[idx] = (data[idx] * invA256 + bSrc) >> 8;
1655
+ data[idx + 1] = (data[idx + 1] * invA256 + bSrc) >> 8;
1656
+ data[idx + 2] = (data[idx + 2] * invA256 + bSrc) >> 8;
1657
+ }
1658
+ } else {
1659
+ for (let i = 0; i < noiseDensity; i++) {
1660
+ const nx = Math.floor(noiseRng() * width);
1661
+ const ny = Math.floor(noiseRng() * height);
1662
+ const brightness = noiseRng() > 0.5 ? 255 : 0;
1663
+ const srcA256 = Math.round((0.01 + noiseRng() * 0.03) * 256);
1664
+ const invA256 = 256 - srcA256;
1665
+ const bSrc = brightness * srcA256;
1666
+ for (let dy = 0; dy < pixelScale && ny + dy < height; dy++) {
1667
+ for (let dx = 0; dx < pixelScale && nx + dx < width; dx++) {
1668
+ const idx = ((ny + dy) * width + (nx + dx)) << 2;
1669
+ data[idx] = (data[idx] * invA256 + bSrc) >> 8;
1670
+ data[idx + 1] = (data[idx + 1] * invA256 + bSrc) >> 8;
1671
+ data[idx + 2] = (data[idx + 2] * invA256 + bSrc) >> 8;
1672
+ }
1673
+ }
1674
+ }
1675
+ }
1676
+ ctx.putImageData(imageData, 0, 0);
1677
+ } catch {
1678
+ // Fallback for environments where getImageData isn't available (e.g. some OffscreenCanvas)
1679
+ for (let i = 0; i < noiseDensity; i++) {
1680
+ const nx = noiseRng() * width;
1681
+ const ny = noiseRng() * height;
1682
+ const brightness = noiseRng() > 0.5 ? 255 : 0;
1683
+ const alpha = 0.01 + noiseRng() * 0.03;
1684
+ ctx.globalAlpha = alpha;
1685
+ ctx.fillStyle = `rgba(${brightness},${brightness},${brightness},1)`;
1686
+ ctx.fillRect(nx, ny, 1 * scaleFactor, 1 * scaleFactor);
1687
+ }
1293
1688
  }
1294
1689
 
1295
1690
  // ── 8. Vignette — darken edges to draw the eye inward ───────────
1296
1691
  ctx.globalAlpha = 1;
1297
1692
  const vignetteStrength = 0.25 + rng() * 0.2;
1298
1693
  const vigGrad = ctx.createRadialGradient(cx, cy, Math.min(width, height) * 0.3, cx, cy, bgRadius);
1694
+ // Tint vignette based on background: warm sepia for light, cool blue for dark
1695
+ const isLightBg = bgLum > 0.5;
1696
+ const vignetteColor = isLightBg
1697
+ ? `rgba(80,60,30,${vignetteStrength.toFixed(3)})` // warm sepia
1698
+ : `rgba(0,0,0,${vignetteStrength.toFixed(3)})`; // classic dark
1299
1699
  vigGrad.addColorStop(0, "rgba(0,0,0,0)");
1300
1700
  vigGrad.addColorStop(0.6, "rgba(0,0,0,0)");
1301
- vigGrad.addColorStop(1, `rgba(0,0,0,${vignetteStrength.toFixed(3)})`);
1701
+ vigGrad.addColorStop(1, vignetteColor);
1302
1702
  ctx.fillStyle = vigGrad;
1303
1703
  ctx.fillRect(0, 0, width, height);
1304
1704
 
1305
- // ── 9. Organic connecting curves ───────────────────────────────
1705
+ // ── 9. Organic connecting curves — proximity-aware ───────────────
1706
+ // Optimized: batch all curves into alpha-quantized groups to reduce
1707
+ // beginPath/stroke calls from O(numCurves) to O(alphaBuckets).
1306
1708
  if (shapePositions.length > 1) {
1307
1709
  const numCurves = Math.floor((8 * (width * height)) / (1024 * 1024));
1710
+ const maxCurveDist = Math.hypot(width, height) * 0.2; // only connect nearby shapes
1308
1711
  ctx.lineWidth = 0.8 * scaleFactor;
1309
1712
 
1713
+ // Collect curves into 3 alpha buckets
1714
+ const CURVE_ALPHA_BUCKETS = 3;
1715
+ const curveBuckets: Array<Array<{ ax: number; ay: number; cpx: number; cpy: number; bx: number; by: number }>> = [];
1716
+ const curveColors: string[] = [];
1717
+ const curveAlphas: number[] = new Array(CURVE_ALPHA_BUCKETS).fill(0);
1718
+ for (let b = 0; b < CURVE_ALPHA_BUCKETS; b++) curveBuckets.push([]);
1719
+
1310
1720
  for (let i = 0; i < numCurves; i++) {
1311
1721
  const idxA = Math.floor(rng() * shapePositions.length);
1312
1722
  const offset =
@@ -1316,25 +1726,46 @@ export function renderHashArt(
1316
1726
  const a = shapePositions[idxA];
1317
1727
  const b = shapePositions[idxB];
1318
1728
 
1319
- const mx = (a.x + b.x) / 2;
1320
- const my = (a.y + b.y) / 2;
1321
1729
  const dx = b.x - a.x;
1322
1730
  const dy = b.y - a.y;
1323
1731
  const dist = Math.hypot(dx, dy);
1732
+
1733
+ // Skip connections between distant shapes
1734
+ if (dist > maxCurveDist) {
1735
+ continue;
1736
+ }
1737
+
1738
+ const mx = (a.x + b.x) / 2;
1739
+ const my = (a.y + b.y) / 2;
1324
1740
  const bulge = (rng() - 0.5) * dist * 0.4;
1325
1741
 
1326
1742
  const cpx = mx + (-dy / (dist || 1)) * bulge;
1327
1743
  const cpy = my + (dx / (dist || 1)) * bulge;
1328
1744
 
1329
- ctx.globalAlpha = 0.06 + rng() * 0.1;
1330
- ctx.strokeStyle = hexWithAlpha(
1745
+ const curveAlpha = 0.06 + rng() * 0.1;
1746
+ const curveColor = hexWithAlpha(
1331
1747
  enforceContrast(pickHierarchyColor(colorHierarchy, rng), bgLum),
1332
1748
  0.3,
1333
1749
  );
1334
1750
 
1751
+ const bi = Math.min(CURVE_ALPHA_BUCKETS - 1, Math.floor((curveAlpha - 0.06) / 0.1 * CURVE_ALPHA_BUCKETS));
1752
+ curveBuckets[bi].push({ ax: a.x, ay: a.y, cpx, cpy, bx: b.x, by: b.y });
1753
+ curveAlphas[bi] = curveAlpha;
1754
+ if (!curveColors[bi]) curveColors[bi] = curveColor;
1755
+ }
1756
+
1757
+ // Render batched curves
1758
+ for (let bi = 0; bi < CURVE_ALPHA_BUCKETS; bi++) {
1759
+ const curves = curveBuckets[bi];
1760
+ if (curves.length === 0) continue;
1761
+ ctx.globalAlpha = curveAlphas[bi];
1762
+ ctx.strokeStyle = curveColors[bi];
1335
1763
  ctx.beginPath();
1336
- ctx.moveTo(a.x, a.y);
1337
- ctx.quadraticCurveTo(cpx, cpy, b.x, b.y);
1764
+ for (let j = 0; j < curves.length; j++) {
1765
+ const c = curves[j];
1766
+ ctx.moveTo(c.ax, c.ay);
1767
+ ctx.quadraticCurveTo(c.cpx, c.cpy, c.bx, c.by);
1768
+ }
1338
1769
  ctx.stroke();
1339
1770
  }
1340
1771
  }
@@ -1442,12 +1873,15 @@ export function renderHashArt(
1442
1873
  }
1443
1874
  } else if (archName.includes("botanical") || archName.includes("organic") || archName.includes("watercolor")) {
1444
1875
  // Vine tendrils — organic curving lines along edges
1876
+ // Optimized: batch all tendrils into a single path
1445
1877
  ctx.strokeStyle = hexWithAlpha(colorHierarchy.secondary, 0.15);
1446
1878
  ctx.lineWidth = Math.max(0.8, 1.2 * scaleFactor);
1447
1879
  ctx.globalAlpha = 0.12 + borderRng() * 0.08;
1448
1880
  ctx.lineCap = "round";
1449
1881
 
1450
1882
  const tendrilCount = 8 + Math.floor(borderRng() * 8);
1883
+ ctx.beginPath();
1884
+ const leafPositions: Array<{ x: number; y: number; r: number }> = [];
1451
1885
  for (let t = 0; t < tendrilCount; t++) {
1452
1886
  // Start from a random edge point
1453
1887
  const edge = Math.floor(borderRng() * 4);
@@ -1457,7 +1891,6 @@ export function renderHashArt(
1457
1891
  else if (edge === 2) { tx = borderPad; ty = borderRng() * height; }
1458
1892
  else { tx = width - borderPad; ty = borderRng() * height; }
1459
1893
 
1460
- ctx.beginPath();
1461
1894
  ctx.moveTo(tx, ty);
1462
1895
  const segs = 3 + Math.floor(borderRng() * 4);
1463
1896
  for (let s = 0; s < segs; s++) {
@@ -1471,16 +1904,24 @@ export function renderHashArt(
1471
1904
  ty = cpy3;
1472
1905
  ctx.quadraticCurveTo(cpx2, cpy2, tx, ty);
1473
1906
  }
1474
- ctx.stroke();
1475
1907
 
1476
- // Small leaf/dot at tendril end
1908
+ // Collect leaf positions for batch fill
1477
1909
  if (borderRng() < 0.6) {
1478
- ctx.beginPath();
1479
- ctx.arc(tx, ty, borderPad * (0.15 + borderRng() * 0.2), 0, Math.PI * 2);
1480
- ctx.fillStyle = hexWithAlpha(colorHierarchy.secondary, 0.08);
1481
- ctx.fill();
1910
+ leafPositions.push({ x: tx, y: ty, r: borderPad * (0.15 + borderRng() * 0.2) });
1482
1911
  }
1483
1912
  }
1913
+ ctx.stroke();
1914
+
1915
+ // Batch all leaf dots into a single fill
1916
+ if (leafPositions.length > 0) {
1917
+ ctx.fillStyle = hexWithAlpha(colorHierarchy.secondary, 0.08);
1918
+ ctx.beginPath();
1919
+ for (const leaf of leafPositions) {
1920
+ ctx.moveTo(leaf.x + leaf.r, leaf.y);
1921
+ ctx.arc(leaf.x, leaf.y, leaf.r, 0, Math.PI * 2);
1922
+ }
1923
+ ctx.fill();
1924
+ }
1484
1925
  } else if (archName.includes("celestial") || archName.includes("cosmic") || archName.includes("neon")) {
1485
1926
  // Star-studded arcs along edges
1486
1927
  ctx.globalAlpha = 0.1 + borderRng() * 0.08;
@@ -1496,8 +1937,9 @@ export function renderHashArt(
1496
1937
  ctx.arc(cx, height * 1.3, height * 0.6, Math.PI + 0.3, -0.3);
1497
1938
  ctx.stroke();
1498
1939
 
1499
- // Scatter small stars along the border region
1940
+ // Scatter small stars along the border region — batched into single path
1500
1941
  const starCount = 15 + Math.floor(borderRng() * 15);
1942
+ ctx.beginPath();
1501
1943
  for (let s = 0; s < starCount; s++) {
1502
1944
  const edge = Math.floor(borderRng() * 4);
1503
1945
  let sx: number, sy: number;
@@ -1508,7 +1950,6 @@ export function renderHashArt(
1508
1950
 
1509
1951
  const starR = (1 + borderRng() * 2.5) * scaleFactor;
1510
1952
  // 4-point star
1511
- ctx.beginPath();
1512
1953
  for (let p = 0; p < 8; p++) {
1513
1954
  const a = (p / 8) * Math.PI * 2;
1514
1955
  const r = p % 2 === 0 ? starR : starR * 0.4;
@@ -1518,8 +1959,8 @@ export function renderHashArt(
1518
1959
  else ctx.lineTo(px2, py2);
1519
1960
  }
1520
1961
  ctx.closePath();
1521
- ctx.fill();
1522
1962
  }
1963
+ ctx.fill();
1523
1964
  } else if (archName.includes("minimal") || archName.includes("monochrome") || archName.includes("stipple")) {
1524
1965
  // Thin single rule — understated elegance
1525
1966
  ctx.strokeStyle = hexWithAlpha(colorHierarchy.dominant, 0.1);
@@ -1532,13 +1973,31 @@ export function renderHashArt(
1532
1973
  ctx.restore();
1533
1974
  }
1534
1975
 
1535
- // ── 11. Signature mark — unique geometric chop from hash prefix ──
1976
+ // ── 11. Signature mark — placed in the least-dense corner ──────
1536
1977
  {
1537
1978
  const sigRng = createRng(seedFromHash(gitHash, 42));
1538
1979
  const sigSize = Math.min(width, height) * 0.025;
1539
- // Bottom-right corner with padding
1540
- const sigX = width - sigSize * 2.5;
1541
- const sigY = height - sigSize * 2.5;
1980
+ const sigMargin = sigSize * 2.5;
1981
+
1982
+ // Find the corner with the lowest local density
1983
+ const cornerCandidates = [
1984
+ { x: sigMargin, y: sigMargin }, // top-left
1985
+ { x: width - sigMargin, y: sigMargin }, // top-right
1986
+ { x: sigMargin, y: height - sigMargin }, // bottom-left
1987
+ { x: width - sigMargin, y: height - sigMargin }, // bottom-right
1988
+ ];
1989
+ let bestCorner = cornerCandidates[3]; // default: bottom-right
1990
+ let minDensity = Infinity;
1991
+ for (const corner of cornerCandidates) {
1992
+ const density = spatialGrid.countNear(corner.x, corner.y, sigSize * 5);
1993
+ if (density < minDensity) {
1994
+ minDensity = density;
1995
+ bestCorner = corner;
1996
+ }
1997
+ }
1998
+
1999
+ const sigX = bestCorner.x;
2000
+ const sigY = bestCorner.y;
1542
2001
  const sigSegments = 4 + Math.floor(sigRng() * 4); // 4-7 segments
1543
2002
  const sigColor = hexWithAlpha(colorHierarchy.accent, 0.15);
1544
2003