@sarmal/core 0.34.0 → 0.35.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/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,
@@ -1455,7 +1454,12 @@ function createSarmalDotMatrix(canvas, curveDef, options) {
1455
1454
  let scale = 1;
1456
1455
  let offsetX = 0;
1457
1456
  let offsetY = 0;
1458
- let bgCanvas = null;
1457
+ let pixelMaskStarts = new Uint32Array(0);
1458
+ let pixelMaskLengths = new Uint32Array(0);
1459
+ let pixelMaskIndices = new Uint32Array(0);
1460
+ let pixelMaskCoverages = new Float32Array(0);
1461
+ let bgImageData = null;
1462
+ let frameImageData = null;
1459
1463
  let animationId = null;
1460
1464
  let lastTime = 0;
1461
1465
  let pausedByVisibility = false;
@@ -1463,20 +1467,73 @@ function createSarmalDotMatrix(canvas, curveDef, options) {
1463
1467
  let morphReject = null;
1464
1468
  let morphDurationMs = DEFAULT_MORPH_DURATION_MS;
1465
1469
  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();
1470
+ function computePixelMask() {
1471
+ const starts = new Uint32Array(cols * rows);
1472
+ const lengths = new Uint32Array(cols * rows);
1473
+ const allIndices = [];
1474
+ const allCoverages = [];
1475
+ const cornerR = roundness * dotR;
1476
+ const cornerR2 = cornerR * cornerR;
1477
+ const SSAA = 4;
1478
+ const SSAA2 = SSAA * SSAA;
1472
1479
  for (let row = 0; row < rows; row++) {
1473
1480
  for (let col = 0; col < cols; col++) {
1481
+ const dotIdx = row * cols + col;
1474
1482
  const cx = (col + 0.5) * cellW;
1475
1483
  const cy = (row + 0.5) * cellH;
1476
- bgCtx.roundRect(cx - dotR, cy - dotR, dotR * 2, dotR * 2, roundness * dotR);
1484
+ const x0 = Math.max(0, Math.floor(cx - dotR - 1));
1485
+ const x1 = Math.min(W - 1, Math.ceil(cx + dotR + 1));
1486
+ const y0 = Math.max(0, Math.floor(cy - dotR - 1));
1487
+ const y1 = Math.min(H - 1, Math.ceil(cy + dotR + 1));
1488
+ starts[dotIdx] = allIndices.length;
1489
+ let count = 0;
1490
+ for (let py = y0; py <= y1; py++) {
1491
+ for (let px = x0; px <= x1; px++) {
1492
+ let hits = 0;
1493
+ for (let sy = 0; sy < SSAA; sy++) {
1494
+ const spyCenter = py + (sy + 0.5) / SSAA;
1495
+ for (let sx = 0; sx < SSAA; sx++) {
1496
+ const spxCenter = px + (sx + 0.5) / SSAA;
1497
+ const dx = Math.max(Math.abs(spxCenter - cx) - (dotR - cornerR), 0);
1498
+ const dy = Math.max(Math.abs(spyCenter - cy) - (dotR - cornerR), 0);
1499
+ if (dx * dx + dy * dy <= cornerR2) {
1500
+ hits++;
1501
+ }
1502
+ }
1503
+ }
1504
+ if (hits > 0) {
1505
+ allIndices.push((py * W + px) * 4);
1506
+ allCoverages.push(hits / SSAA2);
1507
+ count++;
1508
+ }
1509
+ }
1510
+ }
1511
+ lengths[dotIdx] = count;
1512
+ }
1513
+ }
1514
+ pixelMaskStarts = starts;
1515
+ pixelMaskLengths = lengths;
1516
+ pixelMaskIndices = new Uint32Array(allIndices);
1517
+ pixelMaskCoverages = new Float32Array(allCoverages);
1518
+ }
1519
+ function buildBgImageData() {
1520
+ bgImageData = new ImageData(W, H);
1521
+ const bg = gradientRgb ? gradientRgb[0] : colorRgb;
1522
+ const baseAlpha = 0.05 * 255;
1523
+ const { data } = bgImageData;
1524
+ const n = cols * rows;
1525
+ for (let dotIdx = 0; dotIdx < n; dotIdx++) {
1526
+ const start = pixelMaskStarts[dotIdx];
1527
+ const len = pixelMaskLengths[dotIdx];
1528
+ for (let k = 0; k < len; k++) {
1529
+ const px = pixelMaskIndices[start + k];
1530
+ const coverage = pixelMaskCoverages[start + k];
1531
+ data[px] = bg.r;
1532
+ data[px + 1] = bg.g;
1533
+ data[px + 2] = bg.b;
1534
+ data[px + 3] = Math.round(baseAlpha * coverage);
1477
1535
  }
1478
1536
  }
1479
- bgCtx.fill();
1480
1537
  }
1481
1538
  function sampleGradientRgb(stops, t) {
1482
1539
  const n = stops.length;
@@ -1538,43 +1595,38 @@ function createSarmalDotMatrix(canvas, curveDef, options) {
1538
1595
  }
1539
1596
  }
1540
1597
  function draw() {
1541
- ctx.clearRect(0, 0, W, H);
1542
- if (bgCanvas) {
1543
- ctx.drawImage(bgCanvas, 0, 0);
1598
+ if (!bgImageData || !frameImageData) {
1599
+ return;
1544
1600
  }
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();
1601
+ frameImageData.data.set(bgImageData.data);
1602
+ const { data } = frameImageData;
1603
+ const sineOffset = currentTrailStyle === "gradient-animated" ? 0.15 * Math.sin(animTime / ANIM_PERIOD * 2 * Math.PI) : 0;
1604
+ const n = cols * rows;
1605
+ for (let dotIdx = 0; dotIdx < n; dotIdx++) {
1606
+ const intensity = grid[dotIdx];
1607
+ if (intensity <= 0) {
1608
+ continue;
1609
+ }
1610
+ let r, g, b;
1611
+ if (gradientRgb !== null) {
1612
+ const t = Math.max(0, Math.min(1, intensity + sineOffset));
1613
+ ({ r, g, b } = sampleGradientRgb(gradientRgb, t));
1614
+ } else {
1615
+ ({ r, g, b } = colorRgb);
1616
+ }
1617
+ const baseA = (0.08 + intensity * 0.92) * 255;
1618
+ const start = pixelMaskStarts[dotIdx];
1619
+ const len = pixelMaskLengths[dotIdx];
1620
+ for (let k = 0; k < len; k++) {
1621
+ const px = pixelMaskIndices[start + k];
1622
+ const coverage = pixelMaskCoverages[start + k];
1623
+ data[px] = r;
1624
+ data[px + 1] = g;
1625
+ data[px + 2] = b;
1626
+ data[px + 3] = Math.round(baseA * coverage);
1575
1627
  }
1576
1628
  }
1577
- ctx.globalAlpha = 1;
1629
+ ctx.putImageData(frameImageData, 0, 0);
1578
1630
  }
1579
1631
  function renderFrame(deltaTime) {
1580
1632
  if (engine.morphAlpha !== null) {
@@ -1605,7 +1657,9 @@ function createSarmalDotMatrix(canvas, curveDef, options) {
1605
1657
  animationId = requestAnimationFrame(loop);
1606
1658
  }
1607
1659
  calculateBoundaries(engine.getSarmalSkeleton());
1608
- buildBgCanvas();
1660
+ computePixelMask();
1661
+ frameImageData = new ImageData(W, H);
1662
+ buildBgImageData();
1609
1663
  if (initialPhase !== void 0) {
1610
1664
  engine.seek(initialPhase);
1611
1665
  }
@@ -1691,7 +1745,7 @@ function createSarmalDotMatrix(canvas, curveDef, options) {
1691
1745
  }
1692
1746
  }
1693
1747
  if (needsRebuildBg) {
1694
- buildBgCanvas();
1748
+ buildBgImageData();
1695
1749
  }
1696
1750
  if (currentTrailStyle !== "default" && gradientRgb === null) {
1697
1751
  console.warn(