git-hash-art 0.11.0 → 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 {
@@ -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
 
@@ -707,23 +740,27 @@ export function renderHashArt(
707
740
  ctx.arc(zone.x, zone.y, zone.radius, 0, Math.PI * 2);
708
741
  ctx.stroke();
709
742
 
710
- // ~50% chance: scatter tiny dots inside the void
743
+ // ~50% chance: scatter tiny dots inside the void — batched into single path
711
744
  if (rng() < 0.5) {
712
745
  const dotCount = 3 + Math.floor(rng() * 6);
713
746
  ctx.globalAlpha = 0.06 + rng() * 0.04;
714
747
  ctx.fillStyle = hexWithAlpha(colorHierarchy.secondary, 0.15);
748
+ ctx.beginPath();
715
749
  for (let d = 0; d < dotCount; d++) {
716
750
  const angle = rng() * Math.PI * 2;
717
751
  const dist = rng() * zone.radius * 0.7;
718
752
  const dotR = (1 + rng() * 3) * scaleFactor;
719
- ctx.beginPath();
753
+ ctx.moveTo(
754
+ zone.x + Math.cos(angle) * dist + dotR,
755
+ zone.y + Math.sin(angle) * dist,
756
+ );
720
757
  ctx.arc(
721
758
  zone.x + Math.cos(angle) * dist,
722
759
  zone.y + Math.sin(angle) * dist,
723
760
  dotR, 0, Math.PI * 2,
724
761
  );
725
- ctx.fill();
726
762
  }
763
+ ctx.fill();
727
764
  }
728
765
 
729
766
  // ~30% chance: thin concentric ring inside
@@ -821,6 +858,28 @@ export function renderHashArt(
821
858
  // ── 5. Shape layers ────────────────────────────────────────────
822
859
  const maxLocalDensity = Math.ceil(shapesPerLayer * 0.15);
823
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
+
824
883
  for (let layer = 0; layer < layers; layer++) {
825
884
  const layerRatio = layers > 1 ? layer / (layers - 1) : 0;
826
885
  const numShapes =
@@ -957,7 +1016,30 @@ export function renderHashArt(
957
1016
 
958
1017
  // Organic edge jitter — applied via watercolor style on ~15% of shapes
959
1018
  const useOrganicEdges = rng() < 0.15 && shapeRenderStyle === "fill-and-stroke";
960
- 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
+ }
961
1043
 
962
1044
  // Consistent light direction — subtle shadow offset
963
1045
  const shadowDist = hasGlow ? 0 : (size * 0.02);
@@ -1016,28 +1098,37 @@ export function renderHashArt(
1016
1098
  mirrorAxis: mirrorAxis!,
1017
1099
  mirrorGap: size * (0.1 + rng() * 0.3),
1018
1100
  });
1101
+ complexityBudget -= styleCost * 2; // mirrored = 2 shapes
1019
1102
  } else {
1020
1103
  enhanceShapeGeneration(ctx, shape, finalX, finalY, shapeConfig);
1104
+ complexityBudget -= styleCost;
1021
1105
  }
1022
1106
 
1107
+ // ── Extras budget gate — skip multiplier sections when over budget ──
1108
+ const extrasAllowed = extrasSpent < budgetForExtras;
1109
+
1023
1110
  // ── Glazing — luminous multi-pass transparency on ~20% of shapes ──
1024
1111
  if (rng() < 0.2 && size > adjustedMinSize * 2) {
1025
1112
  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
- });
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;
1040
1130
  }
1131
+ // RNG consumed by glazePasses calculation above regardless
1041
1132
  }
1042
1133
 
1043
1134
  shapePositions.push({ x: finalX, y: finalY, size, shape });
@@ -1047,29 +1138,33 @@ export function renderHashArt(
1047
1138
  if (size > adjustedMaxSize * 0.5 && rng() < 0.2) {
1048
1139
  const echoCount = 2 + Math.floor(rng() * 2);
1049
1140
  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 });
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;
1072
1166
  }
1167
+ // RNG for echoCount + echoAngle consumed above regardless
1073
1168
  }
1074
1169
 
1075
1170
  // ── 5d. Recursive nesting ──────────────────────────────────
@@ -1078,36 +1173,51 @@ export function renderHashArt(
1078
1173
  const nestingChance = 0.15 + focalProximity * 0.15; // 15-30% near focal
1079
1174
  if (size > adjustedMaxSize * 0.4 && rng() < nestingChance) {
1080
1175
  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
- );
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
+ }
1111
1221
  }
1112
1222
  }
1113
1223
 
@@ -1117,42 +1227,58 @@ export function renderHashArt(
1117
1227
  const constellation = CONSTELLATIONS[Math.floor(rng() * CONSTELLATIONS.length)];
1118
1228
  const members = constellation.build(rng, size);
1119
1229
  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
1230
 
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
-
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 });
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
+ }
1156
1282
  }
1157
1283
  }
1158
1284
 
@@ -1165,37 +1291,45 @@ export function renderHashArt(
1165
1291
  const rhythmDecay = 0.7 + rng() * 0.15; // size multiplier per step
1166
1292
  const rhythmShape = shape; // same shape for visual rhythm
1167
1293
 
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);
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);
1172
1299
 
1173
- if (rx < 0 || rx > width || ry < 0 || ry > height) break;
1174
- if (isInVoidZone(rx, ry, voidZones)) break;
1300
+ if (rx < 0 || rx > width || ry < 0 || ry > height) break;
1301
+ if (isInVoidZone(rx, ry, voidZones)) break;
1175
1302
 
1176
- rhythmSize *= rhythmDecay;
1177
- if (rhythmSize < adjustedMinSize) break;
1303
+ rhythmSize *= rhythmDecay;
1304
+ if (rhythmSize < adjustedMinSize) break;
1178
1305
 
1179
- const rhythmAlpha = layerOpacity * (0.6 - r * 0.08);
1180
- ctx.globalAlpha = Math.max(0.1, rhythmAlpha);
1306
+ const rhythmAlpha = layerOpacity * (0.6 - r * 0.08);
1307
+ ctx.globalAlpha = Math.max(0.1, rhythmAlpha);
1181
1308
 
1182
- const rhythmFill = hexWithAlpha(
1183
- jitterColorHSL(pickHierarchyColor(layerHierarchy, rng), rng, 5, 0.04),
1184
- fillAlpha * 0.7,
1185
- );
1309
+ const rhythmFill = hexWithAlpha(
1310
+ jitterColorHSL(pickHierarchyColor(layerHierarchy, rng), rng, 5, 0.04),
1311
+ fillAlpha * 0.7,
1312
+ );
1186
1313
 
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 });
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
+ }
1199
1333
  }
1200
1334
  }
1201
1335
  }
@@ -1272,15 +1406,30 @@ export function renderHashArt(
1272
1406
 
1273
1407
 
1274
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).
1275
1412
  const baseFlowLines = 6 + Math.floor(rng() * 10);
1276
1413
  const numFlowLines = Math.round(baseFlowLines * archetype.flowLineMultiplier);
1277
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
+
1278
1426
  for (let i = 0; i < numFlowLines; i++) {
1279
1427
  let fx = rng() * width;
1280
1428
  let fy = rng() * height;
1281
1429
  const steps = 30 + Math.floor(rng() * 40);
1282
1430
  const stepLen = (3 + rng() * 5) * scaleFactor;
1283
1431
  const startWidth = (1 + rng() * 3) * scaleFactor;
1432
+ if (startWidth > globalMaxFlowWidth) globalMaxFlowWidth = startWidth;
1284
1433
 
1285
1434
  // Variable color: interpolate between two hierarchy colors along the stroke
1286
1435
  const lineColorStart = enforceContrast(pickHierarchyColor(colorHierarchy, rng), bgLum);
@@ -1308,23 +1457,21 @@ export function renderHashArt(
1308
1457
  }
1309
1458
 
1310
1459
  const t = s / steps;
1311
- // Taper + pressure
1312
1460
  const taper = 1 - t * 0.8;
1313
1461
  const pressure = 0.6 + 0.4 * Math.sin(t * pressureFreq * Math.PI + pressurePhase);
1314
-
1315
- ctx.globalAlpha = lineAlpha * taper;
1316
- // Interpolate color along stroke
1462
+ const segWidth = startWidth * taper * pressure;
1463
+ const segAlpha = lineAlpha * taper;
1317
1464
  const lineColor = t < 0.5
1318
1465
  ? hexWithAlpha(lineColorStart, 0.4 + t * 0.2)
1319
1466
  : hexWithAlpha(lineColorEnd, 0.4 + (1 - t) * 0.2);
1320
- ctx.strokeStyle = lineColor;
1321
- ctx.lineWidth = startWidth * taper * pressure;
1322
- ctx.lineCap = "round";
1323
1467
 
1324
- ctx.beginPath();
1325
- ctx.moveTo(prevX, prevY);
1326
- ctx.lineTo(fx, fy);
1327
- 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;
1328
1475
 
1329
1476
  // Branching: ~12% chance per step to spawn a thinner child stroke
1330
1477
  if (rng() < 0.12 && s > 5 && s < steps - 10) {
@@ -1341,12 +1488,14 @@ export function renderHashArt(
1341
1488
  by += Math.sin(bAngle) * stepLen * 0.8;
1342
1489
  if (bx < 0 || bx > width || by < 0 || by > height) break;
1343
1490
  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();
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;
1350
1499
  bPrevX = bx;
1351
1500
  bPrevY = by;
1352
1501
  }
@@ -1357,14 +1506,61 @@ export function renderHashArt(
1357
1506
  }
1358
1507
  }
1359
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
+
1360
1549
  // ── 6b. Motion/energy lines — short directional bursts ─────────
1550
+ // Optimized: collect all burst segments, then batch by quantized alpha
1361
1551
  const energyArchetypes = ["dense-chaotic", "cosmic", "neon-glow", "bold-graphic"];
1362
1552
  const hasEnergyLines = energyArchetypes.some(a => archetype.name.includes(a)) || rng() < 0.25;
1363
1553
  if (hasEnergyLines && shapePositions.length > 0) {
1364
1554
  const energyCount = 5 + Math.floor(rng() * 10);
1365
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
+
1366
1563
  for (let e = 0; e < energyCount; e++) {
1367
- // Pick a random shape to radiate from
1368
1564
  const source = shapePositions[Math.floor(rng() * shapePositions.length)];
1369
1565
  const burstCount = 2 + Math.floor(rng() * 4);
1370
1566
  const baseAngle = flowAngle(source.x, source.y);
@@ -1378,17 +1574,35 @@ export function renderHashArt(
1378
1574
  const ex = sx + Math.cos(angle) * lineLen;
1379
1575
  const ey = sy + Math.sin(angle) * lineLen;
1380
1576
 
1381
- ctx.globalAlpha = 0.04 + rng() * 0.06;
1382
- ctx.strokeStyle = hexWithAlpha(
1577
+ const eAlpha = 0.04 + rng() * 0.06;
1578
+ const eColor = hexWithAlpha(
1383
1579
  enforceContrast(pickHierarchyColor(colorHierarchy, rng), bgLum), 0.3,
1384
1580
  );
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();
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;
1390
1587
  }
1391
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);
1603
+ }
1604
+ ctx.stroke();
1605
+ }
1392
1606
  }
1393
1607
 
1394
1608
  // ── 6c. Apply symmetry mirroring ─────────────────────────────────
@@ -1414,28 +1628,48 @@ export function renderHashArt(
1414
1628
 
1415
1629
 
1416
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.
1417
1633
  const noiseRng = createRng(seedFromHash(gitHash, 777));
1418
- const noiseDensity = Math.floor((width * height) / 800);
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);
1419
1638
  try {
1420
1639
  const imageData = ctx.getImageData(0, 0, width, height);
1421
1640
  const data = imageData.data;
1422
1641
  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
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
+ }
1439
1673
  }
1440
1674
  }
1441
1675
  }
@@ -1469,11 +1703,20 @@ export function renderHashArt(
1469
1703
  ctx.fillRect(0, 0, width, height);
1470
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,15 +1742,30 @@ 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
  }
@@ -1613,12 +1873,15 @@ export function renderHashArt(
1613
1873
  }
1614
1874
  } else if (archName.includes("botanical") || archName.includes("organic") || archName.includes("watercolor")) {
1615
1875
  // Vine tendrils — organic curving lines along edges
1876
+ // Optimized: batch all tendrils into a single path
1616
1877
  ctx.strokeStyle = hexWithAlpha(colorHierarchy.secondary, 0.15);
1617
1878
  ctx.lineWidth = Math.max(0.8, 1.2 * scaleFactor);
1618
1879
  ctx.globalAlpha = 0.12 + borderRng() * 0.08;
1619
1880
  ctx.lineCap = "round";
1620
1881
 
1621
1882
  const tendrilCount = 8 + Math.floor(borderRng() * 8);
1883
+ ctx.beginPath();
1884
+ const leafPositions: Array<{ x: number; y: number; r: number }> = [];
1622
1885
  for (let t = 0; t < tendrilCount; t++) {
1623
1886
  // Start from a random edge point
1624
1887
  const edge = Math.floor(borderRng() * 4);
@@ -1628,7 +1891,6 @@ export function renderHashArt(
1628
1891
  else if (edge === 2) { tx = borderPad; ty = borderRng() * height; }
1629
1892
  else { tx = width - borderPad; ty = borderRng() * height; }
1630
1893
 
1631
- ctx.beginPath();
1632
1894
  ctx.moveTo(tx, ty);
1633
1895
  const segs = 3 + Math.floor(borderRng() * 4);
1634
1896
  for (let s = 0; s < segs; s++) {
@@ -1642,15 +1904,23 @@ export function renderHashArt(
1642
1904
  ty = cpy3;
1643
1905
  ctx.quadraticCurveTo(cpx2, cpy2, tx, ty);
1644
1906
  }
1645
- ctx.stroke();
1646
1907
 
1647
- // Small leaf/dot at tendril end
1908
+ // Collect leaf positions for batch fill
1648
1909
  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();
1910
+ leafPositions.push({ x: tx, y: ty, r: borderPad * (0.15 + borderRng() * 0.2) });
1911
+ }
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);
1653
1922
  }
1923
+ ctx.fill();
1654
1924
  }
1655
1925
  } else if (archName.includes("celestial") || archName.includes("cosmic") || archName.includes("neon")) {
1656
1926
  // Star-studded arcs along edges
@@ -1667,8 +1937,9 @@ export function renderHashArt(
1667
1937
  ctx.arc(cx, height * 1.3, height * 0.6, Math.PI + 0.3, -0.3);
1668
1938
  ctx.stroke();
1669
1939
 
1670
- // Scatter small stars along the border region
1940
+ // Scatter small stars along the border region — batched into single path
1671
1941
  const starCount = 15 + Math.floor(borderRng() * 15);
1942
+ ctx.beginPath();
1672
1943
  for (let s = 0; s < starCount; s++) {
1673
1944
  const edge = Math.floor(borderRng() * 4);
1674
1945
  let sx: number, sy: number;
@@ -1679,7 +1950,6 @@ export function renderHashArt(
1679
1950
 
1680
1951
  const starR = (1 + borderRng() * 2.5) * scaleFactor;
1681
1952
  // 4-point star
1682
- ctx.beginPath();
1683
1953
  for (let p = 0; p < 8; p++) {
1684
1954
  const a = (p / 8) * Math.PI * 2;
1685
1955
  const r = p % 2 === 0 ? starR : starR * 0.4;
@@ -1689,8 +1959,8 @@ export function renderHashArt(
1689
1959
  else ctx.lineTo(px2, py2);
1690
1960
  }
1691
1961
  ctx.closePath();
1692
- ctx.fill();
1693
1962
  }
1963
+ ctx.fill();
1694
1964
  } else if (archName.includes("minimal") || archName.includes("monochrome") || archName.includes("stipple")) {
1695
1965
  // Thin single rule — understated elegance
1696
1966
  ctx.strokeStyle = hexWithAlpha(colorHierarchy.dominant, 0.1);