@sarmal/core 0.34.0 → 0.35.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.cts CHANGED
@@ -104,10 +104,10 @@ interface DotMatrixSarmalOptions extends Pick<BaseRendererOptions, "autoStart" |
104
104
  * Grid geometry is derived from `cols` and `rows`.
105
105
  * For example, a 240x240 canvas with `cols: 32, rows: 32` produces 1024 dots with cells approximately 7.5x7.5 px each.
106
106
  *
107
- * The background layer (all dim dots) is pre-rendered to an OffscreenCanvas at init and
108
- * restored each frame with a single `drawImage` call.
109
- * Lit dots are batched by brightness level,
110
- * so the total draw calls per frame is around 10–12 regardless of grid size.
107
+ * At init, a pixel mask is computed that records which canvas pixels belong to each dot.
108
+ * Each frame, RGBA values are written directly into a typed array (one entry per lit pixel)
109
+ * and flushed to the canvas with a single `ctx.putImageData` call.
110
+ * Frame cost is flat regardless of how many dots are lit or what grid size is used.
111
111
  *
112
112
  * @param canvas - The canvas element to draw into.
113
113
  * Its `width` and `height` HTML attributes determine the rendering area.
package/dist/index.d.ts CHANGED
@@ -104,10 +104,10 @@ interface DotMatrixSarmalOptions extends Pick<BaseRendererOptions, "autoStart" |
104
104
  * Grid geometry is derived from `cols` and `rows`.
105
105
  * For example, a 240x240 canvas with `cols: 32, rows: 32` produces 1024 dots with cells approximately 7.5x7.5 px each.
106
106
  *
107
- * The background layer (all dim dots) is pre-rendered to an OffscreenCanvas at init and
108
- * restored each frame with a single `drawImage` call.
109
- * Lit dots are batched by brightness level,
110
- * so the total draw calls per frame is around 10–12 regardless of grid size.
107
+ * At init, a pixel mask is computed that records which canvas pixels belong to each dot.
108
+ * Each frame, RGBA values are written directly into a typed array (one entry per lit pixel)
109
+ * and flushed to the canvas with a single `ctx.putImageData` call.
110
+ * Frame cost is flat regardless of how many dots are lit or what grid size is used.
111
111
  *
112
112
  * @param canvas - The canvas element to draw into.
113
113
  * Its `width` and `height` HTML attributes determine the rendering area.
package/dist/index.js CHANGED
@@ -1416,7 +1416,6 @@ function createSarmalSVG(container, curveDef, options) {
1416
1416
  }
1417
1417
 
1418
1418
  // src/renderer-dot-matrix.ts
1419
- var NUM_BUCKETS = 8;
1420
1419
  function createSarmalDotMatrix(canvas, curveDef, options) {
1421
1420
  const {
1422
1421
  cols = 32,
@@ -1440,14 +1439,8 @@ function createSarmalDotMatrix(canvas, curveDef, options) {
1440
1439
  const cellW = W / cols;
1441
1440
  const cellH = H / rows;
1442
1441
  const dotR = Math.min(cellW, cellH) * 0.36;
1443
- let gradientRgb;
1444
- if (Array.isArray(initialColor)) {
1445
- validateBaseRenderOptions({ trailColor: initialColor });
1446
- gradientRgb = initialColor.map(colorToRgb);
1447
- } else {
1448
- gradientRgb = null;
1449
- }
1450
- let colorRgb = gradientRgb ? gradientRgb[0] : colorToRgb(initialColor);
1442
+ let gradientOklab = null;
1443
+ let colorRgb = { r: 255, g: 255, b: 255 };
1451
1444
  let currentTrailStyle = initialTrailStyle;
1452
1445
  let animTime = 0;
1453
1446
  const ANIM_PERIOD = 6;
@@ -1455,7 +1448,12 @@ function createSarmalDotMatrix(canvas, curveDef, options) {
1455
1448
  let scale = 1;
1456
1449
  let offsetX = 0;
1457
1450
  let offsetY = 0;
1458
- let bgCanvas = null;
1451
+ let pixelMaskStarts = new Uint32Array(0);
1452
+ let pixelMaskLengths = new Uint32Array(0);
1453
+ let pixelMaskIndices = new Uint32Array(0);
1454
+ let pixelMaskCoverages = new Float32Array(0);
1455
+ let bgImageData = null;
1456
+ let frameImageData = null;
1459
1457
  let animationId = null;
1460
1458
  let lastTime = 0;
1461
1459
  let pausedByVisibility = false;
@@ -1463,33 +1461,82 @@ function createSarmalDotMatrix(canvas, curveDef, options) {
1463
1461
  let morphReject = null;
1464
1462
  let morphDurationMs = DEFAULT_MORPH_DURATION_MS;
1465
1463
  let morphProgress = 0;
1466
- function buildBgCanvas() {
1467
- bgCanvas = new OffscreenCanvas(W, H);
1468
- const bgCtx = bgCanvas.getContext("2d");
1469
- const bg = gradientRgb ? gradientRgb[0] : colorRgb;
1470
- bgCtx.fillStyle = `rgba(${bg.r},${bg.g},${bg.b},0.05)`;
1471
- bgCtx.beginPath();
1464
+ function computePixelMask() {
1465
+ const starts = new Uint32Array(cols * rows);
1466
+ const lengths = new Uint32Array(cols * rows);
1467
+ const allIndices = [];
1468
+ const allCoverages = [];
1469
+ const cornerR = roundness * dotR;
1470
+ const cornerR2 = cornerR * cornerR;
1471
+ const SSAA = 4;
1472
+ const SSAA2 = SSAA * SSAA;
1472
1473
  for (let row = 0; row < rows; row++) {
1473
1474
  for (let col = 0; col < cols; col++) {
1475
+ const dotIdx = row * cols + col;
1474
1476
  const cx = (col + 0.5) * cellW;
1475
1477
  const cy = (row + 0.5) * cellH;
1476
- bgCtx.roundRect(cx - dotR, cy - dotR, dotR * 2, dotR * 2, roundness * dotR);
1478
+ const x0 = Math.max(0, Math.floor(cx - dotR - 1));
1479
+ const x1 = Math.min(W - 1, Math.ceil(cx + dotR + 1));
1480
+ const y0 = Math.max(0, Math.floor(cy - dotR - 1));
1481
+ const y1 = Math.min(H - 1, Math.ceil(cy + dotR + 1));
1482
+ starts[dotIdx] = allIndices.length;
1483
+ let count = 0;
1484
+ for (let py = y0; py <= y1; py++) {
1485
+ for (let px = x0; px <= x1; px++) {
1486
+ let hits = 0;
1487
+ for (let sy = 0; sy < SSAA; sy++) {
1488
+ const spyCenter = py + (sy + 0.5) / SSAA;
1489
+ for (let sx = 0; sx < SSAA; sx++) {
1490
+ const spxCenter = px + (sx + 0.5) / SSAA;
1491
+ const dx = Math.max(Math.abs(spxCenter - cx) - (dotR - cornerR), 0);
1492
+ const dy = Math.max(Math.abs(spyCenter - cy) - (dotR - cornerR), 0);
1493
+ if (dx * dx + dy * dy <= cornerR2) {
1494
+ hits++;
1495
+ }
1496
+ }
1497
+ }
1498
+ if (hits > 0) {
1499
+ allIndices.push((py * W + px) * 4);
1500
+ allCoverages.push(hits / SSAA2);
1501
+ count++;
1502
+ }
1503
+ }
1504
+ }
1505
+ lengths[dotIdx] = count;
1506
+ }
1507
+ }
1508
+ pixelMaskStarts = starts;
1509
+ pixelMaskLengths = lengths;
1510
+ pixelMaskIndices = new Uint32Array(allIndices);
1511
+ pixelMaskCoverages = new Float32Array(allCoverages);
1512
+ }
1513
+ function buildBgImageData() {
1514
+ bgImageData = new ImageData(W, H);
1515
+ const bg = colorRgb;
1516
+ const baseAlpha = 0.05 * 255;
1517
+ const { data } = bgImageData;
1518
+ const n = cols * rows;
1519
+ for (let dotIdx = 0; dotIdx < n; dotIdx++) {
1520
+ const start = pixelMaskStarts[dotIdx];
1521
+ const len = pixelMaskLengths[dotIdx];
1522
+ for (let k = 0; k < len; k++) {
1523
+ const px = pixelMaskIndices[start + k];
1524
+ const coverage = pixelMaskCoverages[start + k];
1525
+ data[px] = bg.r;
1526
+ data[px + 1] = bg.g;
1527
+ data[px + 2] = bg.b;
1528
+ data[px + 3] = Math.round(baseAlpha * coverage);
1477
1529
  }
1478
1530
  }
1479
- bgCtx.fill();
1480
- }
1481
- function sampleGradientRgb(stops, t) {
1482
- const n = stops.length;
1483
- const scaled = Math.max(0, Math.min(1, t)) * (n - 1);
1484
- const i = Math.min(Math.floor(scaled), n - 2);
1485
- const a = stops[i];
1486
- const bStop = stops[i + 1];
1487
- const mix = scaled - i;
1488
- return {
1489
- r: Math.round(a.r + (bStop.r - a.r) * mix),
1490
- g: Math.round(a.g + (bStop.g - a.g) * mix),
1491
- b: Math.round(a.b + (bStop.b - a.b) * mix)
1492
- };
1531
+ }
1532
+ function applyColor(color) {
1533
+ if (Array.isArray(color)) {
1534
+ gradientOklab = color.map((c) => parseColorToOklab(c));
1535
+ colorRgb = oklabToRgb(gradientOklab[0]);
1536
+ } else {
1537
+ gradientOklab = null;
1538
+ colorRgb = colorToRgb(color);
1539
+ }
1493
1540
  }
1494
1541
  function calculateBoundaries(skel) {
1495
1542
  const b = computeBoundaries(skel, W, H);
@@ -1538,43 +1585,44 @@ function createSarmalDotMatrix(canvas, curveDef, options) {
1538
1585
  }
1539
1586
  }
1540
1587
  function draw() {
1541
- ctx.clearRect(0, 0, W, H);
1542
- if (bgCanvas) {
1543
- ctx.drawImage(bgCanvas, 0, 0);
1588
+ if (!bgImageData || !frameImageData) {
1589
+ return;
1544
1590
  }
1545
- const animOffset = currentTrailStyle === "gradient-animated" ? Math.abs(animTime / ANIM_PERIOD % 2 - 1) * 0.35 : 0;
1546
- for (let bucket = 0; bucket < NUM_BUCKETS; bucket++) {
1547
- const lo = bucket / NUM_BUCKETS;
1548
- const hi = (bucket + 1) / NUM_BUCKETS;
1549
- const midpoint = (lo + hi) / 2;
1550
- const alpha = 0.08 + midpoint * 0.92;
1551
- let hasLit = false;
1552
- ctx.beginPath();
1553
- for (let row = 0; row < rows; row++) {
1554
- for (let col = 0; col < cols; col++) {
1555
- const intensity = grid[row * cols + col];
1556
- if (intensity > lo && intensity <= hi) {
1557
- const cx = (col + 0.5) * cellW;
1558
- const cy = (row + 0.5) * cellH;
1559
- ctx.roundRect(cx - dotR, cy - dotR, dotR * 2, dotR * 2, roundness * dotR);
1560
- hasLit = true;
1561
- }
1562
- }
1563
- }
1564
- if (hasLit) {
1565
- if (gradientRgb !== null) {
1566
- const t = ((midpoint + animOffset) % 1 + 1) % 1;
1567
- const { r, g, b } = sampleGradientRgb(gradientRgb, t);
1568
- ctx.fillStyle = `rgb(${r},${g},${b})`;
1569
- } else {
1570
- const { r, g, b } = colorRgb;
1571
- ctx.fillStyle = `rgb(${r},${g},${b})`;
1572
- }
1573
- ctx.globalAlpha = alpha;
1574
- ctx.fill();
1591
+ frameImageData.data.set(bgImageData.data);
1592
+ const { data } = frameImageData;
1593
+ const timeOffset = currentTrailStyle === "gradient-animated" ? animTime / ANIM_PERIOD : 0;
1594
+ const n = cols * rows;
1595
+ for (let dotIdx = 0; dotIdx < n; dotIdx++) {
1596
+ const intensity = grid[dotIdx];
1597
+ if (intensity <= 0) {
1598
+ continue;
1599
+ }
1600
+ let r, g, b;
1601
+ if (gradientOklab !== null) {
1602
+ ({ r, g, b } = oklabToRgb(getPaletteColor(gradientOklab, intensity, timeOffset)));
1603
+ } else {
1604
+ ({ r, g, b } = colorRgb);
1605
+ }
1606
+ const baseA = (0.08 + intensity * 0.92) * 255;
1607
+ const start = pixelMaskStarts[dotIdx];
1608
+ const len = pixelMaskLengths[dotIdx];
1609
+ for (let k = 0; k < len; k++) {
1610
+ const px = pixelMaskIndices[start + k];
1611
+ const coverage = pixelMaskCoverages[start + k];
1612
+ data[px] = r;
1613
+ data[px + 1] = g;
1614
+ data[px + 2] = b;
1615
+ data[px + 3] = Math.round(baseA * coverage);
1575
1616
  }
1576
1617
  }
1577
- ctx.globalAlpha = 1;
1618
+ ctx.putImageData(frameImageData, 0, 0);
1619
+ }
1620
+ function completeMorphNow() {
1621
+ engine.completeMorph();
1622
+ morphResolve?.();
1623
+ morphResolve = null;
1624
+ morphReject = null;
1625
+ morphProgress = 0;
1578
1626
  }
1579
1627
  function renderFrame(deltaTime) {
1580
1628
  if (engine.morphAlpha !== null) {
@@ -1582,11 +1630,7 @@ function createSarmalDotMatrix(canvas, curveDef, options) {
1582
1630
  engine.setMorphAlpha(morphProgress);
1583
1631
  calculateBoundaries(engine.getSarmalSkeleton());
1584
1632
  if (morphProgress >= 1) {
1585
- engine.completeMorph();
1586
- morphResolve?.();
1587
- morphResolve = null;
1588
- morphReject = null;
1589
- morphProgress = 0;
1633
+ completeMorphNow();
1590
1634
  calculateBoundaries(engine.getSarmalSkeleton());
1591
1635
  }
1592
1636
  } else if (engine.isLiveSkeleton) {
@@ -1604,8 +1648,12 @@ function createSarmalDotMatrix(canvas, curveDef, options) {
1604
1648
  renderFrame(deltaTime);
1605
1649
  animationId = requestAnimationFrame(loop);
1606
1650
  }
1651
+ validateBaseRenderOptions({ trailColor: initialColor });
1652
+ applyColor(initialColor);
1607
1653
  calculateBoundaries(engine.getSarmalSkeleton());
1608
- buildBgCanvas();
1654
+ computePixelMask();
1655
+ frameImageData = new ImageData(W, H);
1656
+ buildBgImageData();
1609
1657
  if (initialPhase !== void 0) {
1610
1658
  engine.seek(initialPhase);
1611
1659
  }
@@ -1650,11 +1698,7 @@ function createSarmalDotMatrix(canvas, curveDef, options) {
1650
1698
  */
1651
1699
  morphTo(target, opts) {
1652
1700
  if (morphResolve !== null) {
1653
- engine.completeMorph();
1654
- morphResolve();
1655
- morphResolve = null;
1656
- morphReject = null;
1657
- morphProgress = 0;
1701
+ completeMorphNow();
1658
1702
  }
1659
1703
  morphDurationMs = opts?.duration ?? DEFAULT_MORPH_DURATION_MS;
1660
1704
  morphProgress = 0;
@@ -1675,13 +1719,7 @@ function createSarmalDotMatrix(canvas, curveDef, options) {
1675
1719
  validateBaseRenderOptions(partial);
1676
1720
  let needsRebuildBg = false;
1677
1721
  if (partial.trailColor !== void 0) {
1678
- if (Array.isArray(partial.trailColor)) {
1679
- gradientRgb = partial.trailColor.map(colorToRgb);
1680
- colorRgb = gradientRgb[0];
1681
- } else {
1682
- gradientRgb = null;
1683
- colorRgb = colorToRgb(partial.trailColor);
1684
- }
1722
+ applyColor(partial.trailColor);
1685
1723
  needsRebuildBg = true;
1686
1724
  }
1687
1725
  if (partial.trailStyle !== void 0) {
@@ -1691,11 +1729,15 @@ function createSarmalDotMatrix(canvas, curveDef, options) {
1691
1729
  }
1692
1730
  }
1693
1731
  if (needsRebuildBg) {
1694
- buildBgCanvas();
1732
+ buildBgImageData();
1695
1733
  }
1696
- if (currentTrailStyle !== "default" && gradientRgb === null) {
1734
+ if (currentTrailStyle !== "default" && gradientOklab === null) {
1735
+ console.warn(
1736
+ `[sarmal] dot matrix: trailColor is a single color but trailStyle is "${currentTrailStyle}"; the trail will render as a solid color. Pass an array of hex colors to use a real gradient.`
1737
+ );
1738
+ } else if (currentTrailStyle === "default" && gradientOklab !== null) {
1697
1739
  console.warn(
1698
- "[sarmal] dot matrix: gradient trailStyle has no effect without a trailColor array"
1740
+ '[sarmal] dot matrix: trailColor is an array but trailStyle is "default"; only the first color will be used. Pass a gradient trailStyle to use the whole palette.'
1699
1741
  );
1700
1742
  }
1701
1743
  }