git-hash-art 0.11.0 → 0.13.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 {
@@ -52,6 +51,40 @@ import { createRng, seedFromHash, createSimplexNoise, createFBM } from "./utils"
52
51
  import { DEFAULT_CONFIG, type GenerationConfig } from "../types";
53
52
  import { selectArchetype, type BackgroundStyle, type CompositionMode } from "./archetypes";
54
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
+ }
87
+
55
88
 
56
89
  // ── Shape categories for weighted selection (legacy fallback) ───────
57
90
 
@@ -446,6 +479,15 @@ export function renderHashArt(
446
479
  config: Partial<GenerationConfig> = {},
447
480
  ): void {
448
481
  const finalConfig: GenerationConfig = { ...DEFAULT_CONFIG, ...config };
482
+ const _dt = finalConfig._debugTiming;
483
+ const _t = _dt ? () => performance.now() : undefined;
484
+ let _p = _t ? _t() : 0;
485
+ function _mark(name: string) {
486
+ if (!_dt || !_t) return;
487
+ const now = _t();
488
+ _dt.phases[name] = (now - _p);
489
+ _p = now;
490
+ }
449
491
 
450
492
  const rng = createRng(seedFromHash(gitHash));
451
493
 
@@ -497,13 +539,16 @@ export function renderHashArt(
497
539
  const cx = width / 2;
498
540
  const cy = height / 2;
499
541
 
542
+ _mark("0_setup");
543
+
500
544
  // ── 1. Background ──────────────────────────────────────────────
501
545
  const bgRadius = Math.hypot(cx, cy);
502
546
  drawBackground(ctx, archetype.backgroundStyle, bgStart, bgEnd, width, height, cx, cy, bgRadius, rng, colors);
503
547
 
504
548
  // Gradient mesh overlay — 3-4 color control points for richer backgrounds
549
+ // Use source-over instead of soft-light for cheaper compositing
505
550
  const meshPoints = 3 + Math.floor(rng() * 2);
506
- ctx.globalCompositeOperation = "soft-light";
551
+ ctx.globalAlpha = 1;
507
552
  for (let i = 0; i < meshPoints; i++) {
508
553
  const mx = rng() * width;
509
554
  const my = rng() * height;
@@ -512,104 +557,114 @@ export function renderHashArt(
512
557
  const grad = ctx.createRadialGradient(mx, my, 0, mx, my, mRadius);
513
558
  grad.addColorStop(0, hexWithAlpha(mColor, 0.08 + rng() * 0.06));
514
559
  grad.addColorStop(1, "rgba(0,0,0,0)");
515
- ctx.globalAlpha = 1;
516
560
  ctx.fillStyle = grad;
517
- ctx.fillRect(0, 0, width, height);
561
+ // Clip to gradient bounding box — avoids blending transparent pixels
562
+ const gx = Math.max(0, mx - mRadius);
563
+ const gy = Math.max(0, my - mRadius);
564
+ const gw = Math.min(width, mx + mRadius) - gx;
565
+ const gh = Math.min(height, my + mRadius) - gy;
566
+ ctx.fillRect(gx, gy, gw, gh);
518
567
  }
519
- ctx.globalCompositeOperation = "source-over";
520
568
 
521
569
  // Compute average background luminance for contrast enforcement
522
570
  const bgLum = (luminance(bgStart) + luminance(bgEnd)) / 2;
523
571
 
524
572
  // ── 1b. Layered background — archetype-coherent shapes ─────────
573
+ // Use source-over with pre-multiplied alpha instead of soft-light
574
+ // for much cheaper compositing (soft-light requires per-pixel blend)
525
575
  const bgShapeCount = 3 + Math.floor(rng() * 4);
526
- ctx.globalCompositeOperation = "soft-light";
527
576
  for (let i = 0; i < bgShapeCount; i++) {
528
577
  const bx = rng() * width;
529
578
  const by = rng() * height;
530
579
  const bSize = (width * 0.3 + rng() * width * 0.5);
531
580
  const bColor = pickHierarchyColor(colorHierarchy, rng);
532
- ctx.globalAlpha = 0.03 + rng() * 0.05;
581
+ ctx.globalAlpha = (0.03 + rng() * 0.05) * 0.5; // halved to compensate for source-over vs soft-light
533
582
  ctx.fillStyle = hexWithAlpha(bColor, 0.15);
534
583
  ctx.beginPath();
535
584
  // Use archetype-appropriate background shapes
536
585
  if (archetype.name === "geometric-precision" || archetype.name === "op-art") {
537
- // Rectangular shapes for geometric archetypes
538
586
  ctx.rect(bx - bSize / 2, by - bSize / 2, bSize, bSize * (0.5 + rng() * 0.5));
539
587
  } else {
540
588
  ctx.arc(bx, by, bSize / 2, 0, Math.PI * 2);
541
589
  }
542
590
  ctx.fill();
543
591
  }
544
- // Subtle concentric rings from center
592
+ // Subtle concentric rings from center — batched into single stroke
545
593
  const ringCount = 2 + Math.floor(rng() * 3);
546
594
  ctx.globalAlpha = 0.02 + rng() * 0.03;
547
595
  ctx.strokeStyle = hexWithAlpha(colorHierarchy.dominant, 0.1);
548
596
  ctx.lineWidth = 1 * scaleFactor;
597
+ ctx.beginPath();
549
598
  for (let i = 1; i <= ringCount; i++) {
550
599
  const r = (Math.min(width, height) * 0.15) * i;
551
- ctx.beginPath();
600
+ ctx.moveTo(cx + r, cy);
552
601
  ctx.arc(cx, cy, r, 0, Math.PI * 2);
553
- ctx.stroke();
554
602
  }
555
- ctx.globalCompositeOperation = "source-over";
603
+ ctx.stroke();
556
604
 
557
605
  // ── 1c. Background pattern layer — subtle textured paper ───────
558
606
  const bgPatternRoll = rng();
559
607
  if (bgPatternRoll < 0.6) {
560
608
  ctx.save();
561
- ctx.globalCompositeOperation = "soft-light";
562
609
  const patternOpacity = 0.02 + rng() * 0.04;
563
610
  const patternColor = hexWithAlpha(colorHierarchy.dominant, 0.15);
564
611
 
565
612
  if (bgPatternRoll < 0.2) {
566
- // Dot grid — batched into a single path
567
- const dotSpacing = Math.max(8, Math.min(width, height) * (0.015 + rng() * 0.015));
568
- const dotR = dotSpacing * 0.08;
613
+ // Dot grid — use fillRect instead of arcs (much cheaper, no path building)
614
+ const dotSpacing = Math.max(12, Math.min(width, height) * (0.015 + rng() * 0.015));
615
+ const dotDiam = Math.max(1, Math.round(dotSpacing * 0.16));
569
616
  ctx.globalAlpha = patternOpacity;
570
617
  ctx.fillStyle = patternColor;
571
- ctx.beginPath();
572
- for (let px = 0; px < width; px += dotSpacing) {
573
- for (let py = 0; py < height; py += dotSpacing) {
574
- ctx.moveTo(px + dotR, py);
575
- ctx.arc(px, py, dotR, 0, Math.PI * 2);
618
+ let dotCount = 0;
619
+ for (let px = 0; px < width && dotCount < 2000; px += dotSpacing) {
620
+ for (let py = 0; py < height && dotCount < 2000; py += dotSpacing) {
621
+ ctx.fillRect(px, py, dotDiam, dotDiam);
622
+ dotCount++;
576
623
  }
577
624
  }
578
- ctx.fill();
579
625
  } else if (bgPatternRoll < 0.4) {
580
- // Diagonal lines — batched into a single path
581
- const lineSpacing = Math.max(6, Math.min(width, height) * (0.02 + rng() * 0.02));
626
+ // Diagonal lines — batched into a single path, capped at 300 lines
627
+ const lineSpacing = Math.max(10, Math.min(width, height) * (0.02 + rng() * 0.02));
582
628
  ctx.globalAlpha = patternOpacity;
583
629
  ctx.strokeStyle = patternColor;
584
630
  ctx.lineWidth = 0.5 * scaleFactor;
585
631
  const diag = Math.hypot(width, height);
586
632
  ctx.beginPath();
587
- for (let d = -diag; d < diag; d += lineSpacing) {
633
+ let lineCount = 0;
634
+ for (let d = -diag; d < diag && lineCount < 300; d += lineSpacing) {
588
635
  ctx.moveTo(d, 0);
589
636
  ctx.lineTo(d + height, height);
637
+ lineCount++;
590
638
  }
591
639
  ctx.stroke();
592
640
  } else {
593
- // Tessellation — hexagonal grid, batched into a single path
594
- const tessSize = Math.max(10, Math.min(width, height) * (0.025 + rng() * 0.02));
641
+ // Tessellation — hexagonal grid, capped at 500 hexagons
642
+ const tessSize = Math.max(15, Math.min(width, height) * (0.025 + rng() * 0.02));
595
643
  const tessH = tessSize * Math.sqrt(3);
596
644
  ctx.globalAlpha = patternOpacity * 0.7;
597
645
  ctx.strokeStyle = patternColor;
598
646
  ctx.lineWidth = 0.4 * scaleFactor;
647
+ // Pre-compute hex vertex offsets (avoid trig per vertex)
648
+ const hexVx: number[] = [];
649
+ const hexVy: number[] = [];
650
+ for (let s = 0; s < 6; s++) {
651
+ const angle = (Math.PI / 3) * s - Math.PI / 6;
652
+ hexVx.push(Math.cos(angle) * tessSize * 0.5);
653
+ hexVy.push(Math.sin(angle) * tessSize * 0.5);
654
+ }
599
655
  ctx.beginPath();
600
- for (let row = 0; row * tessH < height + tessH; row++) {
656
+ let hexCount = 0;
657
+ for (let row = 0; row * tessH < height + tessH && hexCount < 500; row++) {
601
658
  const offsetX = (row % 2) * tessSize * 0.75;
602
- for (let col = 0; col * tessSize * 1.5 < width + tessSize * 1.5; col++) {
659
+ for (let col = 0; col * tessSize * 1.5 < width + tessSize * 1.5 && hexCount < 500; col++) {
603
660
  const hx = col * tessSize * 1.5 + offsetX;
604
661
  const hy = row * tessH;
605
- for (let s = 0; s < 6; s++) {
606
- const angle = (Math.PI / 3) * s - Math.PI / 6;
607
- const vx = hx + Math.cos(angle) * tessSize * 0.5;
608
- const vy = hy + Math.sin(angle) * tessSize * 0.5;
609
- if (s === 0) ctx.moveTo(vx, vy);
610
- else ctx.lineTo(vx, vy);
662
+ ctx.moveTo(hx + hexVx[0], hy + hexVy[0]);
663
+ for (let s = 1; s < 6; s++) {
664
+ ctx.lineTo(hx + hexVx[s], hy + hexVy[s]);
611
665
  }
612
666
  ctx.closePath();
667
+ hexCount++;
613
668
  }
614
669
  }
615
670
  ctx.stroke();
@@ -618,6 +673,8 @@ export function renderHashArt(
618
673
  }
619
674
  ctx.globalCompositeOperation = "source-over";
620
675
 
676
+ _mark("1_background");
677
+
621
678
  // ── 2. Composition mode — archetype-aware selection ──────────────
622
679
  const compositionMode: CompositionMode = rng() < 0.7
623
680
  ? archetype.preferredCompositions[Math.floor(rng() * archetype.preferredCompositions.length)]
@@ -707,23 +764,27 @@ export function renderHashArt(
707
764
  ctx.arc(zone.x, zone.y, zone.radius, 0, Math.PI * 2);
708
765
  ctx.stroke();
709
766
 
710
- // ~50% chance: scatter tiny dots inside the void
767
+ // ~50% chance: scatter tiny dots inside the void — batched into single path
711
768
  if (rng() < 0.5) {
712
769
  const dotCount = 3 + Math.floor(rng() * 6);
713
770
  ctx.globalAlpha = 0.06 + rng() * 0.04;
714
771
  ctx.fillStyle = hexWithAlpha(colorHierarchy.secondary, 0.15);
772
+ ctx.beginPath();
715
773
  for (let d = 0; d < dotCount; d++) {
716
774
  const angle = rng() * Math.PI * 2;
717
775
  const dist = rng() * zone.radius * 0.7;
718
776
  const dotR = (1 + rng() * 3) * scaleFactor;
719
- ctx.beginPath();
777
+ ctx.moveTo(
778
+ zone.x + Math.cos(angle) * dist + dotR,
779
+ zone.y + Math.sin(angle) * dist,
780
+ );
720
781
  ctx.arc(
721
782
  zone.x + Math.cos(angle) * dist,
722
783
  zone.y + Math.sin(angle) * dist,
723
784
  dotR, 0, Math.PI * 2,
724
785
  );
725
- ctx.fill();
726
786
  }
787
+ ctx.fill();
727
788
  }
728
789
 
729
790
  // ~30% chance: thin concentric ring inside
@@ -739,6 +800,8 @@ export function renderHashArt(
739
800
  }
740
801
  ctx.globalAlpha = 1;
741
802
 
803
+ _mark("2_3_composition_focal");
804
+
742
805
  // ── 4. Flow field — simplex noise for organic variation ─────────
743
806
  // Create a seeded simplex noise field (unique per hash)
744
807
  const noiseFieldRng = createRng(seedFromHash(gitHash, 333));
@@ -818,9 +881,33 @@ export function renderHashArt(
818
881
  }
819
882
 
820
883
 
884
+ _mark("4_flowfield_hero");
885
+
821
886
  // ── 5. Shape layers ────────────────────────────────────────────
822
887
  const maxLocalDensity = Math.ceil(shapesPerLayer * 0.15);
823
888
 
889
+ // ── Complexity budget — caps total rendering work ──────────────
890
+ // Budget scales with pixel area so larger canvases get proportionally
891
+ // more headroom. The multiplier extras (glazing, echoes, nesting,
892
+ // constellations, rhythm) are gated behind the budget; when it runs
893
+ // low they are skipped. When it's exhausted, expensive render styles
894
+ // are downgraded to cheaper alternatives.
895
+ //
896
+ // RNG values are always consumed even when skipping, so the
897
+ // deterministic sequence for shapes that *do* render is preserved.
898
+ const pixelArea = width * height;
899
+ const BUDGET_PER_MEGAPIXEL = 6000; // cost units per 1M pixels
900
+ let complexityBudget = (pixelArea / 1_000_000) * BUDGET_PER_MEGAPIXEL;
901
+ const totalBudget = complexityBudget;
902
+ const budgetForExtras = complexityBudget * 0.25; // reserve 25% for multiplier extras
903
+ let extrasSpent = 0;
904
+
905
+ // Hard cap on clip-heavy render styles (stipple, noise-grain).
906
+ // These generate O(size²) fillRect calls per shape and dominate
907
+ // worst-case render time. Cap scales with pixel area.
908
+ const MAX_CLIP_HEAVY_SHAPES = Math.max(4, Math.floor(8 * (pixelArea / 1_000_000)));
909
+ let clipHeavyCount = 0;
910
+
824
911
  for (let layer = 0; layer < layers; layer++) {
825
912
  const layerRatio = layers > 1 ? layer / (layers - 1) : 0;
826
913
  const numShapes =
@@ -957,7 +1044,30 @@ export function renderHashArt(
957
1044
 
958
1045
  // Organic edge jitter — applied via watercolor style on ~15% of shapes
959
1046
  const useOrganicEdges = rng() < 0.15 && shapeRenderStyle === "fill-and-stroke";
960
- const finalRenderStyle = useOrganicEdges ? "watercolor" as RenderStyle : shapeRenderStyle;
1047
+ let finalRenderStyle = useOrganicEdges ? "watercolor" as RenderStyle : shapeRenderStyle;
1048
+
1049
+ // Budget check: downgrade expensive styles proportionally —
1050
+ // the more expensive the style, the earlier it gets downgraded.
1051
+ // noise-grain (400) downgrades when budget < 20% remaining,
1052
+ // stipple (90) when < 82%, wood-grain (10) when < 98%.
1053
+ let styleCost = RENDER_STYLE_COST[finalRenderStyle] ?? 1;
1054
+ if (styleCost > 3) {
1055
+ const downgradeThreshold = Math.min(0.85, styleCost / 500);
1056
+ if (complexityBudget < totalBudget * (1 - downgradeThreshold)) {
1057
+ finalRenderStyle = downgradeRenderStyle(finalRenderStyle);
1058
+ styleCost = RENDER_STYLE_COST[finalRenderStyle] ?? 1;
1059
+ }
1060
+ }
1061
+ // Hard cap: clip-heavy styles (stipple, noise-grain) are limited
1062
+ // to MAX_CLIP_HEAVY_SHAPES total across the entire render.
1063
+ if ((finalRenderStyle === "stipple" || finalRenderStyle === "noise-grain") &&
1064
+ clipHeavyCount >= MAX_CLIP_HEAVY_SHAPES) {
1065
+ finalRenderStyle = downgradeRenderStyle(finalRenderStyle);
1066
+ styleCost = RENDER_STYLE_COST[finalRenderStyle] ?? 1;
1067
+ }
1068
+ if (finalRenderStyle === "stipple" || finalRenderStyle === "noise-grain") {
1069
+ clipHeavyCount++;
1070
+ }
961
1071
 
962
1072
  // Consistent light direction — subtle shadow offset
963
1073
  const shadowDist = hasGlow ? 0 : (size * 0.02);
@@ -1016,28 +1126,37 @@ export function renderHashArt(
1016
1126
  mirrorAxis: mirrorAxis!,
1017
1127
  mirrorGap: size * (0.1 + rng() * 0.3),
1018
1128
  });
1129
+ complexityBudget -= styleCost * 2; // mirrored = 2 shapes
1019
1130
  } else {
1020
1131
  enhanceShapeGeneration(ctx, shape, finalX, finalY, shapeConfig);
1132
+ complexityBudget -= styleCost;
1021
1133
  }
1022
1134
 
1135
+ // ── Extras budget gate — skip multiplier sections when over budget ──
1136
+ const extrasAllowed = extrasSpent < budgetForExtras;
1137
+
1023
1138
  // ── Glazing — luminous multi-pass transparency on ~20% of shapes ──
1024
1139
  if (rng() < 0.2 && size > adjustedMinSize * 2) {
1025
1140
  const glazePasses = 2 + Math.floor(rng() * 2);
1026
- for (let g = 0; g < glazePasses; g++) {
1027
- const glazeScale = 1 - (g + 1) * 0.12; // progressively smaller
1028
- const glazeAlpha = 0.08 + g * 0.04; // progressively more opaque toward center
1029
- ctx.globalAlpha = glazeAlpha;
1030
- enhanceShapeGeneration(ctx, shape, finalX, finalY, {
1031
- fillColor: hexWithAlpha(fillColor, 0.15 + g * 0.1),
1032
- strokeColor: "rgba(0,0,0,0)",
1033
- strokeWidth: 0,
1034
- size: size * glazeScale,
1035
- rotation,
1036
- proportionType: "GOLDEN_RATIO",
1037
- renderStyle: "fill-only",
1038
- rng,
1039
- });
1141
+ if (extrasAllowed) {
1142
+ for (let g = 0; g < glazePasses; g++) {
1143
+ const glazeScale = 1 - (g + 1) * 0.12;
1144
+ const glazeAlpha = 0.08 + g * 0.04;
1145
+ ctx.globalAlpha = glazeAlpha;
1146
+ enhanceShapeGeneration(ctx, shape, finalX, finalY, {
1147
+ fillColor: hexWithAlpha(fillColor, 0.15 + g * 0.1),
1148
+ strokeColor: "rgba(0,0,0,0)",
1149
+ strokeWidth: 0,
1150
+ size: size * glazeScale,
1151
+ rotation,
1152
+ proportionType: "GOLDEN_RATIO",
1153
+ renderStyle: "fill-only",
1154
+ rng,
1155
+ });
1156
+ }
1157
+ extrasSpent += glazePasses;
1040
1158
  }
1159
+ // RNG consumed by glazePasses calculation above regardless
1041
1160
  }
1042
1161
 
1043
1162
  shapePositions.push({ x: finalX, y: finalY, size, shape });
@@ -1047,29 +1166,33 @@ export function renderHashArt(
1047
1166
  if (size > adjustedMaxSize * 0.5 && rng() < 0.2) {
1048
1167
  const echoCount = 2 + Math.floor(rng() * 2);
1049
1168
  const echoAngle = rng() * Math.PI * 2;
1050
- for (let e = 0; e < echoCount; e++) {
1051
- const echoScale = 0.3 - e * 0.08;
1052
- const echoDist = size * (0.6 + e * 0.4);
1053
- const echoX = finalX + Math.cos(echoAngle) * echoDist;
1054
- const echoY = finalY + Math.sin(echoAngle) * echoDist;
1055
- const echoSize = size * Math.max(0.1, echoScale);
1056
-
1057
- if (echoX < 0 || echoX > width || echoY < 0 || echoY > height) continue;
1058
-
1059
- ctx.globalAlpha = layerOpacity * (0.4 - e * 0.1);
1060
- enhanceShapeGeneration(ctx, shape, echoX, echoY, {
1061
- fillColor: hexWithAlpha(fillColor, fillAlpha * 0.6),
1062
- strokeColor: hexWithAlpha(strokeColor, 0.4),
1063
- strokeWidth: strokeWidth * 0.6,
1064
- size: echoSize,
1065
- rotation: rotation + (e + 1) * 15,
1066
- proportionType: "GOLDEN_RATIO",
1067
- renderStyle: finalRenderStyle,
1068
- rng,
1069
- });
1070
- shapePositions.push({ x: echoX, y: echoY, size: echoSize, shape });
1071
- spatialGrid.insert({ x: echoX, y: echoY, size: echoSize, shape });
1169
+ if (extrasAllowed) {
1170
+ for (let e = 0; e < echoCount; e++) {
1171
+ const echoScale = 0.3 - e * 0.08;
1172
+ const echoDist = size * (0.6 + e * 0.4);
1173
+ const echoX = finalX + Math.cos(echoAngle) * echoDist;
1174
+ const echoY = finalY + Math.sin(echoAngle) * echoDist;
1175
+ const echoSize = size * Math.max(0.1, echoScale);
1176
+
1177
+ if (echoX < 0 || echoX > width || echoY < 0 || echoY > height) continue;
1178
+
1179
+ ctx.globalAlpha = layerOpacity * (0.4 - e * 0.1);
1180
+ enhanceShapeGeneration(ctx, shape, echoX, echoY, {
1181
+ fillColor: hexWithAlpha(fillColor, fillAlpha * 0.6),
1182
+ strokeColor: hexWithAlpha(strokeColor, 0.4),
1183
+ strokeWidth: strokeWidth * 0.6,
1184
+ size: echoSize,
1185
+ rotation: rotation + (e + 1) * 15,
1186
+ proportionType: "GOLDEN_RATIO",
1187
+ renderStyle: finalRenderStyle,
1188
+ rng,
1189
+ });
1190
+ shapePositions.push({ x: echoX, y: echoY, size: echoSize, shape });
1191
+ spatialGrid.insert({ x: echoX, y: echoY, size: echoSize, shape });
1192
+ }
1193
+ extrasSpent += echoCount * styleCost;
1072
1194
  }
1195
+ // RNG for echoCount + echoAngle consumed above regardless
1073
1196
  }
1074
1197
 
1075
1198
  // ── 5d. Recursive nesting ──────────────────────────────────
@@ -1078,36 +1201,51 @@ export function renderHashArt(
1078
1201
  const nestingChance = 0.15 + focalProximity * 0.15; // 15-30% near focal
1079
1202
  if (size > adjustedMaxSize * 0.4 && rng() < nestingChance) {
1080
1203
  const innerCount = 1 + Math.floor(rng() * 3);
1081
- for (let n = 0; n < innerCount; n++) {
1082
- // Pick inner shape from palette affinities
1083
- const innerSizeFraction = (size * 0.25) / adjustedMaxSize;
1084
- const innerShape = pickShapeFromPalette(shapePalette, rng, innerSizeFraction);
1085
- const innerSize = size * (0.15 + rng() * 0.25);
1086
- const innerOffX = (rng() - 0.5) * size * 0.4;
1087
- const innerOffY = (rng() - 0.5) * size * 0.4;
1088
- const innerRot = rng() * 360;
1089
- const innerFill = hexWithAlpha(
1090
- jitterColorHSL(pickHierarchyColor(colorHierarchy, rng), rng, 10, 0.1),
1091
- 0.3 + rng() * 0.4,
1092
- );
1093
-
1094
- ctx.globalAlpha = layerOpacity * 0.7;
1095
- enhanceShapeGeneration(
1096
- ctx,
1097
- innerShape,
1098
- finalX + innerOffX,
1099
- finalY + innerOffY,
1100
- {
1101
- fillColor: innerFill,
1102
- strokeColor: hexWithAlpha(strokeColor, 0.5),
1103
- strokeWidth: strokeWidth * 0.6,
1104
- size: innerSize,
1105
- rotation: innerRot,
1106
- proportionType: "GOLDEN_RATIO",
1107
- renderStyle: pickStyleForShape(innerShape, layerRenderStyle, rng) as RenderStyle,
1108
- rng,
1109
- },
1110
- );
1204
+ if (extrasAllowed) {
1205
+ for (let n = 0; n < innerCount; n++) {
1206
+ // Pick inner shape from palette affinities
1207
+ const innerSizeFraction = (size * 0.25) / adjustedMaxSize;
1208
+ const innerShape = pickShapeFromPalette(shapePalette, rng, innerSizeFraction);
1209
+ const innerSize = size * (0.15 + rng() * 0.25);
1210
+ const innerOffX = (rng() - 0.5) * size * 0.4;
1211
+ const innerOffY = (rng() - 0.5) * size * 0.4;
1212
+ const innerRot = rng() * 360;
1213
+ const innerFill = hexWithAlpha(
1214
+ jitterColorHSL(pickHierarchyColor(colorHierarchy, rng), rng, 10, 0.1),
1215
+ 0.3 + rng() * 0.4,
1216
+ );
1217
+
1218
+ let innerStyle = pickStyleForShape(innerShape, layerRenderStyle, rng) as RenderStyle;
1219
+ // Apply clip-heavy cap to nested shapes too
1220
+ if ((innerStyle === "stipple" || innerStyle === "noise-grain") &&
1221
+ clipHeavyCount >= MAX_CLIP_HEAVY_SHAPES) {
1222
+ innerStyle = downgradeRenderStyle(innerStyle);
1223
+ }
1224
+ if (innerStyle === "stipple" || innerStyle === "noise-grain") clipHeavyCount++;
1225
+ ctx.globalAlpha = layerOpacity * 0.7;
1226
+ enhanceShapeGeneration(
1227
+ ctx,
1228
+ innerShape,
1229
+ finalX + innerOffX,
1230
+ finalY + innerOffY,
1231
+ {
1232
+ fillColor: innerFill,
1233
+ strokeColor: hexWithAlpha(strokeColor, 0.5),
1234
+ strokeWidth: strokeWidth * 0.6,
1235
+ size: innerSize,
1236
+ rotation: innerRot,
1237
+ proportionType: "GOLDEN_RATIO",
1238
+ renderStyle: innerStyle,
1239
+ rng,
1240
+ },
1241
+ );
1242
+ extrasSpent += RENDER_STYLE_COST[innerStyle] ?? 1;
1243
+ }
1244
+ } else {
1245
+ // Drain RNG to keep determinism — each nested shape consumes ~8 rng calls
1246
+ for (let n = 0; n < innerCount; n++) {
1247
+ rng(); rng(); rng(); rng(); rng(); rng(); rng(); rng();
1248
+ }
1111
1249
  }
1112
1250
  }
1113
1251
 
@@ -1117,42 +1255,58 @@ export function renderHashArt(
1117
1255
  const constellation = CONSTELLATIONS[Math.floor(rng() * CONSTELLATIONS.length)];
1118
1256
  const members = constellation.build(rng, size);
1119
1257
  const groupRotation = rng() * Math.PI * 2;
1120
- const cosR = Math.cos(groupRotation);
1121
- const sinR = Math.sin(groupRotation);
1122
-
1123
- for (const member of members) {
1124
- // Rotate the group offset by the group rotation
1125
- const mx = finalX + member.dx * cosR - member.dy * sinR;
1126
- const my = finalY + member.dx * sinR + member.dy * cosR;
1127
-
1128
- if (mx < 0 || mx > width || my < 0 || my > height) continue;
1129
-
1130
- const memberFill = hexWithAlpha(
1131
- jitterColorHSL(pickHierarchyColor(colorHierarchy, rng), rng, 8, 0.06),
1132
- fillAlpha * 0.8,
1133
- );
1134
- const memberStroke = enforceContrast(
1135
- jitterColorHSL(strokeBase, rng, 5, 0.04), bgLum,
1136
- );
1137
1258
 
1138
- ctx.globalAlpha = layerOpacity * 0.6;
1139
- // Use the member's shape if available, otherwise fall back to palette
1140
- const memberShape = shapeNames.includes(member.shape)
1141
- ? member.shape
1142
- : pickShapeFromPalette(shapePalette, rng, member.size / adjustedMaxSize);
1143
-
1144
- enhanceShapeGeneration(ctx, memberShape, mx, my, {
1145
- fillColor: memberFill,
1146
- strokeColor: memberStroke,
1147
- strokeWidth: strokeWidth * 0.7,
1148
- size: member.size,
1149
- rotation: member.rotation + (groupRotation * 180) / Math.PI,
1150
- proportionType: "GOLDEN_RATIO",
1151
- renderStyle: pickStyleForShape(memberShape, layerRenderStyle, rng) as RenderStyle,
1152
- rng,
1153
- });
1154
- shapePositions.push({ x: mx, y: my, size: member.size, shape: memberShape });
1155
- spatialGrid.insert({ x: mx, y: my, size: member.size, shape: memberShape });
1259
+ if (extrasAllowed) {
1260
+ const cosR = Math.cos(groupRotation);
1261
+ const sinR = Math.sin(groupRotation);
1262
+
1263
+ for (const member of members) {
1264
+ // Rotate the group offset by the group rotation
1265
+ const mx = finalX + member.dx * cosR - member.dy * sinR;
1266
+ const my = finalY + member.dx * sinR + member.dy * cosR;
1267
+
1268
+ if (mx < 0 || mx > width || my < 0 || my > height) continue;
1269
+
1270
+ const memberFill = hexWithAlpha(
1271
+ jitterColorHSL(pickHierarchyColor(colorHierarchy, rng), rng, 8, 0.06),
1272
+ fillAlpha * 0.8,
1273
+ );
1274
+ const memberStroke = enforceContrast(
1275
+ jitterColorHSL(strokeBase, rng, 5, 0.04), bgLum,
1276
+ );
1277
+
1278
+ ctx.globalAlpha = layerOpacity * 0.6;
1279
+ // Use the member's shape if available, otherwise fall back to palette
1280
+ const memberShape = shapeNames.includes(member.shape)
1281
+ ? member.shape
1282
+ : pickShapeFromPalette(shapePalette, rng, member.size / adjustedMaxSize);
1283
+
1284
+ let memberStyle = pickStyleForShape(memberShape, layerRenderStyle, rng) as RenderStyle;
1285
+ // Apply clip-heavy cap to constellation members too
1286
+ if ((memberStyle === "stipple" || memberStyle === "noise-grain") &&
1287
+ clipHeavyCount >= MAX_CLIP_HEAVY_SHAPES) {
1288
+ memberStyle = downgradeRenderStyle(memberStyle);
1289
+ }
1290
+ if (memberStyle === "stipple" || memberStyle === "noise-grain") clipHeavyCount++;
1291
+ enhanceShapeGeneration(ctx, memberShape, mx, my, {
1292
+ fillColor: memberFill,
1293
+ strokeColor: memberStroke,
1294
+ strokeWidth: strokeWidth * 0.7,
1295
+ size: member.size,
1296
+ rotation: member.rotation + (groupRotation * 180) / Math.PI,
1297
+ proportionType: "GOLDEN_RATIO",
1298
+ renderStyle: memberStyle,
1299
+ rng,
1300
+ });
1301
+ shapePositions.push({ x: mx, y: my, size: member.size, shape: memberShape });
1302
+ spatialGrid.insert({ x: mx, y: my, size: member.size, shape: memberShape });
1303
+ extrasSpent += RENDER_STYLE_COST[memberStyle] ?? 1;
1304
+ }
1305
+ } else {
1306
+ // Drain RNG — each member consumes ~6 rng calls for colors/style
1307
+ for (let m = 0; m < members.length; m++) {
1308
+ rng(); rng(); rng(); rng(); rng(); rng();
1309
+ }
1156
1310
  }
1157
1311
  }
1158
1312
 
@@ -1165,37 +1319,45 @@ export function renderHashArt(
1165
1319
  const rhythmDecay = 0.7 + rng() * 0.15; // size multiplier per step
1166
1320
  const rhythmShape = shape; // same shape for visual rhythm
1167
1321
 
1168
- let rhythmSize = size * 0.6;
1169
- for (let r = 0; r < rhythmCount; r++) {
1170
- const rx = finalX + Math.cos(rhythmAngle) * rhythmSpacing * (r + 1);
1171
- const ry = finalY + Math.sin(rhythmAngle) * rhythmSpacing * (r + 1);
1322
+ if (extrasAllowed) {
1323
+ let rhythmSize = size * 0.6;
1324
+ for (let r = 0; r < rhythmCount; r++) {
1325
+ const rx = finalX + Math.cos(rhythmAngle) * rhythmSpacing * (r + 1);
1326
+ const ry = finalY + Math.sin(rhythmAngle) * rhythmSpacing * (r + 1);
1172
1327
 
1173
- if (rx < 0 || rx > width || ry < 0 || ry > height) break;
1174
- if (isInVoidZone(rx, ry, voidZones)) break;
1328
+ if (rx < 0 || rx > width || ry < 0 || ry > height) break;
1329
+ if (isInVoidZone(rx, ry, voidZones)) break;
1175
1330
 
1176
- rhythmSize *= rhythmDecay;
1177
- if (rhythmSize < adjustedMinSize) break;
1331
+ rhythmSize *= rhythmDecay;
1332
+ if (rhythmSize < adjustedMinSize) break;
1178
1333
 
1179
- const rhythmAlpha = layerOpacity * (0.6 - r * 0.08);
1180
- ctx.globalAlpha = Math.max(0.1, rhythmAlpha);
1334
+ const rhythmAlpha = layerOpacity * (0.6 - r * 0.08);
1335
+ ctx.globalAlpha = Math.max(0.1, rhythmAlpha);
1181
1336
 
1182
- const rhythmFill = hexWithAlpha(
1183
- jitterColorHSL(pickHierarchyColor(layerHierarchy, rng), rng, 5, 0.04),
1184
- fillAlpha * 0.7,
1185
- );
1337
+ const rhythmFill = hexWithAlpha(
1338
+ jitterColorHSL(pickHierarchyColor(layerHierarchy, rng), rng, 5, 0.04),
1339
+ fillAlpha * 0.7,
1340
+ );
1186
1341
 
1187
- enhanceShapeGeneration(ctx, rhythmShape, rx, ry, {
1188
- fillColor: rhythmFill,
1189
- strokeColor: hexWithAlpha(strokeColor, 0.5),
1190
- strokeWidth: strokeWidth * 0.7,
1191
- size: rhythmSize,
1192
- rotation: rotation + (r + 1) * 12,
1193
- proportionType: "GOLDEN_RATIO",
1194
- renderStyle: finalRenderStyle,
1195
- rng,
1196
- });
1197
- shapePositions.push({ x: rx, y: ry, size: rhythmSize, shape: rhythmShape });
1198
- spatialGrid.insert({ x: rx, y: ry, size: rhythmSize, shape: rhythmShape });
1342
+ enhanceShapeGeneration(ctx, rhythmShape, rx, ry, {
1343
+ fillColor: rhythmFill,
1344
+ strokeColor: hexWithAlpha(strokeColor, 0.5),
1345
+ strokeWidth: strokeWidth * 0.7,
1346
+ size: rhythmSize,
1347
+ rotation: rotation + (r + 1) * 12,
1348
+ proportionType: "GOLDEN_RATIO",
1349
+ renderStyle: finalRenderStyle,
1350
+ rng,
1351
+ });
1352
+ shapePositions.push({ x: rx, y: ry, size: rhythmSize, shape: rhythmShape });
1353
+ spatialGrid.insert({ x: rx, y: ry, size: rhythmSize, shape: rhythmShape });
1354
+ }
1355
+ extrasSpent += rhythmCount * styleCost;
1356
+ } else {
1357
+ // Drain RNG — each rhythm step consumes ~3 rng calls for colors
1358
+ for (let r = 0; r < rhythmCount; r++) {
1359
+ rng(); rng(); rng();
1360
+ }
1199
1361
  }
1200
1362
  }
1201
1363
  }
@@ -1203,6 +1365,8 @@ export function renderHashArt(
1203
1365
 
1204
1366
  // Reset blend mode for post-processing passes
1205
1367
  ctx.globalCompositeOperation = "source-over";
1368
+ if (_dt) { _dt.shapeCount = shapePositions.length; _dt.extraCount = extrasSpent; }
1369
+ _mark("5_shape_layers");
1206
1370
 
1207
1371
  // ── 5g. Layered masking / cutout portals ───────────────────────
1208
1372
  // ~18% of images get 1-3 portal windows that paint over foreground
@@ -1271,16 +1435,33 @@ export function renderHashArt(
1271
1435
  }
1272
1436
 
1273
1437
 
1438
+ _mark("5g_portals");
1439
+
1274
1440
  // ── 6. Flow-line pass — variable color, branching, pressure ────
1441
+ // Optimized: collect all segments into width-quantized buckets, then
1442
+ // render each bucket as a single batched path. This reduces
1443
+ // beginPath/stroke calls from O(segments) to O(buckets).
1275
1444
  const baseFlowLines = 6 + Math.floor(rng() * 10);
1276
1445
  const numFlowLines = Math.round(baseFlowLines * archetype.flowLineMultiplier);
1277
1446
 
1447
+ // Width buckets — 6 buckets cover the taper×pressure range
1448
+ const FLOW_WIDTH_BUCKETS = 6;
1449
+ type FlowSeg = { x1: number; y1: number; x2: number; y2: number; color: string; alpha: number };
1450
+ const flowBuckets: Array<FlowSeg[]> = [];
1451
+ for (let b = 0; b < FLOW_WIDTH_BUCKETS; b++) flowBuckets.push([]);
1452
+ // Track the representative width for each bucket
1453
+ const flowBucketWidths: number[] = new Array(FLOW_WIDTH_BUCKETS);
1454
+
1455
+ // Pre-compute max possible width for bucket assignment
1456
+ let globalMaxFlowWidth = 0;
1457
+
1278
1458
  for (let i = 0; i < numFlowLines; i++) {
1279
1459
  let fx = rng() * width;
1280
1460
  let fy = rng() * height;
1281
1461
  const steps = 30 + Math.floor(rng() * 40);
1282
1462
  const stepLen = (3 + rng() * 5) * scaleFactor;
1283
1463
  const startWidth = (1 + rng() * 3) * scaleFactor;
1464
+ if (startWidth > globalMaxFlowWidth) globalMaxFlowWidth = startWidth;
1284
1465
 
1285
1466
  // Variable color: interpolate between two hierarchy colors along the stroke
1286
1467
  const lineColorStart = enforceContrast(pickHierarchyColor(colorHierarchy, rng), bgLum);
@@ -1308,23 +1489,21 @@ export function renderHashArt(
1308
1489
  }
1309
1490
 
1310
1491
  const t = s / steps;
1311
- // Taper + pressure
1312
1492
  const taper = 1 - t * 0.8;
1313
1493
  const pressure = 0.6 + 0.4 * Math.sin(t * pressureFreq * Math.PI + pressurePhase);
1314
-
1315
- ctx.globalAlpha = lineAlpha * taper;
1316
- // Interpolate color along stroke
1494
+ const segWidth = startWidth * taper * pressure;
1495
+ const segAlpha = lineAlpha * taper;
1317
1496
  const lineColor = t < 0.5
1318
1497
  ? hexWithAlpha(lineColorStart, 0.4 + t * 0.2)
1319
1498
  : hexWithAlpha(lineColorEnd, 0.4 + (1 - t) * 0.2);
1320
- ctx.strokeStyle = lineColor;
1321
- ctx.lineWidth = startWidth * taper * pressure;
1322
- ctx.lineCap = "round";
1323
1499
 
1324
- ctx.beginPath();
1325
- ctx.moveTo(prevX, prevY);
1326
- ctx.lineTo(fx, fy);
1327
- ctx.stroke();
1500
+ // Quantize width into bucket
1501
+ const bucketIdx = Math.min(
1502
+ FLOW_WIDTH_BUCKETS - 1,
1503
+ Math.floor((segWidth / (globalMaxFlowWidth || 1)) * FLOW_WIDTH_BUCKETS),
1504
+ );
1505
+ flowBuckets[bucketIdx].push({ x1: prevX, y1: prevY, x2: fx, y2: fy, color: lineColor, alpha: segAlpha });
1506
+ flowBucketWidths[bucketIdx] = segWidth;
1328
1507
 
1329
1508
  // Branching: ~12% chance per step to spawn a thinner child stroke
1330
1509
  if (rng() < 0.12 && s > 5 && s < steps - 10) {
@@ -1341,12 +1520,14 @@ export function renderHashArt(
1341
1520
  by += Math.sin(bAngle) * stepLen * 0.8;
1342
1521
  if (bx < 0 || bx > width || by < 0 || by > height) break;
1343
1522
  const bTaper = 1 - (bs / branchSteps) * 0.9;
1344
- ctx.globalAlpha = lineAlpha * taper * bTaper * 0.6;
1345
- ctx.lineWidth = branchWidth * bTaper;
1346
- ctx.beginPath();
1347
- ctx.moveTo(bPrevX, bPrevY);
1348
- ctx.lineTo(bx, by);
1349
- ctx.stroke();
1523
+ const bSegWidth = branchWidth * bTaper;
1524
+ const bAlpha = lineAlpha * taper * bTaper * 0.6;
1525
+ const bBucket = Math.min(
1526
+ FLOW_WIDTH_BUCKETS - 1,
1527
+ Math.floor((bSegWidth / (globalMaxFlowWidth || 1)) * FLOW_WIDTH_BUCKETS),
1528
+ );
1529
+ flowBuckets[bBucket].push({ x1: bPrevX, y1: bPrevY, x2: bx, y2: by, color: lineColor, alpha: bAlpha });
1530
+ flowBucketWidths[bBucket] = bSegWidth;
1350
1531
  bPrevX = bx;
1351
1532
  bPrevY = by;
1352
1533
  }
@@ -1357,14 +1538,63 @@ export function renderHashArt(
1357
1538
  }
1358
1539
  }
1359
1540
 
1541
+ // Render flow line buckets — one batched path per width bucket
1542
+ // Within each bucket, further sub-batch by quantized alpha (4 levels)
1543
+ ctx.lineCap = "round";
1544
+ const FLOW_ALPHA_BUCKETS = 4;
1545
+ for (let wb = 0; wb < FLOW_WIDTH_BUCKETS; wb++) {
1546
+ const segs = flowBuckets[wb];
1547
+ if (segs.length === 0) continue;
1548
+ ctx.lineWidth = flowBucketWidths[wb];
1549
+
1550
+ // Sub-bucket by alpha
1551
+ const alphaSubs: FlowSeg[][] = [];
1552
+ for (let a = 0; a < FLOW_ALPHA_BUCKETS; a++) alphaSubs.push([]);
1553
+ let maxAlpha = 0;
1554
+ for (let j = 0; j < segs.length; j++) {
1555
+ if (segs[j].alpha > maxAlpha) maxAlpha = segs[j].alpha;
1556
+ }
1557
+ for (let j = 0; j < segs.length; j++) {
1558
+ const ai = Math.min(
1559
+ FLOW_ALPHA_BUCKETS - 1,
1560
+ Math.floor((segs[j].alpha / (maxAlpha || 1)) * FLOW_ALPHA_BUCKETS),
1561
+ );
1562
+ alphaSubs[ai].push(segs[j]);
1563
+ }
1564
+
1565
+ for (let ai = 0; ai < FLOW_ALPHA_BUCKETS; ai++) {
1566
+ const sub = alphaSubs[ai];
1567
+ if (sub.length === 0) continue;
1568
+ // Use the median segment's alpha and color as representative
1569
+ const rep = sub[Math.floor(sub.length / 2)];
1570
+ ctx.globalAlpha = rep.alpha;
1571
+ ctx.strokeStyle = rep.color;
1572
+ ctx.beginPath();
1573
+ for (let j = 0; j < sub.length; j++) {
1574
+ ctx.moveTo(sub[j].x1, sub[j].y1);
1575
+ ctx.lineTo(sub[j].x2, sub[j].y2);
1576
+ }
1577
+ ctx.stroke();
1578
+ }
1579
+ }
1580
+
1581
+ _mark("6_flow_lines");
1582
+
1360
1583
  // ── 6b. Motion/energy lines — short directional bursts ─────────
1584
+ // Optimized: collect all burst segments, then batch by quantized alpha
1361
1585
  const energyArchetypes = ["dense-chaotic", "cosmic", "neon-glow", "bold-graphic"];
1362
1586
  const hasEnergyLines = energyArchetypes.some(a => archetype.name.includes(a)) || rng() < 0.25;
1363
1587
  if (hasEnergyLines && shapePositions.length > 0) {
1364
1588
  const energyCount = 5 + Math.floor(rng() * 10);
1365
1589
  ctx.lineCap = "round";
1590
+
1591
+ // Collect all energy segments with their computed state
1592
+ const ENERGY_ALPHA_BUCKETS = 3;
1593
+ const energyBuckets: Array<Array<{ x1: number; y1: number; x2: number; y2: number; color: string; lw: number }>> = [];
1594
+ for (let b = 0; b < ENERGY_ALPHA_BUCKETS; b++) energyBuckets.push([]);
1595
+ const energyAlphas: number[] = new Array(ENERGY_ALPHA_BUCKETS).fill(0);
1596
+
1366
1597
  for (let e = 0; e < energyCount; e++) {
1367
- // Pick a random shape to radiate from
1368
1598
  const source = shapePositions[Math.floor(rng() * shapePositions.length)];
1369
1599
  const burstCount = 2 + Math.floor(rng() * 4);
1370
1600
  const baseAngle = flowAngle(source.x, source.y);
@@ -1378,19 +1608,39 @@ export function renderHashArt(
1378
1608
  const ex = sx + Math.cos(angle) * lineLen;
1379
1609
  const ey = sy + Math.sin(angle) * lineLen;
1380
1610
 
1381
- ctx.globalAlpha = 0.04 + rng() * 0.06;
1382
- ctx.strokeStyle = hexWithAlpha(
1611
+ const eAlpha = 0.04 + rng() * 0.06;
1612
+ const eColor = hexWithAlpha(
1383
1613
  enforceContrast(pickHierarchyColor(colorHierarchy, rng), bgLum), 0.3,
1384
1614
  );
1385
- ctx.lineWidth = (0.5 + rng() * 1.5) * scaleFactor;
1386
- ctx.beginPath();
1387
- ctx.moveTo(sx, sy);
1388
- ctx.lineTo(ex, ey);
1389
- ctx.stroke();
1615
+ const eLw = (0.5 + rng() * 1.5) * scaleFactor;
1616
+
1617
+ // Quantize alpha into bucket
1618
+ const bi = Math.min(ENERGY_ALPHA_BUCKETS - 1, Math.floor((eAlpha - 0.04) / 0.06 * ENERGY_ALPHA_BUCKETS));
1619
+ energyBuckets[bi].push({ x1: sx, y1: sy, x2: ex, y2: ey, color: eColor, lw: eLw });
1620
+ energyAlphas[bi] = eAlpha;
1621
+ }
1622
+ }
1623
+
1624
+ // Render batched energy lines
1625
+ for (let bi = 0; bi < ENERGY_ALPHA_BUCKETS; bi++) {
1626
+ const segs = energyBuckets[bi];
1627
+ if (segs.length === 0) continue;
1628
+ ctx.globalAlpha = energyAlphas[bi];
1629
+ // Use median segment's color and width as representative
1630
+ const rep = segs[Math.floor(segs.length / 2)];
1631
+ ctx.strokeStyle = rep.color;
1632
+ ctx.lineWidth = rep.lw;
1633
+ ctx.beginPath();
1634
+ for (let j = 0; j < segs.length; j++) {
1635
+ ctx.moveTo(segs[j].x1, segs[j].y1);
1636
+ ctx.lineTo(segs[j].x2, segs[j].y2);
1390
1637
  }
1638
+ ctx.stroke();
1391
1639
  }
1392
1640
  }
1393
1641
 
1642
+ _mark("6b_energy_lines");
1643
+
1394
1644
  // ── 6c. Apply symmetry mirroring ─────────────────────────────────
1395
1645
  if (symmetryMode !== "none") {
1396
1646
  const canvas = ctx.canvas;
@@ -1413,46 +1663,28 @@ export function renderHashArt(
1413
1663
  }
1414
1664
 
1415
1665
 
1416
- // ── 7. Noise texture overlay — batched via ImageData ─────────────
1666
+ _mark("6c_symmetry");
1667
+
1668
+ // ── 7. Noise texture overlay ─────────────────────────────────────
1669
+ // With density capped at 2500 dots, direct fillRect calls are far cheaper
1670
+ // than the getImageData/putImageData round-trip which copies the entire
1671
+ // pixel buffer (4 × width × height bytes) twice.
1417
1672
  const noiseRng = createRng(seedFromHash(gitHash, 777));
1418
- const noiseDensity = Math.floor((width * height) / 800);
1419
- try {
1420
- const imageData = ctx.getImageData(0, 0, width, height);
1421
- const data = imageData.data;
1422
- const pixelScale = Math.max(1, Math.round(scaleFactor));
1423
- for (let i = 0; i < noiseDensity; i++) {
1424
- const nx = Math.floor(noiseRng() * width);
1425
- const ny = Math.floor(noiseRng() * height);
1426
- const brightness = noiseRng() > 0.5 ? 255 : 0;
1427
- const alpha = Math.floor((0.01 + noiseRng() * 0.03) * 255);
1428
- // Write a small block of pixels for scale
1429
- for (let dy = 0; dy < pixelScale && ny + dy < height; dy++) {
1430
- for (let dx = 0; dx < pixelScale && nx + dx < width; dx++) {
1431
- const idx = ((ny + dy) * width + (nx + dx)) * 4;
1432
- // Alpha-blend the noise dot onto existing pixel data
1433
- const srcA = alpha / 255;
1434
- const invA = 1 - srcA;
1435
- data[idx] = Math.round(data[idx] * invA + brightness * srcA);
1436
- data[idx + 1] = Math.round(data[idx + 1] * invA + brightness * srcA);
1437
- data[idx + 2] = Math.round(data[idx + 2] * invA + brightness * srcA);
1438
- // Keep existing alpha
1439
- }
1440
- }
1441
- }
1442
- ctx.putImageData(imageData, 0, 0);
1443
- } catch {
1444
- // Fallback for environments where getImageData isn't available (e.g. some OffscreenCanvas)
1445
- for (let i = 0; i < noiseDensity; i++) {
1446
- const nx = noiseRng() * width;
1447
- const ny = noiseRng() * height;
1448
- const brightness = noiseRng() > 0.5 ? 255 : 0;
1449
- const alpha = 0.01 + noiseRng() * 0.03;
1450
- ctx.globalAlpha = alpha;
1451
- ctx.fillStyle = `rgba(${brightness},${brightness},${brightness},1)`;
1452
- ctx.fillRect(nx, ny, 1 * scaleFactor, 1 * scaleFactor);
1453
- }
1673
+ const rawNoiseDensity = Math.floor((width * height) / 800);
1674
+ const noiseDensity = Math.min(rawNoiseDensity, 2500);
1675
+ const pixelScale = Math.max(1, Math.round(scaleFactor));
1676
+ for (let i = 0; i < noiseDensity; i++) {
1677
+ const nx = noiseRng() * width;
1678
+ const ny = noiseRng() * height;
1679
+ const brightness = noiseRng() > 0.5 ? 255 : 0;
1680
+ const alpha = 0.01 + noiseRng() * 0.03;
1681
+ ctx.globalAlpha = alpha;
1682
+ ctx.fillStyle = `rgb(${brightness},${brightness},${brightness})`;
1683
+ ctx.fillRect(nx, ny, pixelScale, pixelScale);
1454
1684
  }
1455
1685
 
1686
+ _mark("7_noise_texture");
1687
+
1456
1688
  // ── 8. Vignette — darken edges to draw the eye inward ───────────
1457
1689
  ctx.globalAlpha = 1;
1458
1690
  const vignetteStrength = 0.25 + rng() * 0.2;
@@ -1468,12 +1700,23 @@ export function renderHashArt(
1468
1700
  ctx.fillStyle = vigGrad;
1469
1701
  ctx.fillRect(0, 0, width, height);
1470
1702
 
1703
+ _mark("8_vignette");
1704
+
1471
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).
1472
1708
  if (shapePositions.length > 1) {
1473
1709
  const numCurves = Math.floor((8 * (width * height)) / (1024 * 1024));
1474
1710
  const maxCurveDist = Math.hypot(width, height) * 0.2; // only connect nearby shapes
1475
1711
  ctx.lineWidth = 0.8 * scaleFactor;
1476
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
+
1477
1720
  for (let i = 0; i < numCurves; i++) {
1478
1721
  const idxA = Math.floor(rng() * shapePositions.length);
1479
1722
  const offset =
@@ -1488,7 +1731,9 @@ export function renderHashArt(
1488
1731
  const dist = Math.hypot(dx, dy);
1489
1732
 
1490
1733
  // Skip connections between distant shapes
1491
- if (dist > maxCurveDist) continue;
1734
+ if (dist > maxCurveDist) {
1735
+ continue;
1736
+ }
1492
1737
 
1493
1738
  const mx = (a.x + b.x) / 2;
1494
1739
  const my = (a.y + b.y) / 2;
@@ -1497,19 +1742,36 @@ export function renderHashArt(
1497
1742
  const cpx = mx + (-dy / (dist || 1)) * bulge;
1498
1743
  const cpy = my + (dx / (dist || 1)) * bulge;
1499
1744
 
1500
- ctx.globalAlpha = 0.06 + rng() * 0.1;
1501
- ctx.strokeStyle = hexWithAlpha(
1745
+ const curveAlpha = 0.06 + rng() * 0.1;
1746
+ const curveColor = hexWithAlpha(
1502
1747
  enforceContrast(pickHierarchyColor(colorHierarchy, rng), bgLum),
1503
1748
  0.3,
1504
1749
  );
1505
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];
1506
1763
  ctx.beginPath();
1507
- ctx.moveTo(a.x, a.y);
1508
- 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
+ }
1509
1769
  ctx.stroke();
1510
1770
  }
1511
1771
  }
1512
1772
 
1773
+ _mark("9_connecting_curves");
1774
+
1513
1775
  // ── 10. Post-processing ────────────────────────────────────────
1514
1776
 
1515
1777
  // 10a. Color grading — unified tone across the whole image
@@ -1567,6 +1829,8 @@ export function renderHashArt(
1567
1829
  ctx.globalCompositeOperation = "source-over";
1568
1830
  }
1569
1831
 
1832
+ _mark("10_post_processing");
1833
+
1570
1834
  // ── 10e. Generative borders — archetype-driven decorative frames ──
1571
1835
  {
1572
1836
  ctx.save();
@@ -1613,12 +1877,15 @@ export function renderHashArt(
1613
1877
  }
1614
1878
  } else if (archName.includes("botanical") || archName.includes("organic") || archName.includes("watercolor")) {
1615
1879
  // Vine tendrils — organic curving lines along edges
1880
+ // Optimized: batch all tendrils into a single path
1616
1881
  ctx.strokeStyle = hexWithAlpha(colorHierarchy.secondary, 0.15);
1617
1882
  ctx.lineWidth = Math.max(0.8, 1.2 * scaleFactor);
1618
1883
  ctx.globalAlpha = 0.12 + borderRng() * 0.08;
1619
1884
  ctx.lineCap = "round";
1620
1885
 
1621
1886
  const tendrilCount = 8 + Math.floor(borderRng() * 8);
1887
+ ctx.beginPath();
1888
+ const leafPositions: Array<{ x: number; y: number; r: number }> = [];
1622
1889
  for (let t = 0; t < tendrilCount; t++) {
1623
1890
  // Start from a random edge point
1624
1891
  const edge = Math.floor(borderRng() * 4);
@@ -1628,7 +1895,6 @@ export function renderHashArt(
1628
1895
  else if (edge === 2) { tx = borderPad; ty = borderRng() * height; }
1629
1896
  else { tx = width - borderPad; ty = borderRng() * height; }
1630
1897
 
1631
- ctx.beginPath();
1632
1898
  ctx.moveTo(tx, ty);
1633
1899
  const segs = 3 + Math.floor(borderRng() * 4);
1634
1900
  for (let s = 0; s < segs; s++) {
@@ -1642,16 +1908,24 @@ export function renderHashArt(
1642
1908
  ty = cpy3;
1643
1909
  ctx.quadraticCurveTo(cpx2, cpy2, tx, ty);
1644
1910
  }
1645
- ctx.stroke();
1646
1911
 
1647
- // Small leaf/dot at tendril end
1912
+ // Collect leaf positions for batch fill
1648
1913
  if (borderRng() < 0.6) {
1649
- ctx.beginPath();
1650
- ctx.arc(tx, ty, borderPad * (0.15 + borderRng() * 0.2), 0, Math.PI * 2);
1651
- ctx.fillStyle = hexWithAlpha(colorHierarchy.secondary, 0.08);
1652
- ctx.fill();
1914
+ leafPositions.push({ x: tx, y: ty, r: borderPad * (0.15 + borderRng() * 0.2) });
1653
1915
  }
1654
1916
  }
1917
+ ctx.stroke();
1918
+
1919
+ // Batch all leaf dots into a single fill
1920
+ if (leafPositions.length > 0) {
1921
+ ctx.fillStyle = hexWithAlpha(colorHierarchy.secondary, 0.08);
1922
+ ctx.beginPath();
1923
+ for (const leaf of leafPositions) {
1924
+ ctx.moveTo(leaf.x + leaf.r, leaf.y);
1925
+ ctx.arc(leaf.x, leaf.y, leaf.r, 0, Math.PI * 2);
1926
+ }
1927
+ ctx.fill();
1928
+ }
1655
1929
  } else if (archName.includes("celestial") || archName.includes("cosmic") || archName.includes("neon")) {
1656
1930
  // Star-studded arcs along edges
1657
1931
  ctx.globalAlpha = 0.1 + borderRng() * 0.08;
@@ -1667,8 +1941,9 @@ export function renderHashArt(
1667
1941
  ctx.arc(cx, height * 1.3, height * 0.6, Math.PI + 0.3, -0.3);
1668
1942
  ctx.stroke();
1669
1943
 
1670
- // Scatter small stars along the border region
1944
+ // Scatter small stars along the border region — batched into single path
1671
1945
  const starCount = 15 + Math.floor(borderRng() * 15);
1946
+ ctx.beginPath();
1672
1947
  for (let s = 0; s < starCount; s++) {
1673
1948
  const edge = Math.floor(borderRng() * 4);
1674
1949
  let sx: number, sy: number;
@@ -1679,7 +1954,6 @@ export function renderHashArt(
1679
1954
 
1680
1955
  const starR = (1 + borderRng() * 2.5) * scaleFactor;
1681
1956
  // 4-point star
1682
- ctx.beginPath();
1683
1957
  for (let p = 0; p < 8; p++) {
1684
1958
  const a = (p / 8) * Math.PI * 2;
1685
1959
  const r = p % 2 === 0 ? starR : starR * 0.4;
@@ -1689,8 +1963,8 @@ export function renderHashArt(
1689
1963
  else ctx.lineTo(px2, py2);
1690
1964
  }
1691
1965
  ctx.closePath();
1692
- ctx.fill();
1693
1966
  }
1967
+ ctx.fill();
1694
1968
  } else if (archName.includes("minimal") || archName.includes("monochrome") || archName.includes("stipple")) {
1695
1969
  // Thin single rule — understated elegance
1696
1970
  ctx.strokeStyle = hexWithAlpha(colorHierarchy.dominant, 0.1);
@@ -1703,6 +1977,8 @@ export function renderHashArt(
1703
1977
  ctx.restore();
1704
1978
  }
1705
1979
 
1980
+ _mark("10e_borders");
1981
+
1706
1982
  // ── 11. Signature mark — placed in the least-dense corner ──────
1707
1983
  {
1708
1984
  const sigRng = createRng(seedFromHash(gitHash, 42));
@@ -1766,5 +2042,6 @@ export function renderHashArt(
1766
2042
  }
1767
2043
 
1768
2044
  ctx.globalAlpha = 1;
2045
+ _mark("11_signature");
1769
2046
 
1770
2047
  }