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/ALGORITHM.md +76 -24
- package/CHANGELOG.md +9 -1
- package/dist/browser.js +648 -265
- package/dist/browser.js.map +1 -1
- package/dist/main.js +648 -265
- package/dist/main.js.map +1 -1
- package/dist/module.js +648 -265
- package/dist/module.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/performance.test.ts +243 -0
- package/src/__tests__/phase-timing.test.ts +260 -0
- package/src/__tests__/profile-pipeline.test.ts +160 -0
- package/src/lib/canvas/colors.ts +23 -6
- package/src/lib/canvas/draw.ts +149 -62
- package/src/lib/canvas/shapes/complex.ts +19 -10
- package/src/lib/canvas/shapes/sacred.ts +16 -17
- package/src/lib/render.ts +460 -190
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.
|
|
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
|
-
|
|
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
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
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
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
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
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
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
|
-
|
|
1129
|
-
|
|
1130
|
-
const
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
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
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
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
|
-
|
|
1174
|
-
|
|
1300
|
+
if (rx < 0 || rx > width || ry < 0 || ry > height) break;
|
|
1301
|
+
if (isInVoidZone(rx, ry, voidZones)) break;
|
|
1175
1302
|
|
|
1176
|
-
|
|
1177
|
-
|
|
1303
|
+
rhythmSize *= rhythmDecay;
|
|
1304
|
+
if (rhythmSize < adjustedMinSize) break;
|
|
1178
1305
|
|
|
1179
|
-
|
|
1180
|
-
|
|
1306
|
+
const rhythmAlpha = layerOpacity * (0.6 - r * 0.08);
|
|
1307
|
+
ctx.globalAlpha = Math.max(0.1, rhythmAlpha);
|
|
1181
1308
|
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1309
|
+
const rhythmFill = hexWithAlpha(
|
|
1310
|
+
jitterColorHSL(pickHierarchyColor(layerHierarchy, rng), rng, 5, 0.04),
|
|
1311
|
+
fillAlpha * 0.7,
|
|
1312
|
+
);
|
|
1186
1313
|
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
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
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
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
|
-
|
|
1382
|
-
|
|
1577
|
+
const eAlpha = 0.04 + rng() * 0.06;
|
|
1578
|
+
const eColor = hexWithAlpha(
|
|
1383
1579
|
enforceContrast(pickHierarchyColor(colorHierarchy, rng), bgLum), 0.3,
|
|
1384
1580
|
);
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
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
|
|
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
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
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)
|
|
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
|
-
|
|
1501
|
-
|
|
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
|
-
|
|
1508
|
-
|
|
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
|
-
//
|
|
1908
|
+
// Collect leaf positions for batch fill
|
|
1648
1909
|
if (borderRng() < 0.6) {
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
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);
|