@moku-labs/game 0.15.6 → 0.15.8

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.
@@ -598,6 +598,16 @@ const DROP_SHARE = .1;
598
598
  const DWELL_BASE_MS = 5e3;
599
599
  /** The dwell never grows past this, ms. */
600
600
  const DWELL_MAX_MS = 12e4;
601
+ /** The render scales the adaptive rule steps through, highest first: shares of the canvas resolution. */
602
+ const SCALE_STEPS = Object.freeze([
603
+ 1,
604
+ .85,
605
+ .75,
606
+ .6,
607
+ .5
608
+ ]);
609
+ /** No scale to step through: the renderer allows none. */
610
+ const NO_SCALES = Object.freeze([]);
601
611
  /** The thermal statuses in order of heat: Android's `THERMAL_STATUS_*` numbers are their indexes. */
602
612
  const THERMAL_STATUSES = [
603
613
  "none",
@@ -632,6 +642,22 @@ function ladderOf(displayHz, maxFps, floor) {
632
642
  return Object.freeze(steps);
633
643
  }
634
644
  /**
645
+ * The render scales the rule may choose down to a floor, highest first, frozen; none when 1 alone reaches it. A floor
646
+ * between two steps stops at the step above it (the renderer's config only takes a step). Allocates: called when the
647
+ * renderer allows a floor, never per frame.
648
+ *
649
+ * @param floor - The lowest scale the renderer allows.
650
+ * @returns The scales.
651
+ * @example
652
+ * ```ts
653
+ * scaleStepsOf(0.75); // [1, 0.85, 0.75]
654
+ * ```
655
+ */
656
+ function scaleStepsOf(floor) {
657
+ const steps = SCALE_STEPS.filter((step) => step >= floor);
658
+ return steps.length > 1 ? Object.freeze(steps) : NO_SCALES;
659
+ }
660
+ /**
635
661
  * The index in `DISPLAY_RATES` a tick interval votes for: the rate whose period is within `DISPLAY_TOLERANCE` of
636
662
  * it, or -1. An indexed loop: no allocation.
637
663
  *
@@ -789,17 +815,20 @@ function thermalTarget(ladder, status, fps) {
789
815
  //#region src/plugins/time/rate.ts
790
816
  /**
791
817
  * @file time — the adaptive frame rate (spec 21 G3–G6): the governor that drops to the next divisor of the display
792
- * rate when drawn frames come late and returns one step after steady room, the display rate learned from the ticks
793
- * in every app (and with the governor from the native shell), the thermal input, and `setRate`. Every number of the
794
- * per-frame path lives in the governor and the display state, allocated once; a frame allocates nothing here. Only a
795
- * change allocates: its payload, the snapshot, the request.
818
+ * rate when drawn frames come late and returns one step after steady room, on a GPU-bound screen the render scale
819
+ * stepped before the rate, the back-off of failed returns, the display rate learned from the ticks in every app (and
820
+ * with the governor from the native shell), the thermal input, and `setRate`. Every number of the per-frame path lives
821
+ * in the governor and the display state, allocated once; a frame allocates nothing here. Only a change allocates: its
822
+ * payload, the snapshot, the request.
796
823
  */
797
- /** The ladder before the display rate is known, and always without `adaptive`. */
824
+ /** The ladder before the display rate is known, and always without `adaptive`; the scales the renderer allows none of. */
798
825
  const NO_STEPS = Object.freeze([]);
799
826
  /** Bit of a sample that came late. */
800
827
  const MISSED = 1;
801
828
  /** Bit of a sample whose frame was heavy for the higher rate. */
802
829
  const HEAVY = 2;
830
+ /** Bit of a late sample whose frame kept the main thread longer than its frame. */
831
+ const BUSY = 4;
803
832
  /** The thermal rank from which the rule never steps up: `"moderate"` and every hotter status. */
804
833
  const HEAT_LIMIT_RANK = 2;
805
834
  /** Milliseconds in a second: turns a rate into the period of one frame. */
@@ -852,6 +881,7 @@ function createGovernor(floor) {
852
881
  return {
853
882
  floor,
854
883
  ladder: NO_STEPS,
884
+ scales: NO_STEPS,
855
885
  thermal: null,
856
886
  lastDrawn: false,
857
887
  lastIdle: false,
@@ -865,12 +895,14 @@ function createGovernor(floor) {
865
895
  dropTail: 0,
866
896
  dropDrawn: 0,
867
897
  dropMissed: 0,
898
+ dropBusy: 0,
868
899
  returnTail: 0,
869
900
  returnDrawn: 0,
870
901
  returnMissed: 0,
871
902
  returnHeavy: 0,
872
903
  switchedAt: Number.NEGATIVE_INFINITY,
873
904
  lastReturnAt: Number.NEGATIVE_INFINITY,
905
+ climbed: false,
874
906
  dwellMs: DWELL_BASE_MS,
875
907
  requested: 0,
876
908
  requestSeq: 0
@@ -906,7 +938,8 @@ function snapshotOf(rate) {
906
938
  ladder: governor?.ladder ?? NO_STEPS,
907
939
  thermal: governor?.thermal ?? null,
908
940
  asked,
909
- heldBy: asked !== null && hz !== null && asked > hz ? "panel" : null
941
+ heldBy: asked !== null && hz !== null && asked > hz ? "panel" : null,
942
+ scale: rate.scale
910
943
  });
911
944
  }
912
945
  /**
@@ -923,6 +956,7 @@ function createRateState(config) {
923
956
  const mode = floor === void 0 ? "fixed" : "auto";
924
957
  const parts = {
925
958
  fps: config.maxFps,
959
+ scale: 1,
926
960
  mode,
927
961
  reason: "start",
928
962
  display: createDisplayState(),
@@ -953,6 +987,7 @@ function emptyWindows(g) {
953
987
  g.dropTail = g.written;
954
988
  g.dropDrawn = 0;
955
989
  g.dropMissed = 0;
990
+ g.dropBusy = 0;
956
991
  g.returnTail = g.written;
957
992
  g.returnDrawn = 0;
958
993
  g.returnMissed = 0;
@@ -967,6 +1002,7 @@ function dropOldest(g) {
967
1002
  const flags = g.sampleFlags[g.dropTail % 512] ?? 0;
968
1003
  g.dropDrawn -= 1;
969
1004
  if ((flags & MISSED) !== 0) g.dropMissed -= 1;
1005
+ if ((flags & BUSY) !== 0) g.dropBusy -= 1;
970
1006
  g.dropTail += 1;
971
1007
  }
972
1008
  /**
@@ -1022,8 +1058,19 @@ function evidenceOf(g, reason) {
1022
1058
  };
1023
1059
  }
1024
1060
  /**
1025
- * The dwell's bookkeeping of a switch: a drop soon after a return doubles it (a failed return), a drop after a long
1026
- * hold resets it, a return notes when it happened, `setRate` starts it over; the display and the heat leave it.
1061
+ * Starts the back-off over: the base dwell, no return to judge.
1062
+ *
1063
+ * @param g - The governor.
1064
+ */
1065
+ function resetDwell(g) {
1066
+ g.dwellMs = DWELL_BASE_MS;
1067
+ g.lastReturnAt = Number.NEGATIVE_INFINITY;
1068
+ g.climbed = false;
1069
+ }
1070
+ /**
1071
+ * The dwell's bookkeeping of a switch: the first drop after a return judges it (held less than a minute it failed and
1072
+ * the dwell doubles, held longer the dwell starts over), the steps down after it leave the dwell, a return notes when
1073
+ * it happened, `setRate` starts it over; the display and the heat leave it.
1027
1074
  *
1028
1075
  * @param g - The governor.
1029
1076
  * @param reason - The reason of the switch.
@@ -1031,19 +1078,16 @@ function evidenceOf(g, reason) {
1031
1078
  */
1032
1079
  function keepDwell(g, reason, at) {
1033
1080
  switch (reason) {
1034
- case "missed": {
1035
- const held = at - g.lastReturnAt;
1036
- if (held < 1e4) g.dwellMs = Math.min(2 * g.dwellMs, DWELL_MAX_MS);
1037
- else if (held >= 6e4) g.dwellMs = DWELL_BASE_MS;
1081
+ case "missed":
1082
+ if (g.climbed) g.dwellMs = at - g.lastReturnAt < 6e4 ? Math.min(2 * g.dwellMs, DWELL_MAX_MS) : DWELL_BASE_MS;
1083
+ g.climbed = false;
1038
1084
  break;
1039
- }
1040
1085
  case "headroom":
1041
1086
  g.lastReturnAt = at;
1087
+ g.climbed = true;
1042
1088
  break;
1043
1089
  case "forced":
1044
- case "auto":
1045
- g.dwellMs = DWELL_BASE_MS;
1046
- g.lastReturnAt = Number.NEGATIVE_INFINITY;
1090
+ case "auto": resetDwell(g);
1047
1091
  }
1048
1092
  }
1049
1093
  /**
@@ -1082,22 +1126,25 @@ function requestRate(ctx) {
1082
1126
  followNativeRate(ctx, (answer) => readAnswer(ctx, g, seq, answer));
1083
1127
  }
1084
1128
  /**
1085
- * The one place the rate or its mode changes (the governor, the display, the heat and `setRate` call it). Nothing
1086
- * when both stay. Otherwise the dwell is kept, both windows are emptied and the next interval is skipped, the
1087
- * snapshot is rebuilt, `time:rate` is emitted with the evidence, the change is logged and the shell is asked for the
1088
- * new rate. The new cap applies from the next tick: the frame that is due still runs.
1129
+ * The one place the rate, the render scale or the mode changes (the governor, the display, the heat and `setRate`
1130
+ * call it). Nothing when all stay. Otherwise the dwell is kept, both windows are emptied and the next interval is
1131
+ * skipped, the snapshot is rebuilt, `time:rate` is emitted with the evidence, the change is logged and the shell is
1132
+ * asked for the new rate. The new cap applies from the next tick: the frame that is due still runs, at the new scale.
1089
1133
  *
1090
1134
  * @param ctx - Domain context of the time plugin.
1091
1135
  * @param to - The new rate.
1136
+ * @param scale - The new render scale.
1092
1137
  * @param mode - The new mode.
1093
1138
  * @param reason - Why.
1094
1139
  * @param at - When, on the page clock.
1095
1140
  */
1096
- function switchRate(ctx, to, mode, reason, at) {
1141
+ function switchRate(ctx, to, scale, mode, reason, at) {
1097
1142
  const { rate } = ctx.state;
1098
- if (to === rate.fps && mode === rate.mode) return;
1143
+ if (to === rate.fps && scale === rate.scale && mode === rate.mode) return;
1099
1144
  const g = rate.governor;
1100
1145
  const from = rate.fps;
1146
+ const fromScale = rate.scale;
1147
+ const scaleOnly = to === from && mode === rate.mode;
1101
1148
  const evidence = evidenceOf(g, reason);
1102
1149
  if (g !== void 0) {
1103
1150
  keepDwell(g, reason, at);
@@ -1106,6 +1153,7 @@ function switchRate(ctx, to, mode, reason, at) {
1106
1153
  emptyWindows(g);
1107
1154
  }
1108
1155
  rate.fps = to;
1156
+ rate.scale = scale;
1109
1157
  rate.mode = mode;
1110
1158
  rate.reason = reason;
1111
1159
  rate.snapshot = snapshotOf(rate);
@@ -1117,8 +1165,15 @@ function switchRate(ctx, to, mode, reason, at) {
1117
1165
  displayHz: rate.display.hz,
1118
1166
  thermal: g?.thermal ?? null,
1119
1167
  window: evidence,
1120
- dwellMs: g?.dwellMs ?? 0
1168
+ dwellMs: g?.dwellMs ?? 0,
1169
+ scale
1170
+ });
1171
+ if (scale !== fromScale) ctx.log.info("time: render scale", {
1172
+ from: fromScale,
1173
+ to: scale,
1174
+ reason
1121
1175
  });
1176
+ if (scaleOnly) return;
1122
1177
  ctx.log.info("time: frame rate", {
1123
1178
  from,
1124
1179
  to,
@@ -1137,7 +1192,7 @@ function applyThermal(ctx, g) {
1137
1192
  const { rate } = ctx.state;
1138
1193
  if (rate.mode !== "auto" || g.ladder.length === 0) return;
1139
1194
  const to = thermalTarget(g.ladder, g.thermal, rate.fps);
1140
- if (to < rate.fps) switchRate(ctx, to, "auto", "thermal", nowOf());
1195
+ if (to < rate.fps) switchRate(ctx, to, rate.scale, "auto", "thermal", nowOf());
1141
1196
  }
1142
1197
  /**
1143
1198
  * Stores a new thermal status of the shell and applies it. A status the shell sent wrong, and the same status
@@ -1182,7 +1237,7 @@ function learnDisplay(ctx, hz, at) {
1182
1237
  if (g !== void 0) g.ladder = ladderOf(hz, ctx.config.maxFps, g.floor);
1183
1238
  rate.snapshot = snapshotOf(rate);
1184
1239
  if (g === void 0 || rate.mode !== "auto") return;
1185
- if (!g.ladder.includes(rate.fps)) switchRate(ctx, g.ladder.find((step) => step <= rate.fps) ?? g.ladder.at(-1) ?? rate.fps, "auto", "display", at);
1240
+ if (!g.ladder.includes(rate.fps)) switchRate(ctx, g.ladder.find((step) => step <= rate.fps) ?? g.ladder.at(-1) ?? rate.fps, rate.scale, "auto", "display", at);
1186
1241
  if (first) applyThermal(ctx, g);
1187
1242
  }
1188
1243
  /**
@@ -1279,31 +1334,54 @@ function shouldDrop(g) {
1279
1334
  return g.dropMissed >= Math.max(8, Math.ceil(DROP_SHARE * g.dropDrawn));
1280
1335
  }
1281
1336
  /**
1282
- * Tells whether the return window has room to step up: no heat, the dwell passed, 90 drawn frames in three seconds
1283
- * with at most one late and a tenth heavy.
1337
+ * Tells whether the late frames of the drop window are GPU-bound: fewer than half of them kept the main thread longer
1338
+ * than their frame.
1339
+ *
1340
+ * @param g - The governor.
1341
+ * @returns True when the scale, not the rate, should give.
1342
+ */
1343
+ function gpuBound(g) {
1344
+ return 2 * g.dropBusy < g.dropMissed;
1345
+ }
1346
+ /**
1347
+ * Tells whether the return window has room to step up: no heat, the dwell passed (twice the dwell for a step of the
1348
+ * scale), 90 drawn frames in three seconds with at most one late, and for a step of the rate a tenth heavy at most.
1284
1349
  *
1285
1350
  * @param g - The governor.
1286
1351
  * @param at - The page time of the sample.
1287
- * @returns True when the rate may return one step.
1352
+ * @param scaleStep - The step up is one of the render scale: it costs the GPU, not the main thread.
1353
+ * @returns True when the rule may return one step.
1288
1354
  */
1289
- function canReturn(g, at) {
1290
- return thermalRank(g.thermal) < HEAT_LIMIT_RANK && at - g.switchedAt >= g.dwellMs && g.returnDrawn >= 90 && g.returnMissed <= 1 && g.returnHeavy <= Math.floor(.1 * g.returnDrawn);
1355
+ function canReturn(g, at, scaleStep) {
1356
+ return thermalRank(g.thermal) < HEAT_LIMIT_RANK && at - g.switchedAt >= (scaleStep ? 2 * g.dwellMs : g.dwellMs) && g.returnDrawn >= 90 && g.returnMissed <= 1 && (scaleStep || g.returnHeavy <= Math.floor(.1 * g.returnDrawn));
1291
1357
  }
1292
1358
  /**
1293
- * Decides on the windows after a sample: down one step when late intervals reach `max(8, 10 %)` of the second's,
1294
- * up one step after the dwell with 90 steady drawn frames in three seconds, at most one late and a tenth heavy, and
1295
- * no heat.
1359
+ * Decides on the windows after a sample. Down one step when late intervals reach `max(8, 10 %)` of the second's: the
1360
+ * render scale when the window is GPU-bound and a lower scale is allowed, the rate otherwise. Up one step in the
1361
+ * reverse of the way down, with 90 steady drawn frames in three seconds, at most one late and no heat: the rate first,
1362
+ * at the scale it dropped at, after the dwell and with a tenth heavy at most, then the scale after twice the dwell. A
1363
+ * probe of the higher rate so never starts at full scale, and its failure changes no resolution.
1296
1364
  *
1297
1365
  * @param ctx - Domain context of the time plugin.
1298
1366
  * @param g - The governor.
1299
1367
  * @param at - The page time of the sample.
1300
1368
  */
1301
1369
  function decide(ctx, g, at) {
1302
- const { fps } = ctx.state.rate;
1303
- const lower = stepBelow(g.ladder, fps);
1370
+ const { fps, scale } = ctx.state.rate;
1371
+ if (shouldDrop(g)) {
1372
+ const smaller = gpuBound(g) ? stepBelow(g.scales, scale) : void 0;
1373
+ const lower = stepBelow(g.ladder, fps);
1374
+ if (smaller !== void 0) switchRate(ctx, fps, smaller, "auto", "missed", at);
1375
+ else if (lower !== void 0) switchRate(ctx, lower, scale, "auto", "missed", at);
1376
+ return;
1377
+ }
1304
1378
  const higher = stepAbove(g.ladder, fps);
1305
- if (lower !== void 0 && shouldDrop(g)) switchRate(ctx, lower, "auto", "missed", at);
1306
- else if (higher !== void 0 && canReturn(g, at)) switchRate(ctx, higher, "auto", "headroom", at);
1379
+ if (higher !== void 0 && canReturn(g, at, false)) {
1380
+ switchRate(ctx, higher, scale, "auto", "headroom", at);
1381
+ return;
1382
+ }
1383
+ const larger = stepAbove(g.scales, scale);
1384
+ if (larger !== void 0 && canReturn(g, at, true)) switchRate(ctx, fps, larger, "auto", "headroom", at);
1307
1385
  }
1308
1386
  /**
1309
1387
  * Judges the interval from the last run frame's start to this one: the first after a change is skipped, one that is
@@ -1325,10 +1403,11 @@ function judgeFrame$1(ctx, g, intervalMs, at) {
1325
1403
  const higher = stepAbove(g.ladder, fps);
1326
1404
  const missed = intervalMs > missLineMs(fps, ctx.state.rate.display.hz ?? fps);
1327
1405
  const heavy = higher !== void 0 && g.lastWorkMs > .5 * SECOND_MS$1 / higher;
1406
+ const busy = missed && g.lastWorkMs > SECOND_MS$1 / fps;
1328
1407
  const slot = g.written % 512;
1329
1408
  evict$2(g, at);
1330
1409
  g.sampleAt[slot] = at;
1331
- g.sampleFlags[slot] = (missed ? MISSED : 0) | (heavy ? HEAVY : 0);
1410
+ g.sampleFlags[slot] = (missed ? MISSED : 0) | (heavy ? HEAVY : 0) | (busy ? BUSY : 0);
1332
1411
  g.written += 1;
1333
1412
  g.dropDrawn += 1;
1334
1413
  g.returnDrawn += 1;
@@ -1337,6 +1416,7 @@ function judgeFrame$1(ctx, g, intervalMs, at) {
1337
1416
  g.returnMissed += 1;
1338
1417
  }
1339
1418
  if (heavy) g.returnHeavy += 1;
1419
+ if (busy) g.dropBusy += 1;
1340
1420
  decide(ctx, g, at);
1341
1421
  }
1342
1422
  /**
@@ -1358,6 +1438,39 @@ function closeFrame(ctx, g, workMs) {
1358
1438
  g.lastWorkMs = Math.max(0, workMs - agentMs);
1359
1439
  }
1360
1440
  /**
1441
+ * Starts the governor over for a new scene: the back-off of failed returns goes back to the base dwell, both windows
1442
+ * are emptied and the next interval is skipped, so the new screen is judged on its own frames. The rate and the scale
1443
+ * stay. Nothing without a governor.
1444
+ *
1445
+ * @param rate - The rate state.
1446
+ */
1447
+ function sceneRate(rate) {
1448
+ const g = rate.governor;
1449
+ if (g === void 0) return;
1450
+ resetDwell(g);
1451
+ g.skipNext = true;
1452
+ emptyWindows(g);
1453
+ }
1454
+ /**
1455
+ * Sets the render scales the rule may step through, down to `floor`. A scale under the new floor goes up to it at
1456
+ * once, quietly: the caller draws at what it decided. Nothing without a governor.
1457
+ *
1458
+ * @param rate - The rate state.
1459
+ * @param floor - The lowest scale the renderer allows; 1 allows none.
1460
+ * @returns The frame rate after the call.
1461
+ */
1462
+ function allowScale(rate, floor) {
1463
+ const g = rate.governor;
1464
+ if (g === void 0) return rate.snapshot;
1465
+ g.scales = scaleStepsOf(floor);
1466
+ const lowest = g.scales.at(-1) ?? 1;
1467
+ if (rate.scale < lowest) {
1468
+ rate.scale = lowest;
1469
+ rate.snapshot = snapshotOf(rate);
1470
+ }
1471
+ return rate.snapshot;
1472
+ }
1473
+ /**
1361
1474
  * Restarts the tick history of the display after a resume and, with a governor, its windows, and skips the next
1362
1475
  * interval: a return from the background is a new situation. The display rate already known stays.
1363
1476
  *
@@ -1403,10 +1516,10 @@ function resumeAuto(ctx) {
1403
1516
  const { maxFps } = ctx.config;
1404
1517
  const g = ctx.state.rate.governor;
1405
1518
  if (g === void 0) {
1406
- switchRate(ctx, maxFps, "fixed", "auto", nowOf());
1519
+ switchRate(ctx, maxFps, 1, "fixed", "auto", nowOf());
1407
1520
  return;
1408
1521
  }
1409
- switchRate(ctx, autoRate(g, maxFps), "auto", "auto", nowOf());
1522
+ switchRate(ctx, autoRate(g, maxFps), 1, "auto", "auto", nowOf());
1410
1523
  }
1411
1524
  /**
1412
1525
  * Forces the rate (the exact cap, never above `maxFps`) or gives it back to the config with `"auto"`.
@@ -1420,7 +1533,7 @@ function setRateChoice(ctx, choice) {
1420
1533
  const value = choice;
1421
1534
  if (!isChoice(value)) throw new Error(`[game] time.setRate takes "auto", 30, 60 or 120.\n Pass one of them, not ${String(value)}.`);
1422
1535
  const { rate } = ctx.state;
1423
- if (value !== "auto") switchRate(ctx, Math.min(value, ctx.config.maxFps), "forced", "forced", nowOf());
1536
+ if (value !== "auto") switchRate(ctx, Math.min(value, ctx.config.maxFps), 1, "forced", "forced", nowOf());
1424
1537
  else if (rate.mode === "forced") resumeAuto(ctx);
1425
1538
  return rate.snapshot;
1426
1539
  }
@@ -1694,7 +1807,7 @@ function countFrameListeners(ctx) {
1694
1807
  }
1695
1808
  /**
1696
1809
  * Creates the time API: frame callbacks per phase, the `Time` resource, scale, pause and
1697
- * resume, `wake` for the idle cap, the measurement of an agent build, the frame rate, and `step`
1810
+ * resume, `wake` for the idle cap, the measurement of an agent build, the frame rate and its render scale, and `step`
1698
1811
  * for tests and tools.
1699
1812
  *
1700
1813
  * @param ctx - Domain context of the time plugin.
@@ -1744,6 +1857,8 @@ function createTimeApi(ctx) {
1744
1857
  },
1745
1858
  rate: () => ctx.state.rate.snapshot,
1746
1859
  setRate: (choice) => setRateChoice(ctx, choice),
1860
+ allowScale: (floor) => allowScale(ctx.state.rate, floor),
1861
+ sceneChanged: () => sceneRate(ctx.state.rate),
1747
1862
  frameDrawn: () => {
1748
1863
  ctx.state.rate.drawnNow = true;
1749
1864
  },
@@ -1907,7 +2022,7 @@ const timePlugin = /*#__PURE__*/ createPlugin$1("time", {
1907
2022
  },
1908
2023
  events: (register) => register.map({
1909
2024
  "time:thermal": "The native shell reported a thermal reading",
1910
- "time:rate": "The loop's frame rate changed"
2025
+ "time:rate": "The loop's frame rate or render scale changed"
1911
2026
  }),
1912
2027
  createState: createTimeState,
1913
2028
  api: createTimeApi,
@@ -3222,7 +3337,7 @@ function deliver$1(state, answer) {
3222
3337
  * @param ctx - Domain context of the flow plugin.
3223
3338
  * @param answer - The answer the closed gate refused.
3224
3339
  */
3225
- function hold(ctx, answer) {
3340
+ function hold$1(ctx, answer) {
3226
3341
  if (ctx.state.fx.mode === "fast") return;
3227
3342
  if (!ctx.deps.time.isRunning()) return;
3228
3343
  if (ctx.config.holdMs <= 0 && ctx.state.gate.held !== void 0) return;
@@ -3261,7 +3376,7 @@ function createGateApi(ctx) {
3261
3376
  return {
3262
3377
  answer: (answer) => {
3263
3378
  if (ctx.state.gate.open === void 0) {
3264
- hold(ctx, answer);
3379
+ hold$1(ctx, answer);
3265
3380
  return false;
3266
3381
  }
3267
3382
  if (!accepts(ctx.state.gate, answer)) return false;
@@ -5391,7 +5506,7 @@ function startDeadline(timeoutMs) {
5391
5506
  * @param running - The promise of `run()`.
5392
5507
  * @returns `true` when the loop settled, `false` when the deadline won and the loop still runs.
5393
5508
  */
5394
- async function settle$1(ctx, running) {
5509
+ async function settle$2(ctx, running) {
5395
5510
  const quiet = running.then(noop, noop);
5396
5511
  const deadline = startDeadline(ctx.config.settleTimeoutMs);
5397
5512
  try {
@@ -5420,7 +5535,7 @@ async function stopRunner(ctx) {
5420
5535
  stop.abort("stop");
5421
5536
  state.abort = stop;
5422
5537
  }
5423
- const settled = running === void 0 || await settle$1(ctx, running);
5538
+ const settled = running === void 0 || await settle$2(ctx, running);
5424
5539
  state.running = void 0;
5425
5540
  ctx.state.gate.held = void 0;
5426
5541
  if (settled) state.abort = void 0;
@@ -12845,7 +12960,541 @@ function installRebindingRenderGroupPipe(pixi) {
12845
12960
  };
12846
12961
  }
12847
12962
  //#endregion
12963
+ //#region src/plugins/renderer/host/render-pass.ts
12964
+ /** Pixi's `CLEAR.NONE`. */
12965
+ const CLEAR_NONE = 0;
12966
+ /** Pixi's `CLEAR.ALL`: colour, depth and stencil. */
12967
+ const CLEAR_ALL = 17664;
12968
+ /** Pixi's `CLEAR.COLOR`. */
12969
+ const CLEAR_COLOR = 16384;
12970
+ /**
12971
+ * The `CLEAR` bits of a pass's clear, as Pixi reads a boolean. `clearBits(true); // 17664`
12972
+ *
12973
+ * @param clear - The clear Pixi was handed.
12974
+ * @returns The bits.
12975
+ */
12976
+ function clearBits(clear) {
12977
+ if (clear === true) return CLEAR_ALL;
12978
+ if (clear === false) return CLEAR_NONE;
12979
+ return clear;
12980
+ }
12981
+ /**
12982
+ * Builds the classes of the lazy canvas pass from the module object, at call time, because the bases arrive with the
12983
+ * lazily loaded module. Each system keeps its base's `static extension` object as it is, so the registry files it
12984
+ * under the same type and name. They cost one test of two booleans per pipeline request, and nothing else per draw.
12985
+ *
12986
+ * @param pixi - The loaded Pixi module.
12987
+ * @returns The render target system and the pipeline system.
12988
+ */
12989
+ function createLazyCanvasClasses(pixi) {
12990
+ /**
12991
+ * Pixi's WebGPU adaptor, which begins a pass on the canvas at its first draw and sets the canvas's stencil and depth
12992
+ * operations by the rule of the file. One per renderer.
12993
+ */
12994
+ class LazyCanvasAdaptor extends pixi.GpuRenderTargetAdaptor {
12995
+ /** The canvas is bound and its pass has not begun: the pipeline system begins it at the first draw. */
12996
+ pending = false;
12997
+ /** A pipeline that tests or writes depth was asked for: the canvas keeps its depth from then on. */
12998
+ depthUsed = false;
12999
+ /** The renderer: Pixi's own `_renderer` is private. */
13000
+ gpu = void 0;
13001
+ /** The render target system: Pixi's own `_renderTargetSystem` is private. */
13002
+ targets = void 0;
13003
+ /** The renderer's pipeline system is the lazy one: only then does a pass wait for a draw. */
13004
+ hooked = false;
13005
+ /** The canvas whose pass is pending or whose clear is owed. */
13006
+ canvas = void 0;
13007
+ /** The `CLEAR` bits the canvas owes. */
13008
+ owed = CLEAR_NONE;
13009
+ /** The colour of the owed clear, copied, when one was given. */
13010
+ owedColor = [
13011
+ 0,
13012
+ 0,
13013
+ 0,
13014
+ 0
13015
+ ];
13016
+ /** `owedColor` holds the colour of the owed clear. */
13017
+ owedColorSet = false;
13018
+ /** The viewport of the canvas's pass to come, copied from the bind that deferred it. */
13019
+ view = new pixi.Rectangle();
13020
+ /** The canvas whose pass this adaptor began and has not ended. */
13021
+ openCanvas = void 0;
13022
+ /** The open canvas pass stores its stencil. */
13023
+ keepStencil = false;
13024
+ /** The open canvas pass stores its depth. */
13025
+ keepDepth = false;
13026
+ /** The pass being begun stores its stencil. */
13027
+ planStencil = false;
13028
+ /** The pass being begun stores its depth. */
13029
+ planDepth = false;
13030
+ /** The canvas pass that ended last stored its depth. */
13031
+ depthStored = false;
13032
+ /** A canvas pass began since the last `renderEnd`. */
13033
+ acquired = false;
13034
+ /** A mask was pushed on the canvas in the last frame drawn on it. */
13035
+ maskedBefore = false;
13036
+ /** A mask was pushed on the canvas in this frame. */
13037
+ maskedNow = false;
13038
+ /** The root bind of the canvas emitted no target change: the stencil system hears of it when its pass begins. */
13039
+ changeOwed = false;
13040
+ /**
13041
+ * Keeps the renderer and the render target system, then lets Pixi subscribe the adaptor.
13042
+ *
13043
+ * @param renderer - The WebGPU renderer.
13044
+ * @param renderTargetSystem - Its render target system.
13045
+ */
13046
+ init(renderer, renderTargetSystem) {
13047
+ super.init(renderer, renderTargetSystem);
13048
+ this.gpu = renderer;
13049
+ this.targets = renderTargetSystem;
13050
+ }
13051
+ /** Forgets every pass of the old device, then lets Pixi drop its restore helper. */
13052
+ contextChange() {
13053
+ super.contextChange();
13054
+ this.pending = false;
13055
+ this.canvas = void 0;
13056
+ this.owed = CLEAR_NONE;
13057
+ this.owedColorSet = false;
13058
+ this.openCanvas = void 0;
13059
+ this.depthStored = false;
13060
+ this.changeOwed = false;
13061
+ }
13062
+ /**
13063
+ * Tells whether the root bind of a render may skip its target change: the root is a canvas whose pass is deferred.
13064
+ * The adaptor then owes the stencil system the change, until the canvas pass begins or another target is bound.
13065
+ *
13066
+ * @param root - The root target of the render.
13067
+ * @returns True when the bind may skip the change.
13068
+ */
13069
+ owesChange(root) {
13070
+ this.changeOwed = this.defers(root);
13071
+ return this.changeOwed;
13072
+ }
13073
+ /**
13074
+ * Begins a pass as Pixi does on any target but the canvas inside a frame. On the canvas it reuses an open pass as
13075
+ * Pixi does, and otherwise ends the open pass and defers the canvas's, its clear owed.
13076
+ *
13077
+ * @param renderTarget - The target.
13078
+ * @param clear - What to clear.
13079
+ * @param clearColor - The colour to clear to.
13080
+ * @param viewport - The viewport.
13081
+ * @param mipLevel - The mip level.
13082
+ * @param layer - The array layer.
13083
+ */
13084
+ startRenderPass(renderTarget, clear = true, clearColor, viewport, mipLevel = 0, layer = 0) {
13085
+ if (viewport === void 0 || mipLevel !== 0 || layer !== 0 || !this.defers(renderTarget)) {
13086
+ this.pending = false;
13087
+ this.openCanvas = void 0;
13088
+ this.changeOwed = false;
13089
+ super.startRenderPass(renderTarget, clear, clearColor, viewport, mipLevel, layer);
13090
+ return;
13091
+ }
13092
+ const bits = clearBits(clear);
13093
+ if (this.canvas !== void 0 && this.canvas !== renderTarget) this.settleCanvas();
13094
+ if (bits === CLEAR_NONE && this.isOpen(renderTarget)) {
13095
+ this.beginCanvas(renderTarget, CLEAR_NONE, void 0, viewport);
13096
+ return;
13097
+ }
13098
+ this.finishRenderPass();
13099
+ this.canvas = renderTarget;
13100
+ this.pending = true;
13101
+ this.owe(bits, clearColor);
13102
+ this.view.copyFrom(viewport);
13103
+ }
13104
+ /** Ends the open pass, as Pixi does. */
13105
+ finishRenderPass() {
13106
+ super.finishRenderPass();
13107
+ this.openCanvas = void 0;
13108
+ }
13109
+ /**
13110
+ * Copies out of a render target, as Pixi does. A copy that reads or writes the canvas first pays the clear it owes,
13111
+ * or a first pass it never had; a canvas that had its pass in this render and owes nothing begins no empty one.
13112
+ *
13113
+ * @param source - The target copied from.
13114
+ * @param destination - The texture copied into.
13115
+ * @param from - The origin in the source.
13116
+ * @param size - The size of the copy.
13117
+ * @param to - The origin in the destination.
13118
+ * @returns The destination.
13119
+ */
13120
+ copyToTexture(source, destination, from, size, to) {
13121
+ const canvas = this.canvas;
13122
+ if (canvas !== void 0 && (source === canvas || destination.source === canvas.colorTexture) && (this.owed !== CLEAR_NONE || !this.acquired)) this.payOwed(canvas);
13123
+ return super.copyToTexture(source, destination, from, size, to);
13124
+ }
13125
+ /**
13126
+ * Copies a render target's depth, as Pixi does; a copy of the canvas's depth makes the canvas keep its depth from
13127
+ * then on, and pays the clear it owes first.
13128
+ *
13129
+ * @param source - The target copied from.
13130
+ * @param destination - The depth texture copied into.
13131
+ * @param from - The origin in the source.
13132
+ * @param size - The size of the copy.
13133
+ * @param to - The origin in the destination.
13134
+ */
13135
+ copyDepthTexture(source, destination, from, size, to) {
13136
+ if (this.isCanvas(source)) {
13137
+ this.depthUsed = true;
13138
+ if (source === this.canvas) this.payOwed(source);
13139
+ }
13140
+ super.copyDepthTexture(source, destination, from, size, to);
13141
+ }
13142
+ /** Begins the pending canvas pass, with the clear it owes: a draw is about to be recorded into it. */
13143
+ beginPending() {
13144
+ const target = this.canvas;
13145
+ if (!this.pending || target === void 0) return;
13146
+ const bits = this.owed;
13147
+ const color = this.owedColorSet ? this.owedColor : void 0;
13148
+ this.pending = false;
13149
+ this.canvas = void 0;
13150
+ this.owed = CLEAR_NONE;
13151
+ this.owedColorSet = false;
13152
+ this.beginCanvas(target, bits, color, this.view);
13153
+ }
13154
+ /**
13155
+ * A pipeline that tests or writes depth was asked for: the canvas keeps its depth from now on, and an open canvas
13156
+ * pass that discards it begins again, with the depth cleared, as nothing wrote it yet, and stored.
13157
+ */
13158
+ useDepth() {
13159
+ this.depthUsed = true;
13160
+ const canvas = this.openCanvas;
13161
+ if (canvas?.depthStencilAttachment !== void 0 && !this.keepDepth && this.passOpen()) this.restart(canvas);
13162
+ }
13163
+ /**
13164
+ * A stencil mask is about to be pushed on the bound target: on the canvas, the stencil is kept from now on.
13165
+ *
13166
+ * @param target - The bound target.
13167
+ */
13168
+ maskPushes(target) {
13169
+ if (this.isCanvas(target)) this.maskedNow = true;
13170
+ }
13171
+ /**
13172
+ * A stencil mask was pushed on the bound target: an open canvas pass that discards its stencil begins again, the
13173
+ * stencil cleared, as no mask was open, and stored, before the mask is drawn. A pending canvas pass whose target
13174
+ * change is owed begins now, so the stencil system knows its target before it sets the mask's mode.
13175
+ *
13176
+ * @param target - The bound target.
13177
+ */
13178
+ maskPushed(target) {
13179
+ if (this.changeOwed && this.pending && this.canvas === target) this.beginPending();
13180
+ else if (this.openCanvas === target && !this.keepStencil && this.passOpen()) this.restart(target);
13181
+ }
13182
+ /**
13183
+ * The end of a render call: pays the clear the canvas owes, gives a canvas that saw no pass one, so the frame
13184
+ * clears and presents it as Pixi's does, and moves the mask memory on after a frame drawn on the canvas.
13185
+ *
13186
+ * @param root - The root target of the render call.
13187
+ */
13188
+ settle(root) {
13189
+ this.settleCanvas();
13190
+ this.acquired = false;
13191
+ if (this.isCanvas(root)) {
13192
+ this.maskedBefore = this.maskedNow;
13193
+ this.maskedNow = false;
13194
+ }
13195
+ }
13196
+ /**
13197
+ * Tells whether a pass on a target waits for its first draw: the target is a canvas without multisampling, and the
13198
+ * renderer's pipeline system is the lazy one that begins it.
13199
+ *
13200
+ * @param target - The target.
13201
+ * @returns True when the pass is deferred.
13202
+ */
13203
+ defers(target) {
13204
+ if (!this.hooked) this.hooked = this.gpu?.pipeline instanceof LazyCanvasPipeline;
13205
+ return this.hooked && this.isCanvas(target);
13206
+ }
13207
+ /**
13208
+ * Tells whether a target is a canvas without multisampling: Pixi gives a canvas's GPU target its context.
13209
+ *
13210
+ * @param target - The target.
13211
+ * @returns True for such a canvas.
13212
+ */
13213
+ isCanvas(target) {
13214
+ const targets = this.targets;
13215
+ if (targets === void 0) return false;
13216
+ const gpuTarget = targets.getGpuRenderTarget(target);
13217
+ return gpuTarget.contexts.length > 0 && !gpuTarget.msaa;
13218
+ }
13219
+ /**
13220
+ * Tells whether a pass of the encoder is open.
13221
+ *
13222
+ * @returns True while one is.
13223
+ */
13224
+ passOpen() {
13225
+ const encoder = this.gpu?.encoder;
13226
+ return encoder !== void 0 && encoder.renderPassEncoder !== null;
13227
+ }
13228
+ /**
13229
+ * Tells whether the pass this adaptor began on a canvas is still open.
13230
+ *
13231
+ * @param target - The canvas.
13232
+ * @returns True when its pass is open.
13233
+ */
13234
+ isOpen(target) {
13235
+ return this.openCanvas === target && this.passOpen();
13236
+ }
13237
+ /**
13238
+ * Adds a clear to the one the canvas owes; a colour clear given a colour keeps a copy of it.
13239
+ *
13240
+ * @param bits - The `CLEAR` bits.
13241
+ * @param color - The colour, when one was given.
13242
+ */
13243
+ owe(bits, color) {
13244
+ const colour = bits & CLEAR_COLOR;
13245
+ this.owed |= bits;
13246
+ if (colour === 0) return;
13247
+ this.owedColorSet = Array.isArray(color);
13248
+ if (!Array.isArray(color)) return;
13249
+ this.owedColor[0] = color[0];
13250
+ this.owedColor[1] = color[1];
13251
+ this.owedColor[2] = color[2];
13252
+ this.owedColor[3] = color[3];
13253
+ }
13254
+ /**
13255
+ * Begins a pass on the canvas through Pixi, with the stencil and depth operations of the rule set on its
13256
+ * depth-stencil attachment first, where Pixi's descriptor reads them; a clear Pixi was asked for still wins. Pixi
13257
+ * reuses an open pass when nothing is cleared and nothing changed; a pass that really began is recorded.
13258
+ *
13259
+ * @param target - The canvas.
13260
+ * @param bits - The `CLEAR` bits.
13261
+ * @param color - The clear colour, when one was given.
13262
+ * @param viewport - The viewport.
13263
+ */
13264
+ beginCanvas(target, bits, color, viewport) {
13265
+ const gpu = this.gpu;
13266
+ const targets = this.targets;
13267
+ if (gpu === void 0 || targets === void 0) return;
13268
+ this.planDepthStencil(target, targets);
13269
+ const before = gpu.encoder.renderPassEncoder;
13270
+ if (viewport !== this.view) this.view.copyFrom(viewport);
13271
+ super.startRenderPass(target, bits, color, viewport, 0, 0);
13272
+ this.openCanvas = target;
13273
+ if (gpu.encoder.renderPassEncoder !== before) this.began(target, targets);
13274
+ }
13275
+ /**
13276
+ * Sets the stencil and depth operations of the rule on the canvas's depth-stencil attachment, made first when
13277
+ * Pixi would make it, and keeps what the pass stores.
13278
+ *
13279
+ * @param target - The canvas.
13280
+ * @param targets - The render target system.
13281
+ */
13282
+ planDepthStencil(target, targets) {
13283
+ if ((target.stencil || target.depth) && target.depthStencilAttachment === void 0) target.ensureDepthStencilTexture();
13284
+ const attachment = target.depthStencilAttachment;
13285
+ const masked = targets.getGpuRenderTarget(target).maskStackIndex !== 0;
13286
+ this.planStencil = masked || this.maskedBefore || this.maskedNow;
13287
+ this.planDepth = this.depthUsed && attachment !== void 0;
13288
+ if (attachment === void 0) return;
13289
+ attachment.stencilLoadOp = masked ? "load" : "clear";
13290
+ attachment.stencilStoreOp = this.planStencil ? "store" : "discard";
13291
+ attachment.depthLoadOp = this.depthStored ? "load" : "clear";
13292
+ attachment.depthStoreOp = this.planDepth ? "store" : "discard";
13293
+ }
13294
+ /**
13295
+ * Records a canvas pass that really began (Pixi did not reuse the open one), and tells the stencil system of the
13296
+ * canvas when the root bind owes it the change, now that there is a pass to set its reference on.
13297
+ *
13298
+ * @param target - The canvas.
13299
+ * @param targets - The render target system.
13300
+ */
13301
+ began(target, targets) {
13302
+ this.acquired = true;
13303
+ this.keepStencil = this.planStencil;
13304
+ this.keepDepth = this.planDepth;
13305
+ this.depthStored = this.planDepth;
13306
+ if (this.changeOwed) {
13307
+ this.changeOwed = false;
13308
+ targets.onRenderTargetChange.emit(target);
13309
+ }
13310
+ }
13311
+ /**
13312
+ * Begins and ends one pass on a canvas now, with the clear it owes, and leaves the canvas bound with nothing owed.
13313
+ *
13314
+ * @param target - The canvas.
13315
+ */
13316
+ payOwed(target) {
13317
+ const bits = this.owed;
13318
+ const color = this.owedColorSet ? this.owedColor : void 0;
13319
+ this.owed = CLEAR_NONE;
13320
+ this.owedColorSet = false;
13321
+ this.beginCanvas(target, bits, color, this.view);
13322
+ this.finishRenderPass();
13323
+ }
13324
+ /**
13325
+ * Ends the canvas's wait: pays the clear it owes, or a first pass it never had, and forgets it.
13326
+ */
13327
+ settleCanvas() {
13328
+ const target = this.canvas;
13329
+ if (target === void 0) return;
13330
+ if (this.owed !== CLEAR_NONE || !this.acquired) this.payOwed(target);
13331
+ this.pending = false;
13332
+ this.canvas = void 0;
13333
+ }
13334
+ /**
13335
+ * Begins the open canvas pass again with nothing cleared, by the rule as it stands now, and sets its stencil
13336
+ * reference again: a new pass starts from 0, and Pixi sets it only when the mode or the target changes.
13337
+ *
13338
+ * @param target - The canvas.
13339
+ */
13340
+ restart(target) {
13341
+ const gpu = this.gpu;
13342
+ const targets = this.targets;
13343
+ if (gpu === void 0 || targets === void 0) return;
13344
+ this.finishRenderPass();
13345
+ this.beginCanvas(target, CLEAR_NONE, void 0, this.view);
13346
+ gpu.encoder.setStencilReference(targets.getGpuRenderTarget(target).stencilReference);
13347
+ }
13348
+ }
13349
+ /** Pixi's WebGPU render target system over the lazy adaptor, which settles the canvas at the end of a render. */
13350
+ class LazyCanvasTargets extends pixi.GpuRenderTargetSystem {
13351
+ static extension = pixi.GpuRenderTargetSystem.extension;
13352
+ /** The adaptor, typed as the lazy one. */
13353
+ lazy;
13354
+ /**
13355
+ * Makes Pixi's system, then puts the lazy adaptor in the place of Pixi's.
13356
+ *
13357
+ * @param renderer - The WebGPU renderer.
13358
+ */
13359
+ constructor(renderer) {
13360
+ super(renderer);
13361
+ const lazy = new LazyCanvasAdaptor();
13362
+ lazy.init(renderer, this);
13363
+ this.adaptor = lazy;
13364
+ this.lazy = lazy;
13365
+ }
13366
+ /**
13367
+ * Starts a render as Pixi does; a root canvas whose pass is deferred is marked bound first, so Pixi's bind of it
13368
+ * emits no target change: the adaptor emits it when the pass begins.
13369
+ *
13370
+ * @param options - The root binding.
13371
+ */
13372
+ renderStart(options) {
13373
+ const root = this.getRenderTarget(options.target);
13374
+ if (this.renderTarget !== root && Boolean(root.flipY) === Boolean(options.flipY) && this.lazy.owesChange(root)) this.renderTarget = root;
13375
+ super.renderStart(options);
13376
+ }
13377
+ /** The end of a render call, before the encoder submits it: the canvas settles. */
13378
+ renderEnd() {
13379
+ this.lazy.settle(this.rootRenderTarget);
13380
+ }
13381
+ /** Makes sure the bound target has a stencil for a mask about to be pushed, and keeps it by the rule. */
13382
+ ensureDepthStencil() {
13383
+ const target = this.renderTarget;
13384
+ this.lazy.maskPushes(target);
13385
+ super.ensureDepthStencil();
13386
+ this.lazy.maskPushed(target);
13387
+ }
13388
+ }
13389
+ /** Pixi's WebGPU pipeline system, which begins a deferred canvas pass before a draw or a stencil reference needs it. */
13390
+ class LazyCanvasPipeline extends pixi.PipelineSystem {
13391
+ static extension = pixi.PipelineSystem.extension;
13392
+ /** The renderer: Pixi's own `_renderer` is private. */
13393
+ host;
13394
+ /** The lazy adaptor of the renderer, once found. */
13395
+ lazy = void 0;
13396
+ /**
13397
+ * Keeps the renderer it is made for.
13398
+ *
13399
+ * @param renderer - The WebGPU renderer.
13400
+ */
13401
+ constructor(renderer) {
13402
+ super(renderer);
13403
+ this.host = renderer;
13404
+ }
13405
+ /**
13406
+ * Notes a state that tests or writes depth, begins a pending canvas pass, then answers Pixi's pipeline. Its five
13407
+ * arguments are named, not spread: a rest array would be made at every draw.
13408
+ *
13409
+ * @param geometry - The geometry.
13410
+ * @param program - The program.
13411
+ * @param state - The state.
13412
+ * @param topology - The topology.
13413
+ * @param overrides - The shader overrides.
13414
+ * @returns The pipeline.
13415
+ */
13416
+ getPipeline(geometry, program, state, topology, overrides) {
13417
+ const lazy = this.lazy ?? this.link();
13418
+ if (lazy !== void 0) {
13419
+ if (!lazy.depthUsed && (state.depthTest || state.depthMask)) lazy.useDepth();
13420
+ if (lazy.pending) lazy.beginPending();
13421
+ }
13422
+ return super.getPipeline(geometry, program, state, topology, overrides);
13423
+ }
13424
+ /**
13425
+ * Begins a pending canvas pass, then answers Pixi's buffer names: the first call of a batch or a graphics draw.
13426
+ *
13427
+ * @param geometry - The geometry.
13428
+ * @param program - The program.
13429
+ * @returns The names of the buffers to bind.
13430
+ */
13431
+ getBufferNamesToBind(geometry, program) {
13432
+ this.beginPending();
13433
+ return super.getBufferNamesToBind(geometry, program);
13434
+ }
13435
+ /**
13436
+ * Begins a pending canvas pass, then sets the stencil mode: the stencil system sets its reference on the pass next.
13437
+ *
13438
+ * @param stencilMode - The stencil mode.
13439
+ */
13440
+ setStencilMode(stencilMode) {
13441
+ this.beginPending();
13442
+ super.setStencilMode(stencilMode);
13443
+ }
13444
+ /**
13445
+ * Begins a pending canvas pass, then answers Pixi's key: a render bundle is recorded or replayed next.
13446
+ *
13447
+ * @returns The key.
13448
+ */
13449
+ get bundleStateKey() {
13450
+ this.beginPending();
13451
+ return super.bundleStateKey;
13452
+ }
13453
+ /** Begins the pending canvas pass of the renderer, when there is one. */
13454
+ beginPending() {
13455
+ const lazy = this.lazy ?? this.link();
13456
+ if (lazy?.pending === true) lazy.beginPending();
13457
+ }
13458
+ /**
13459
+ * Finds the renderer's lazy adaptor, once its render target system exists.
13460
+ *
13461
+ * @returns The adaptor, or `undefined` while there is none.
13462
+ */
13463
+ link() {
13464
+ const adaptor = this.host.renderTarget.adaptor;
13465
+ if (adaptor instanceof LazyCanvasAdaptor) this.lazy = adaptor;
13466
+ return this.lazy;
13467
+ }
13468
+ }
13469
+ return {
13470
+ targets: LazyCanvasTargets,
13471
+ pipeline: LazyCanvasPipeline
13472
+ };
13473
+ }
13474
+ /**
13475
+ * Swaps Pixi's WebGPU render target and pipeline systems for the lazy canvas ones in the module's extension registry,
13476
+ * and registers Pixi's stencil system again after them: the registry appends a class it is given, and the stencil
13477
+ * system subscribes to the render target system when it is made, so it must come later. Every application made
13478
+ * afterwards defers its canvas pass. The registry is global to the module object, so the remover matters: it puts
13479
+ * Pixi's own classes back, the stencil system again last.
13480
+ *
13481
+ * @param pixi - The loaded Pixi module.
13482
+ * @returns The remover.
13483
+ * @throws {TypeError} When the module has none of the classes to subclass.
13484
+ */
13485
+ function installLazyCanvasPass(pixi) {
13486
+ const lazy = createLazyCanvasClasses(pixi);
13487
+ pixi.extensions.remove(pixi.GpuRenderTargetSystem, pixi.GpuStencilSystem, pixi.PipelineSystem);
13488
+ pixi.extensions.add(lazy.targets, pixi.GpuStencilSystem, lazy.pipeline);
13489
+ return () => {
13490
+ pixi.extensions.remove(lazy.targets, pixi.GpuStencilSystem, lazy.pipeline);
13491
+ pixi.extensions.add(pixi.GpuRenderTargetSystem, pixi.GpuStencilSystem, pixi.PipelineSystem);
13492
+ };
13493
+ }
13494
+ //#endregion
12848
13495
  //#region src/plugins/renderer/host/resolution.ts
13496
+ /** The short side, in CSS pixels, at or under which a coarse pointer is a phone: the rule of `effects.phone`. */
13497
+ const PHONE_SIDE$1 = 820;
12849
13498
  /**
12850
13499
  * Device pixels per CSS pixel of the window; 1 without a window. Moved here from `host/init.ts`.
12851
13500
  *
@@ -12855,20 +13504,67 @@ function devicePixels() {
12855
13504
  return typeof globalThis.devicePixelRatio === "number" ? globalThis.devicePixelRatio : 1;
12856
13505
  }
12857
13506
  /**
12858
- * The resolution to draw at: the override, else the device pixels under the cap.
13507
+ * The resolution to draw at: the override, else the device pixels under the cap at the render scale.
12859
13508
  *
12860
13509
  * @param state - The resolution state of the host.
12861
13510
  * @param state.dpr - Device pixels per CSS pixel.
12862
13511
  * @param state.cap - The cap of `maxResolution`.
12863
13512
  * @param state.override - The switch of `monitor.setResolution`, when set.
13513
+ * @param state.scale - The render scale of the adaptive frame rate.
12864
13514
  * @returns The target resolution.
12865
13515
  * @example
12866
13516
  * ```ts
12867
- * targetResolution({ dpr: 2.81, cap: 2, override: undefined }); // 2
13517
+ * targetResolution({ dpr: 2.81, cap: 2, override: undefined, scale: 0.75 }); // 1.5
12868
13518
  * ```
12869
13519
  */
12870
13520
  function targetResolution(state) {
12871
- return state.override ?? Math.min(state.dpr, state.cap);
13521
+ return state.override ?? Math.min(state.dpr, state.cap) * state.scale;
13522
+ }
13523
+ /**
13524
+ * Tells a phone: a coarse pointer and a short side of at most 820 CSS px, the rule `effects` reads for `phone: "auto"`.
13525
+ * False without a window.
13526
+ *
13527
+ * @returns True on a phone.
13528
+ */
13529
+ function isPhone() {
13530
+ if (typeof globalThis.matchMedia !== "function") return false;
13531
+ return globalThis.matchMedia("(pointer: coarse)").matches && Math.min(globalThis.innerWidth, globalThis.innerHeight) <= PHONE_SIDE$1;
13532
+ }
13533
+ /**
13534
+ * The lowest render scale the adaptive frame rate may choose on this device: the floor of `adaptiveScale` where its
13535
+ * `devices` take this one, otherwise 1, none.
13536
+ *
13537
+ * @param config - The resolved plugin config.
13538
+ * @returns The floor; 1 allows no scale.
13539
+ */
13540
+ function scaleFloorOf(config) {
13541
+ const { adaptiveScale } = config;
13542
+ if (adaptiveScale === false) return 1;
13543
+ return adaptiveScale.devices === "all" || isPhone() ? adaptiveScale.floor : 1;
13544
+ }
13545
+ /**
13546
+ * Follows the render scale `time:rate` carries: remembers it and asks for the target with reason `"rate"`. The
13547
+ * override still wins.
13548
+ *
13549
+ * @param state - The host branch of the plugin state.
13550
+ * @param scale - The render scale the adaptive frame rate chose.
13551
+ * @returns True when a change is now pending.
13552
+ */
13553
+ function followScale(state, scale) {
13554
+ if (scale === state.resolution.scale) return false;
13555
+ state.resolution.scale = scale;
13556
+ return askResolution(state, targetResolution(state.resolution), "rate");
13557
+ }
13558
+ /**
13559
+ * Lets the adaptive frame rate step the render scale down to the floor of this device, or to none while the override
13560
+ * holds, and takes the scale it answers: a scale it withdrew is 1 again.
13561
+ *
13562
+ * @param ctx - Domain context of the renderer plugin.
13563
+ */
13564
+ function allowRuleScale(ctx) {
13565
+ const { resolution } = ctx.state.host;
13566
+ const floor = resolution.override === void 0 ? resolution.floor : 1;
13567
+ resolution.scale = ctx.deps.time.allowScale(floor).scale;
12872
13568
  }
12873
13569
  /**
12874
13570
  * The one mechanism: remembers a resolution for the next frame. Nothing is applied here. While no
@@ -13461,7 +14157,8 @@ function createPoolState() {
13461
14157
  /**
13462
14158
  * Creates the initial host state: no Pixi, no application, nothing drawn, nothing listening, no
13463
14159
  * counting, no bitmap text pipe and no render-group pipe installed, the resolution of a page without a window (device
13464
- * pixels 1, cap 2, no override, nothing pending), no own GPU, no kept picture and no pool trim yet.
14160
+ * pixels 1, cap 2, no override, nothing pending, the whole scale, no scale allowed yet), no own GPU, no kept picture
14161
+ * and no pool trim yet.
13465
14162
  *
13466
14163
  * @returns The host branch of the plugin state.
13467
14164
  */
@@ -13483,11 +14180,14 @@ function createHostState() {
13483
14180
  uninstallPasses: void 0,
13484
14181
  uninstallSdfText: void 0,
13485
14182
  uninstallRenderGroup: void 0,
14183
+ uninstallRenderPass: void 0,
13486
14184
  resolution: {
13487
14185
  dpr: 1,
13488
14186
  cap: 2,
13489
14187
  override: void 0,
13490
- pending: void 0
14188
+ pending: void 0,
14189
+ scale: 1,
14190
+ floor: 1
13491
14191
  },
13492
14192
  onResolutionChange: [],
13493
14193
  ownGpu: void 0,
@@ -13604,11 +14304,42 @@ function installRenderGroup(ctx, pixi) {
13604
14304
  }
13605
14305
  }
13606
14306
  /**
14307
+ * Swaps Pixi's WebGPU render target and pipeline systems for the renderer's, which begin the canvas's render pass at
14308
+ * its first draw and keep the canvas's stencil and depth only where something reads them (B3). Like the batched text it
14309
+ * runs before `new Application()`, once per host state, in every build. A Pixi without the pieces draws on with its own
14310
+ * systems, with a warning.
14311
+ *
14312
+ * @param ctx - Domain context of the renderer plugin.
14313
+ * @param pixi - The module the application is built from.
14314
+ */
14315
+ function installRenderPass(ctx, pixi) {
14316
+ const state = ctx.state.host;
14317
+ if (state.uninstallRenderPass !== void 0) return;
14318
+ try {
14319
+ state.uninstallRenderPass = installLazyCanvasPass(pixi);
14320
+ } catch (error) {
14321
+ ctx.log.warn("renderer: the canvas pass begins at render start", { error });
14322
+ }
14323
+ }
14324
+ /**
14325
+ * Tells whether a feature brings a filter that reads what was drawn under its view (`defineFilter({ backdrop: true })`).
14326
+ * Pixi's WebGL renderer copies that from its back buffer, and skips such a filter, with a warning, without one; the back
14327
+ * buffer costs a full-screen copy per drawn frame, so it is asked for only then. WebGPU copies from the canvas and
14328
+ * ignores the option.
14329
+ *
14330
+ * @param features - Every feature of the game, its sub-features included.
14331
+ * @returns True when one of their filters is a backdrop.
14332
+ */
14333
+ function wantsBackBuffer(features) {
14334
+ for (const feature of features) for (const entry of feature.description.filters ?? []) if ("backdrop" in entry.filter && entry.filter.backdrop === true) return true;
14335
+ return false;
14336
+ }
14337
+ /**
13607
14338
  * Loads Pixi once, creates the application and appends its canvas, at the target resolution of
13608
14339
  * now: a restore after a lost device comes back at it, override included. Pixi picks WebGL by
13609
14340
  * itself when WebGPU is missing, so only a device with neither ends up in the catch of the
13610
14341
  * caller. An agent build marks the Pixi load and the init on the page's timeline and hands Pixi
13611
- * the engine's own device.
14342
+ * the engine's own device. A game with a backdrop filter gets Pixi's WebGL back buffer.
13612
14343
  *
13613
14344
  * @param ctx - Domain context of the renderer plugin.
13614
14345
  * @param mount - Where the canvas goes.
@@ -13625,6 +14356,7 @@ async function createApplication(ctx, mount, deps) {
13625
14356
  installTextBatch(ctx, pixi);
13626
14357
  installCounting$1(ctx, pixi, deps);
13627
14358
  installRenderGroup(ctx, pixi);
14359
+ installRenderPass(ctx, pixi);
13628
14360
  state.resolution.dpr = devicePixels();
13629
14361
  state.resolution.cap = ctx.config.maxResolution;
13630
14362
  state.resolution.pending = void 0;
@@ -13636,7 +14368,8 @@ async function createApplication(ctx, mount, deps) {
13636
14368
  resolution: targetResolution(state.resolution),
13637
14369
  autoDensity: true,
13638
14370
  autoStart: false,
13639
- sharedTicker: false
14371
+ sharedTicker: false,
14372
+ useBackBuffer: wantsBackBuffer(ctx.deps.flow.features.all())
13640
14373
  };
13641
14374
  await (typeof __MOKU_GAME_AGENT__ !== "undefined" && __MOKU_GAME_AGENT__ ? initTimed(ctx, app, options) : app.init(options));
13642
14375
  state.app = app;
@@ -13668,8 +14401,8 @@ function destroyApplication(state) {
13668
14401
  /**
13669
14402
  * Undoes everything `init` created. Works on the state alone, because `onStop` has no context.
13670
14403
  * `ready` drops first, so a device that goes away during the teardown is ignored. The draw
13671
- * counter, the batched text and the render-group pipe swap Pixi's own classes back, so the next application on
13672
- * the page starts from them. The pool trim starts over and its listeners go; Pixi's pool itself is left.
14404
+ * counter, the batched text, the render-group pipe and the lazy canvas pass swap Pixi's own classes back, so the next
14405
+ * application on the page starts from them. The pool trim starts over and its listeners go; Pixi's pool itself is left.
13673
14406
  *
13674
14407
  * @param state - The host branch of the plugin state.
13675
14408
  */
@@ -13687,6 +14420,8 @@ function stopHost(state) {
13687
14420
  state.uninstallSdfText = void 0;
13688
14421
  state.uninstallRenderGroup?.();
13689
14422
  state.uninstallRenderGroup = void 0;
14423
+ state.uninstallRenderPass?.();
14424
+ state.uninstallRenderPass = void 0;
13690
14425
  state.unsupported?.remove();
13691
14426
  state.unsupported = void 0;
13692
14427
  state.onReady.length = 0;
@@ -14199,6 +14934,7 @@ function createHostApi(ctx, deps) {
14199
14934
  setResolution: (value, reason) => askResolution(state, value, reason),
14200
14935
  overrideResolution: (value) => {
14201
14936
  state.resolution.override = value;
14937
+ allowRuleScale(ctx);
14202
14938
  refreshResolution(ctx, "debug");
14203
14939
  return targetResolution(state.resolution);
14204
14940
  },
@@ -14256,6 +14992,8 @@ function createHostApi(ctx, deps) {
14256
14992
  watchDevice(ctx, restart);
14257
14993
  watchVisibility(ctx);
14258
14994
  watchPixelRatio(ctx);
14995
+ state.resolution.floor = scaleFloorOf(ctx.config);
14996
+ allowRuleScale(ctx);
14259
14997
  state.ready = true;
14260
14998
  for (const fn of state.onReady) fn();
14261
14999
  }
@@ -23634,7 +24372,7 @@ function destroyPools(state) {
23634
24372
  * view with `applyPose`.
23635
24373
  */
23636
24374
  /** How deep a parent chain is followed; deeper is treated as a loop. */
23637
- const MAX_DEPTH$5 = 32;
24375
+ const MAX_DEPTH$6 = 32;
23638
24376
  /**
23639
24377
  * The parent an entity moves with.
23640
24378
  *
@@ -23656,7 +24394,7 @@ function parentOf$1(ecs, entity) {
23656
24394
  */
23657
24395
  function isLeaving(ecs, entity) {
23658
24396
  let current = entity;
23659
- for (let depth = 0; depth < MAX_DEPTH$5 && current !== 0; depth += 1) {
24397
+ for (let depth = 0; depth < MAX_DEPTH$6 && current !== 0; depth += 1) {
23660
24398
  if (ecs.has(current, Exiting)) return true;
23661
24399
  current = parentOf$1(ecs, current);
23662
24400
  }
@@ -23762,7 +24500,7 @@ function rootPoseInto(ecs, entity, out, own) {
23762
24500
  out.pivot.x = start.pivot.x;
23763
24501
  out.pivot.y = start.pivot.y;
23764
24502
  let current = entity;
23765
- for (let depth = 0; depth < MAX_DEPTH$5; depth += 1) {
24503
+ for (let depth = 0; depth < MAX_DEPTH$6; depth += 1) {
23766
24504
  const parent = parentOf$1(ecs, current);
23767
24505
  if (parent === 0) break;
23768
24506
  const above = ecs.get(parent, Transform) ?? Transform.defaults;
@@ -24103,7 +24841,7 @@ const RETAKE_STREAK = 3;
24103
24841
  /** Frames in a row with no mark in it before a live range is kept as a picture again (spec 23's `CALM_AFTER`). */
24104
24842
  const CALM_FRAMES = 8;
24105
24843
  /** How deep a walk up the tree goes. */
24106
- const MAX_DEPTH$4 = 32;
24844
+ const MAX_DEPTH$5 = 32;
24107
24845
  /** CSS px a view may fall short of the viewport frame and still cover it: the reference frame rounds by a fraction. */
24108
24846
  const COVER_SLACK = 1;
24109
24847
  /** The whole-frame reasons after which a kept picture is taken again: the screen changed whole. */
@@ -24267,7 +25005,7 @@ function sameList$1(left, right) {
24267
25005
  */
24268
25006
  function inRange(set, node, root) {
24269
25007
  let current = node;
24270
- for (let depth = 0; depth < MAX_DEPTH$4 && current !== null && current !== root; depth += 1) {
25008
+ for (let depth = 0; depth < MAX_DEPTH$5 && current !== null && current !== root; depth += 1) {
24271
25009
  if (set.has(current)) return true;
24272
25010
  current = current.parent;
24273
25011
  }
@@ -27225,7 +27963,7 @@ function installPassCounting(pixi, sinks) {
27225
27963
  //#endregion
27226
27964
  //#region src/plugins/renderer/sync/hit-test.ts
27227
27965
  /** How deep a parent chain or a container tree is followed. */
27228
- const MAX_DEPTH$3 = 32;
27966
+ const MAX_DEPTH$4 = 32;
27229
27967
  /** Below this effective alpha a view is treated as not there. */
27230
27968
  const MIN_ALPHA = .01;
27231
27969
  /**
@@ -27307,7 +28045,7 @@ function clipBoxOf(sctx, entity) {
27307
28045
  function clippedOut(sctx, entity, x, y) {
27308
28046
  const ecs = sctx.ctx.deps.world.ecs;
27309
28047
  let current = parentOf$1(ecs, entity);
27310
- for (let depth = 0; depth < MAX_DEPTH$3 && current !== 0; depth += 1) {
28048
+ for (let depth = 0; depth < MAX_DEPTH$4 && current !== 0; depth += 1) {
27311
28049
  const box = clipBoxOf(sctx, current);
27312
28050
  if (box !== void 0 && !inBox(box, localPoint(sctx, current, x, y))) return true;
27313
28051
  current = parentOf$1(ecs, current);
@@ -27326,7 +28064,7 @@ function clippedOut(sctx, entity, x, y) {
27326
28064
  function effectiveAlpha(object, root, off) {
27327
28065
  let alpha = 1;
27328
28066
  let current = object;
27329
- for (let depth = 0; depth < MAX_DEPTH$3 && current !== void 0; depth += 1) {
28067
+ for (let depth = 0; depth < MAX_DEPTH$4 && current !== void 0; depth += 1) {
27330
28068
  if (current === root) return alpha;
27331
28069
  if (!current.visible || off?.has(current) === true) return 0;
27332
28070
  alpha *= current.alpha;
@@ -27356,7 +28094,7 @@ function isShown$1(object, root, off) {
27356
28094
  * @returns The same array.
27357
28095
  */
27358
28096
  function topFirst(container, out, depth = 0) {
27359
- if (depth >= MAX_DEPTH$3) return out;
28097
+ if (depth >= MAX_DEPTH$4) return out;
27360
28098
  const ordered = container.children.toSorted((left, right) => left.zIndex - right.zIndex);
27361
28099
  for (let index = ordered.length - 1; index >= 0; index -= 1) {
27362
28100
  const child = ordered[index];
@@ -27445,7 +28183,7 @@ function hitAll(sctx, x, y) {
27445
28183
  * hit test walks, so a box the doors report is the box a click reaches.
27446
28184
  */
27447
28185
  /** How deep a parent chain is followed; deeper is treated as a loop. */
27448
- const MAX_DEPTH$2 = 32;
28186
+ const MAX_DEPTH$3 = 32;
27449
28187
  /**
27450
28188
  * The four corners of a box, clockwise from the top-left one.
27451
28189
  *
@@ -27539,7 +28277,7 @@ function boundsOf(sctx, entity) {
27539
28277
  function layerOf(sctx, entity) {
27540
28278
  const ecs = sctx.ctx.deps.world.ecs;
27541
28279
  let top = entity;
27542
- for (let depth = 0; depth < MAX_DEPTH$2; depth += 1) {
28280
+ for (let depth = 0; depth < MAX_DEPTH$3; depth += 1) {
27543
28281
  const parent = parentOf$1(ecs, top);
27544
28282
  if (parent === 0) break;
27545
28283
  top = parent;
@@ -27954,7 +28692,7 @@ function stepParts(sctx) {
27954
28692
  //#endregion
27955
28693
  //#region src/plugins/renderer/sync/overlay.ts
27956
28694
  /** How deep a `Parent` chain or a walk up the tree goes. */
27957
- const MAX_DEPTH$1 = 32;
28695
+ const MAX_DEPTH$2 = 32;
27958
28696
  /** The owner `ui` gives the entities it spawns: the only overlays honoured (O4.8). */
27959
28697
  const UI_OWNER_NAME = "ui";
27960
28698
  /**
@@ -28023,7 +28761,7 @@ function clampInto(sctx, entity, into, raw) {
28023
28761
  function rootOf(sctx, entity) {
28024
28762
  const ecs = sctx.ctx.deps.world.ecs;
28025
28763
  let current = entity;
28026
- for (let depth = 0; depth < MAX_DEPTH$1; depth += 1) {
28764
+ for (let depth = 0; depth < MAX_DEPTH$2; depth += 1) {
28027
28765
  const parent = parentOf$1(ecs, current);
28028
28766
  if (parent === 0) return current;
28029
28767
  current = parent;
@@ -28236,7 +28974,7 @@ function drawnAlpha(sctx, record) {
28236
28974
  const alphaFilter = sctx.deps.host.pixi()?.AlphaFilter;
28237
28975
  let alpha = 1;
28238
28976
  let node = view.wrapper ?? view.object;
28239
- for (let depth = 0; depth < MAX_DEPTH$1 && node !== null; depth += 1) {
28977
+ for (let depth = 0; depth < MAX_DEPTH$2 && node !== null; depth += 1) {
28240
28978
  if (node === root) return alpha;
28241
28979
  if (!node.visible) return 0;
28242
28980
  const owner = state.viewOfTop.get(node);
@@ -29683,9 +30421,9 @@ function createRendererApi(ctx) {
29683
30421
  //#region src/plugins/renderer/handlers.ts
29684
30422
  /**
29685
30423
  * @file renderer plugin — the hooks: a pause hands the captures still waiting for a drawn frame
29686
- * their picture, since no frame comes while the clock is paused, and trims Pixi's texture pool. In an agent build the
29687
- * performance monitor also hears the pause stack, the flow path, the thermal readings and the
29688
- * changes of the frame rate.
30424
+ * their picture, since no frame comes while the clock is paused, and trims Pixi's texture pool; a change of the frame
30425
+ * rate brings the render scale the adaptive rule chose. In an agent build the performance monitor also hears the pause
30426
+ * stack, the flow path, the thermal readings, the changes of the frame rate and of the scale.
29689
30427
  */
29690
30428
  /**
29691
30429
  * Creates the hooks of the renderer. `time` is resolved when a hook fires, not while this factory
@@ -29743,13 +30481,23 @@ function createHandlers$5(ctx) {
29743
30481
  if (typeof __MOKU_GAME_AGENT__ !== "undefined" && __MOKU_GAME_AGENT__) monitorOf().thermal(reading);
29744
30482
  },
29745
30483
  /**
29746
- * Writes a change of the frame rate as a `rate` event line, the payload as its detail. Agent
29747
- * builds only.
30484
+ * Draws at the render scale the change carries from the next frame, in every build. An agent build writes the
30485
+ * change as a `rate` event line, the payload as its detail, and a move of the scale as a `scale` line.
29748
30486
  *
29749
30487
  * @param change - The change `time:rate` carried.
29750
30488
  */
29751
30489
  "time:rate": (change) => {
29752
- if (typeof __MOKU_GAME_AGENT__ !== "undefined" && __MOKU_GAME_AGENT__) monitorOf().event("rate", change);
30490
+ const from = ctx.state.host.resolution.scale;
30491
+ followScale(ctx.state.host, change.scale);
30492
+ if (typeof __MOKU_GAME_AGENT__ !== "undefined" && __MOKU_GAME_AGENT__) {
30493
+ monitorOf().event("rate", change);
30494
+ if (change.scale !== from) monitorOf().event("scale", {
30495
+ from,
30496
+ to: change.scale,
30497
+ reason: change.reason,
30498
+ fps: change.to
30499
+ });
30500
+ }
29753
30501
  }
29754
30502
  };
29755
30503
  }
@@ -29974,12 +30722,28 @@ function checkPoolTrim(poolTrim) {
29974
30722
  if (!isCount(aboveMb)) throw new Error(`[game] renderer.poolTrim.aboveMb is ${String(aboveMb)}.\n Give the MiB the targets must grow by, 0 or more.`);
29975
30723
  }
29976
30724
  /**
30725
+ * Checks the adaptive render scale of the config: `false`, or a floor that is a step of the scale and the devices it
30726
+ * applies to.
30727
+ *
30728
+ * @param adaptiveScale - What the config holds.
30729
+ * @throws {Error} When it is neither `false` nor an object, the floor is not 1, 0.85, 0.75, 0.6 or 0.5, or the
30730
+ * devices are neither `"phone"` nor `"all"`.
30731
+ */
30732
+ function checkAdaptiveScale(adaptiveScale) {
30733
+ if (adaptiveScale === false) return;
30734
+ if (typeof adaptiveScale !== "object" || adaptiveScale === null) throw new Error(`[game] renderer.adaptiveScale is ${String(adaptiveScale)}.\n Give { floor, devices } or false.`);
30735
+ const floor = "floor" in adaptiveScale ? adaptiveScale.floor : void 0;
30736
+ const devices = "devices" in adaptiveScale ? adaptiveScale.devices : void 0;
30737
+ if (typeof floor !== "number" || !SCALE_STEPS.includes(floor)) throw new Error(`[game] renderer.adaptiveScale.floor is ${String(floor)}.\n Give a step of the scale: 1, 0.85, 0.75, 0.6 or 0.5.`);
30738
+ if (devices !== "phone" && devices !== "all") throw new Error(`[game] renderer.adaptiveScale.devices is ${String(devices)}.\n Give "phone" or "all".`);
30739
+ }
30740
+ /**
29977
30741
  * Checks the numbers of the config a game can get wrong, at `createApp`, in every build: the
29978
- * resolution cap, the pool trim, the performance monitor, the budget of the kept pictures and the
29979
- * probe draws of the warm-up.
30742
+ * resolution cap, the pool trim, the adaptive render scale, the performance monitor, the budget of the kept pictures
30743
+ * and the probe draws of the warm-up.
29980
30744
  *
29981
30745
  * @param config - The resolved plugin config.
29982
- * @throws {Error} When `maxResolution` is not a number above 0, `poolTrim` fails its check,
30746
+ * @throws {Error} When `maxResolution` is not a number above 0, `poolTrim` or `adaptiveScale` fails its check,
29983
30747
  * `monitor.budgetMs` is neither `"rate"` nor a number above 0, `monitor.frames` is not a whole
29984
30748
  * number from 64 to 16384, `keptMb` is not a finite number of at least 0, or `warm` is not a
29985
30749
  * whole number from 0 to 16.
@@ -29989,6 +30753,7 @@ function checkConfig(config) {
29989
30753
  const { budgetMs, frames } = config.monitor;
29990
30754
  if (!Number.isFinite(maxResolution) || maxResolution <= 0) throw new Error(`[game] renderer.maxResolution is ${maxResolution}.\n Give a number above 0.`);
29991
30755
  checkPoolTrim(config.poolTrim);
30756
+ checkAdaptiveScale(config.adaptiveScale);
29992
30757
  if (budgetMs !== "rate" && !(Number.isFinite(budgetMs) && budgetMs > 0)) throw new Error(`[game] renderer.monitor.budgetMs is ${String(budgetMs)}.\n Give the frame budget in milliseconds, or "rate" for one frame of the current rate.`);
29993
30758
  if (!Number.isInteger(frames) || frames < RING_FRAMES.min || frames > RING_FRAMES.max) throw new Error(`[game] renderer.monitor.frames is ${frames}.\n Give a whole number from 64 to 16384.`);
29994
30759
  if (typeof keptMb !== "number" || !Number.isFinite(keptMb) || keptMb < 0) throw new Error(`[game] renderer.keptMb is ${String(keptMb)}.\n Give the MiB kept pictures may hold, or 0 to keep none.`);
@@ -30074,7 +30839,11 @@ const rendererPlugin = /*#__PURE__*/ createPlugin$1("renderer", {
30074
30839
  },
30075
30840
  onDemand: false,
30076
30841
  keptMb: 64,
30077
- warm: 1
30842
+ warm: 1,
30843
+ adaptiveScale: {
30844
+ floor: .75,
30845
+ devices: "phone"
30846
+ }
30078
30847
  },
30079
30848
  events: (register) => register.map({ "renderer:device-lost": "The GPU device or context was lost; the game is paused" }),
30080
30849
  createState: createRendererState,
@@ -35393,7 +36162,7 @@ async function settledRecord(state, bundle) {
35393
36162
  * @param size - The size of the decoded image.
35394
36163
  * @param mips - True for a texture uploaded with its full mip chain.
35395
36164
  */
35396
- function resize(target, size, mips) {
36165
+ function resize$1(target, size, mips) {
35397
36166
  if (target === void 0 || size === void 0) return;
35398
36167
  target.width = size.width;
35399
36168
  target.height = size.height;
@@ -35415,7 +36184,7 @@ function storeFont(io, record, owner, font, sizes) {
35415
36184
  const old = record.fonts.get(file.key);
35416
36185
  record.fonts.set(file.key, font);
35417
36186
  for (const page of old?.pages ?? []) io.destroyTexture(page);
35418
- for (const [at, texture] of font.pages.entries()) resize(pages[at], sizes.get(texture), false);
36187
+ for (const [at, texture] of font.pages.entries()) resize$1(pages[at], sizes.get(texture), false);
35419
36188
  file.mb = sumMb(pages);
35420
36189
  }
35421
36190
  /**
@@ -35440,7 +36209,7 @@ function store(io, record, fresh, sizes) {
35440
36209
  const old = record.textures.get(file.key);
35441
36210
  record.textures.set(file.key, fresh.texture);
35442
36211
  if (old !== void 0) io.destroyTexture(old);
35443
- resize(file, sizes.get(fresh.texture), file.mips === true);
36212
+ resize$1(file, sizes.get(fresh.texture), file.mips === true);
35444
36213
  }
35445
36214
  /**
35446
36215
  * Makes the numbers and the picture follow the files that were stored: the megabytes of the
@@ -35880,7 +36649,7 @@ function leavingOf(previous, scene) {
35880
36649
  * Builds the scene in one synchronous block: the layers, then the projections only the old scene
35881
36650
  * lists out, then the list of the new scene in (a projection both list stays and takes its new
35882
36651
  * place), the new scene to the world's systems, then the list the scene names to `assets`, then
35883
- * the frame loop out of its idle cap, and only then the event.
36652
+ * the frame loop out of its idle cap and the adaptive frame rate told of the new screen, and only then the event.
35884
36653
  *
35885
36654
  * @param ctx - Domain context of the plugin.
35886
36655
  * @param scene - The scene whose bundles are there.
@@ -35899,6 +36668,7 @@ function apply(ctx, scene, named) {
35899
36668
  state.named = named;
35900
36669
  ctx.deps.assets.sceneBundles(named);
35901
36670
  ctx.deps.time.wake();
36671
+ ctx.deps.time.sceneChanged();
35902
36672
  ctx.emit("scenes:changed", {
35903
36673
  from,
35904
36674
  to: scene.id,
@@ -37645,96 +38415,6 @@ function createEffectsApi(ctx) {
37645
38415
  };
37646
38416
  }
37647
38417
  //#endregion
37648
- //#region src/plugins/effects/filters/glsl.ts
37649
- /**
37650
- * The head of every filter's GLSL. `#version 300 es` comes first, so Pixi compiles both stages
37651
- * as GLSL ES 3.0 and the dev check compiles the same text; `highp` matches the `f32` of the WGSL
37652
- * twin and the precision of the uniforms the vertex stage shares. Then the texture coordinate of
37653
- * `defaultFilterVert`, the output, the input texture and the global filter uniforms as `vec4`.
37654
- */
37655
- const GLSL_HEADER = `#version 300 es
37656
- precision highp float;
37657
-
37658
- in vec2 vTextureCoord;
37659
- out vec4 finalColor;
37660
-
37661
- uniform sampler2D uTexture;
37662
- uniform vec4 uInputSize;
37663
- uniform vec4 uInputPixel;
37664
- uniform vec4 uInputClamp;
37665
- uniform vec4 uOutputFrame;
37666
- uniform vec4 uGlobalFrame;
37667
- uniform vec4 uOutputTexture;
37668
-
37669
- `;
37670
- /** The GLSL type of each uniform type: a number is a `float`, a colour or a tuple a vector. */
37671
- const GLSL_TYPES = {
37672
- f32: "float",
37673
- "vec2<f32>": "vec2",
37674
- "vec3<f32>": "vec3",
37675
- "vec4<f32>": "vec4"
37676
- };
37677
- /**
37678
- * One `uniform` line per uniform, in declaration order, under the bare names the group keys.
37679
- * Nothing without uniforms.
37680
- *
37681
- * @param uniforms - The uniforms in declaration order.
37682
- * @returns The GLSL lines, or `""`.
37683
- * @example
37684
- * ```ts
37685
- * glslUniforms([{ name: "color", type: "vec3<f32>", form: "color", size: 3 }]); // "uniform vec3 color;\n\n"
37686
- * ```
37687
- */
37688
- function glslUniforms(uniforms) {
37689
- if (uniforms.length === 0) return "";
37690
- return `${uniforms.map((uniform) => `uniform ${GLSL_TYPES[uniform.type]} ${uniform.name};\n`).join("")}\n`;
37691
- }
37692
- /**
37693
- * The engine's half of each input in GLSL: the texture, and the function that reads it at the body's uv, its scale
37694
- * and its clamp applied. The two uniforms it reads are declared before it by `glslUniforms`. Nothing without inputs.
37695
- *
37696
- * @param inputs - The inputs in declaration order.
37697
- * @returns The GLSL lines, or `""`.
37698
- * @example
37699
- * ```ts
37700
- * glslInputs([blurInput]).startsWith("uniform sampler2D blurTexture;"); // true
37701
- * ```
37702
- */
37703
- function glslInputs(inputs) {
37704
- return inputs.map((input) => `uniform sampler2D ${input.texture};\n\nvec4 ${input.sample}(vec2 uv) {\n return textureLod(${input.texture}, clamp(uv * uInputSize.xy * ${input.scale.name}, ${input.clamp.name}.xy, ${input.clamp.name}.zw), 0.0);\n}
37705
-
37706
- `).join("");
37707
- }
37708
- /** The GLSL twin of `canvasPixel(uv)`: the pixel's canvas position in CSS px (spec 23 P8.3). */
37709
- const GLSL_CANVAS_PIXEL = `vec2 canvasPixel(vec2 uv) {
37710
- return uOutputFrame.xy + keptOrigin + uv * uInputSize.xy;
37711
- }
37712
-
37713
- `;
37714
- /**
37715
- * The whole GLSL of a filter: the header, the uniforms (then the scale and the clamp of each input, then the
37716
- * engine's `keptOrigin` of a body that calls `canvasPixel`), the inputs, the helper, the body.
37717
- *
37718
- * @param uniforms - The uniforms in declaration order.
37719
- * @param inputs - The inputs in declaration order.
37720
- * @param body - The fragment body the game wrote.
37721
- * @param origin - The `keptOrigin` uniform, for a body that calls `canvasPixel`.
37722
- * @returns The GLSL fragment source the `GlProgram` is built from.
37723
- * @example
37724
- * ```ts
37725
- * assembleGlsl([], [], body).endsWith(body); // true
37726
- * ```
37727
- */
37728
- function assembleGlsl(uniforms, inputs, body, origin) {
37729
- const declared = glslUniforms([
37730
- ...uniforms,
37731
- ...inputs.flatMap((input) => [input.scale, input.clamp]),
37732
- ...origin === void 0 ? [] : [origin]
37733
- ]);
37734
- const helper = origin === void 0 ? "" : GLSL_CANVAS_PIXEL;
37735
- return `${GLSL_HEADER}${declared}${glslInputs(inputs)}${helper}${body}`;
37736
- }
37737
- //#endregion
37738
38418
  //#region src/plugins/effects/filters/wgsl.ts
37739
38419
  /**
37740
38420
  * The head of every filter shader: the global filter uniforms and the input texture and sampler
@@ -37811,30 +38491,165 @@ const CANVAS_PIXEL = `fn canvasPixel(uv: vec2<f32>) -> vec2<f32> {
37811
38491
  return gfu.uOutputFrame.xy + fu.keptOrigin + uv * gfu.uInputSize.xy;
37812
38492
  }
37813
38493
 
38494
+ `;
38495
+ /**
38496
+ * The engine's uniform a backdrop's `sampleBack` scales its uv by: one over the size of the back texture's backing,
38497
+ * CSS px. Filled by every apply from the texture Pixi copied.
38498
+ */
38499
+ const BACK_SCALE = Object.freeze({
38500
+ name: "backScale",
38501
+ type: "vec2<f32>",
38502
+ form: "vector",
38503
+ size: 2
38504
+ });
38505
+ /** The engine's uniform a backdrop's `sampleBack` clamps to: half a texel inside the copied part of the back texture. */
38506
+ const BACK_CLAMP = Object.freeze({
38507
+ name: "backClamp",
38508
+ type: "vec4<f32>",
38509
+ form: "vector",
38510
+ size: 4
38511
+ });
38512
+ /**
38513
+ * What a backdrop's body reads what was drawn under its view through: the texture Pixi's filter system copies the
38514
+ * canvas into and binds at group 0 binding 3 (`Filter.blendRequired`), sampled at the body's uv with the engine's
38515
+ * scale and clamp, as an input is.
38516
+ */
38517
+ const SAMPLE_BACK = `@group(0) @binding(3) var uBackTexture: texture_2d<f32>;
38518
+
38519
+ fn sampleBack(uv: vec2<f32>) -> vec4<f32> {
38520
+ return textureSampleLevel(uBackTexture, uSampler, clamp(uv * gfu.uInputSize.xy * fu.backScale, fu.backClamp.xy, fu.backClamp.zw), 0.0);
38521
+ }
38522
+
37814
38523
  `;
37815
38524
  /**
37816
38525
  * The whole source of a filter: the header, the uniform struct (the uniforms, then the scale and the clamp of each
37817
- * input, then the engine's `keptOrigin` of a body that calls `canvasPixel`), the input bindings, the helper, the
37818
- * body. A body without the helper assembles byte-identically to one without the cache.
38526
+ * input, then a backdrop's back scale and clamp, then the engine's `keptOrigin` of a body that calls `canvasPixel`),
38527
+ * the input bindings, a backdrop's `sampleBack`, the `canvasPixel` helper, the body. A body without either helper
38528
+ * assembles byte-identically to one without the cache and without the backdrop.
37819
38529
  *
37820
38530
  * @param uniforms - The uniforms in declaration order.
37821
38531
  * @param inputs - The inputs in declaration order.
37822
38532
  * @param body - The fragment body the game wrote.
37823
38533
  * @param origin - The `keptOrigin` uniform, for a body that calls `canvasPixel`.
38534
+ * @param backdrop - True for a filter that reads what was drawn under its view.
37824
38535
  * @returns The WGSL source both stages are built from.
37825
38536
  * @example
37826
38537
  * ```ts
37827
- * assemble([], [], body).endsWith(body); // true
38538
+ * assemble([], [], body, undefined, true).includes("fn sampleBack(uv: vec2<f32>) -> vec4<f32>"); // true
37828
38539
  * ```
37829
38540
  */
37830
- function assemble(uniforms, inputs, body, origin) {
38541
+ function assemble(uniforms, inputs, body, origin, backdrop = false) {
37831
38542
  const struct = uniformStruct([
37832
38543
  ...uniforms,
37833
38544
  ...inputs.flatMap((input) => [input.scale, input.clamp]),
38545
+ ...backdrop ? [BACK_SCALE, BACK_CLAMP] : [],
37834
38546
  ...origin === void 0 ? [] : [origin]
37835
38547
  ]);
38548
+ const back = backdrop ? SAMPLE_BACK : "";
37836
38549
  const helper = origin === void 0 ? "" : CANVAS_PIXEL;
37837
- return `${HEADER}${struct}${inputBindings(inputs)}${helper}${body}`;
38550
+ return `${HEADER}${struct}${inputBindings(inputs)}${back}${helper}${body}`;
38551
+ }
38552
+ //#endregion
38553
+ //#region src/plugins/effects/filters/glsl.ts
38554
+ /**
38555
+ * The head of every filter's GLSL. `#version 300 es` comes first, so Pixi compiles both stages
38556
+ * as GLSL ES 3.0 and the dev check compiles the same text; `highp` matches the `f32` of the WGSL
38557
+ * twin and the precision of the uniforms the vertex stage shares. Then the texture coordinate of
38558
+ * `defaultFilterVert`, the output, the input texture and the global filter uniforms as `vec4`.
38559
+ */
38560
+ const GLSL_HEADER = `#version 300 es
38561
+ precision highp float;
38562
+
38563
+ in vec2 vTextureCoord;
38564
+ out vec4 finalColor;
38565
+
38566
+ uniform sampler2D uTexture;
38567
+ uniform vec4 uInputSize;
38568
+ uniform vec4 uInputPixel;
38569
+ uniform vec4 uInputClamp;
38570
+ uniform vec4 uOutputFrame;
38571
+ uniform vec4 uGlobalFrame;
38572
+ uniform vec4 uOutputTexture;
38573
+
38574
+ `;
38575
+ /** The GLSL type of each uniform type: a number is a `float`, a colour or a tuple a vector. */
38576
+ const GLSL_TYPES = {
38577
+ f32: "float",
38578
+ "vec2<f32>": "vec2",
38579
+ "vec3<f32>": "vec3",
38580
+ "vec4<f32>": "vec4"
38581
+ };
38582
+ /**
38583
+ * One `uniform` line per uniform, in declaration order, under the bare names the group keys.
38584
+ * Nothing without uniforms.
38585
+ *
38586
+ * @param uniforms - The uniforms in declaration order.
38587
+ * @returns The GLSL lines, or `""`.
38588
+ * @example
38589
+ * ```ts
38590
+ * glslUniforms([{ name: "color", type: "vec3<f32>", form: "color", size: 3 }]); // "uniform vec3 color;\n\n"
38591
+ * ```
38592
+ */
38593
+ function glslUniforms(uniforms) {
38594
+ if (uniforms.length === 0) return "";
38595
+ return `${uniforms.map((uniform) => `uniform ${GLSL_TYPES[uniform.type]} ${uniform.name};\n`).join("")}\n`;
38596
+ }
38597
+ /**
38598
+ * The engine's half of each input in GLSL: the texture, and the function that reads it at the body's uv, its scale
38599
+ * and its clamp applied. The two uniforms it reads are declared before it by `glslUniforms`. Nothing without inputs.
38600
+ *
38601
+ * @param inputs - The inputs in declaration order.
38602
+ * @returns The GLSL lines, or `""`.
38603
+ * @example
38604
+ * ```ts
38605
+ * glslInputs([blurInput]).startsWith("uniform sampler2D blurTexture;"); // true
38606
+ * ```
38607
+ */
38608
+ function glslInputs(inputs) {
38609
+ return inputs.map((input) => `uniform sampler2D ${input.texture};\n\nvec4 ${input.sample}(vec2 uv) {\n return textureLod(${input.texture}, clamp(uv * uInputSize.xy * ${input.scale.name}, ${input.clamp.name}.xy, ${input.clamp.name}.zw), 0.0);\n}
38610
+
38611
+ `).join("");
38612
+ }
38613
+ /** The GLSL twin of `canvasPixel(uv)`: the pixel's canvas position in CSS px (spec 23 P8.3). */
38614
+ const GLSL_CANVAS_PIXEL = `vec2 canvasPixel(vec2 uv) {
38615
+ return uOutputFrame.xy + keptOrigin + uv * uInputSize.xy;
38616
+ }
38617
+
38618
+ `;
38619
+ /** The GLSL twin of `sampleBack(uv)`: what was drawn under the view, at the body's uv, scaled and clamped. */
38620
+ const GLSL_SAMPLE_BACK = `uniform sampler2D uBackTexture;
38621
+
38622
+ vec4 sampleBack(vec2 uv) {
38623
+ return textureLod(uBackTexture, clamp(uv * uInputSize.xy * backScale, backClamp.xy, backClamp.zw), 0.0);
38624
+ }
38625
+
38626
+ `;
38627
+ /**
38628
+ * The whole GLSL of a filter: the header, the uniforms (then the scale and the clamp of each input, then a backdrop's
38629
+ * back scale and clamp, then the engine's `keptOrigin` of a body that calls `canvasPixel`), the inputs, a backdrop's
38630
+ * `sampleBack`, the `canvasPixel` helper, the body.
38631
+ *
38632
+ * @param uniforms - The uniforms in declaration order.
38633
+ * @param inputs - The inputs in declaration order.
38634
+ * @param body - The fragment body the game wrote.
38635
+ * @param origin - The `keptOrigin` uniform, for a body that calls `canvasPixel`.
38636
+ * @param backdrop - True for a filter that reads what was drawn under its view.
38637
+ * @returns The GLSL fragment source the `GlProgram` is built from.
38638
+ * @example
38639
+ * ```ts
38640
+ * assembleGlsl([], [], body, undefined, true).includes("uniform sampler2D uBackTexture;"); // true
38641
+ * ```
38642
+ */
38643
+ function assembleGlsl(uniforms, inputs, body, origin, backdrop = false) {
38644
+ const declared = glslUniforms([
38645
+ ...uniforms,
38646
+ ...inputs.flatMap((input) => [input.scale, input.clamp]),
38647
+ ...backdrop ? [BACK_SCALE, BACK_CLAMP] : [],
38648
+ ...origin === void 0 ? [] : [origin]
38649
+ ]);
38650
+ const back = backdrop ? GLSL_SAMPLE_BACK : "";
38651
+ const helper = origin === void 0 ? "" : GLSL_CANVAS_PIXEL;
38652
+ return `${GLSL_HEADER}${declared}${glslInputs(inputs)}${back}${helper}${body}`;
37838
38653
  }
37839
38654
  //#endregion
37840
38655
  //#region src/plugins/effects/filters/define.ts
@@ -37859,9 +38674,10 @@ const MAIN_GLSL = /\bvoid\s+main\s*\(/;
37859
38674
  /**
37860
38675
  * Names a uniform cannot take: the two fields every filter component carries, and the names the
37861
38676
  * shader headers declare, which a game uniform would clash with (on WebGL only, for most). The
37862
- * GLSL header also declares `finalColor` and `vTextureCoord`.
38677
+ * GLSL header also declares `finalColor` and `vTextureCoord`; a backdrop's, `uBackTexture`,
38678
+ * `backScale`, `backClamp` and `sampleBack`.
37863
38679
  */
37864
- const RESERVED_NAME = /^(?:enabled|order|uInputSize|uInputPixel|uInputClamp|uOutputFrame|uGlobalFrame|uOutputTexture|uTexture|uSampler|finalColor|vTextureCoord|keptOrigin|canvasPixel)$/;
38680
+ const RESERVED_NAME = /^(?:enabled|order|uInputSize|uInputPixel|uInputClamp|uOutputFrame|uGlobalFrame|uOutputTexture|uTexture|uSampler|finalColor|vTextureCoord|keptOrigin|canvasPixel|uBackTexture|backScale|backClamp|sampleBack)$/;
37865
38681
  /**
37866
38682
  * What a body reads of the screen: the root target's frame, the fragment position, the output target's size, the
37867
38683
  * origin of the output frame (its size `.zw` is the same in a picture and live).
@@ -37945,21 +38761,22 @@ function worstOf(a, b) {
37945
38761
  }
37946
38762
  /**
37947
38763
  * What the kept-picture cache may do with a filter, from what the game declared and what its bodies read only, never
37948
- * the engine's header (LEAD-FIXES-4 item 3): `cache: false` is `"screen"`; a body that reads the screen and declares
37949
- * nothing is `"screen"` too (the safe direction); a body that calls `canvasPixel` is `"placed"`, as is
37950
- * `cache: "placed"`; everything else is `"local"`.
38764
+ * the engine's header (LEAD-FIXES-4 item 3): a backdrop and `cache: false` are `"screen"`; a body that reads the
38765
+ * screen and declares nothing is `"screen"` too (the safe direction); a body that calls `canvasPixel` is `"placed"`,
38766
+ * as is `cache: "placed"`; everything else is `"local"`.
37951
38767
  *
37952
38768
  * @param wgsl - The WGSL body.
37953
38769
  * @param glsl - The GLSL body.
37954
38770
  * @param cache - What the game declared.
38771
+ * @param backdrop - Whether the filter reads what was drawn under its view.
37955
38772
  * @returns The category.
37956
38773
  * @example
37957
38774
  * ```ts
37958
- * cacheOf("let p = gfu.uOutputFrame.xy + uv * gfu.uInputSize.xy;", "", undefined); // "screen"
38775
+ * cacheOf("let p = gfu.uOutputFrame.xy + uv * gfu.uInputSize.xy;", "", undefined, false); // "screen"
37959
38776
  * ```
37960
38777
  */
37961
- function cacheOf(wgsl, glsl, cache) {
37962
- if (cache === false) return "screen";
38778
+ function cacheOf(wgsl, glsl, cache, backdrop) {
38779
+ if (cache === false || backdrop) return "screen";
37963
38780
  if (cache === void 0 && (readsScreenIn(wgsl) || readsScreenIn(glsl))) return "screen";
37964
38781
  if (cache === "placed" || callsCanvasPixel(wgsl, glsl)) return "placed";
37965
38782
  return "local";
@@ -38026,6 +38843,48 @@ function readResolution(id, resolution) {
38026
38843
  throw new Error(`[game] Filter "${id}": resolution must be from 0 to 4, 0 for the resolution of the canvas.\n Got ${String(resolution)}.`);
38027
38844
  }
38028
38845
  /**
38846
+ * Reads the declared backdrop switch: `true`, `false` or left out. A JavaScript caller may pass anything.
38847
+ *
38848
+ * @param id - The filter id.
38849
+ * @param backdrop - What the game declared.
38850
+ * @returns True for a backdrop.
38851
+ * @throws {Error} When it is given and is not a boolean.
38852
+ */
38853
+ function readBackdrop(id, backdrop) {
38854
+ if (backdrop === void 0 || typeof backdrop === "boolean") return backdrop === true;
38855
+ throw new Error(`[game] Filter "${id}": backdrop must be true or false.\n Got ${JSON.stringify(backdrop)}.`);
38856
+ }
38857
+ /**
38858
+ * Refuses what a backdrop cannot have: a padding (Pixi copies the canvas under the padded box, and a copy past the
38859
+ * canvas edge fails on the GPU) and a cache that keeps it (what is under it changes without it).
38860
+ *
38861
+ * @param id - The filter id.
38862
+ * @param padding - The padding as declared, `0` when left out.
38863
+ * @param cache - The cache as declared.
38864
+ * @throws {Error} When the padding is not 0 or the cache keeps it.
38865
+ */
38866
+ function checkBackdrop(id, padding, cache) {
38867
+ if (padding !== 0) throw new Error(`[game] Filter "${id}" is a backdrop: its padding must be 0.\n Pixi copies the canvas under the padded box, and a copy past the canvas edge fails: leave padding out.`);
38868
+ if (cache === "local" || cache === "placed") throw new Error(`[game] Filter "${id}" is a backdrop: it reads what is drawn under its view, so it is drawn live in every frame.\n Leave cache out, or set it to false.`);
38869
+ }
38870
+ /**
38871
+ * Reads where an input's first step reads: the view's own input (the default) or, for a backdrop, what was drawn under
38872
+ * the view. A JavaScript caller may pass anything.
38873
+ *
38874
+ * @param id - The filter id.
38875
+ * @param name - The input name.
38876
+ * @param from - What the game declared.
38877
+ * @param backdrop - Whether the filter is a backdrop.
38878
+ * @returns `"input"` or `"back"`.
38879
+ * @throws {Error} When it is neither, or `"back"` on a filter that is no backdrop.
38880
+ */
38881
+ function readFrom(id, name, from, backdrop) {
38882
+ if (from === void 0 || from === "input") return "input";
38883
+ if (from !== "back") throw new Error(`[game] Filter "${id}": input "${name}" starts from ${JSON.stringify(from)}.\n Write from: "input" or from: "back".`);
38884
+ if (!backdrop) throw new Error(`[game] Filter "${id}": input "${name}" starts from "back", which only a backdrop filter can read.\n Give the filter backdrop: true.`);
38885
+ return "back";
38886
+ }
38887
+ /**
38029
38888
  * Tells whether a value is a list of 2 to 4 finite numbers.
38030
38889
  *
38031
38890
  * @param value - A uniform declaration.
@@ -38206,21 +39065,23 @@ function readStages(id, name, stages) {
38206
39065
  for (const [index, stage] of stages.entries()) {
38207
39066
  if (!isDefinedFilter(stage)) throw new Error(`[game] Filter "${id}": stage ${index} of input "${name}" is not a filter made by defineFilter.\n List what defineFilter returned; a built-in kind cannot be a stage.`);
38208
39067
  if (!drawsAlone(stage.filter)) throw new Error(`[game] Filter "${id}": stage "${stage.filter.id}" of input "${name}" has inputs or a fallback of its own.\n A stage draws alone: give it neither.`);
39068
+ if (stage.filter.backdrop) throw new Error(`[game] Filter "${id}": stage "${stage.filter.id}" of input "${name}" is a backdrop.\n A stage reads the step before it: give it no backdrop.`);
38209
39069
  if (!read.includes(stage)) read.push(stage);
38210
39070
  }
38211
39071
  return Object.freeze(read);
38212
39072
  }
38213
39073
  /**
38214
- * Reads the inputs of a definition: the names the engine declares for each, its two uniforms, its
38215
- * stages and its chain.
39074
+ * Reads the inputs of a definition: the names the engine declares for each, where its first step reads, its two
39075
+ * uniforms, its stages and its chain.
38216
39076
  *
38217
39077
  * @param id - The filter id.
38218
39078
  * @param declared - What the game declared under `inputs`.
38219
39079
  * @param uniforms - The read uniforms.
39080
+ * @param backdrop - Whether the filter is a backdrop: only then may an input start from back.
38220
39081
  * @returns The inputs in declaration order, frozen; empty without `inputs`.
38221
39082
  * @throws {Error} When `inputs` is not an object, has more than four entries, or an input breaks one of its rules.
38222
39083
  */
38223
- function readInputs(id, declared, uniforms) {
39084
+ function readInputs(id, declared, uniforms, backdrop) {
38224
39085
  if (declared === void 0) return Object.freeze([]);
38225
39086
  if (typeof declared !== "object" || declared === null || Array.isArray(declared)) throw new Error(`[game] Filter "${id}": inputs must be an object of named inputs.\n Write inputs: { blur: { stages: [...], chain } }.`);
38226
39087
  const entries = Object.entries(declared);
@@ -38228,10 +39089,12 @@ function readInputs(id, declared, uniforms) {
38228
39089
  const taken = /* @__PURE__ */ new Set();
38229
39090
  return Object.freeze(entries.map(([name, input]) => {
38230
39091
  const names = checkInputNames(id, name, uniforms, taken);
39092
+ const from = readFrom(id, name, input.from, backdrop);
38231
39093
  const stages = readStages(id, name, input.stages);
38232
39094
  if (typeof input.chain !== "function") throw new TypeError(`[game] Filter "${id}": input "${name}" needs a chain.\n Write chain: (value, { resolution }) => [{ stage, scale }].`);
38233
39095
  return Object.freeze({
38234
39096
  name,
39097
+ from,
38235
39098
  texture: names.texture,
38236
39099
  sampler: names.sampler,
38237
39100
  scale: Object.freeze({
@@ -38254,21 +39117,23 @@ function readInputs(id, declared, uniforms) {
38254
39117
  }
38255
39118
  /**
38256
39119
  * Reads the fallback of a definition: another filter of ours, drawing alone, over a subset of its
38257
- * uniforms with the same types.
39120
+ * uniforms with the same types; a backdrop only for a backdrop, which it draws in place of on the same view.
38258
39121
  *
38259
39122
  * @param id - The filter id.
38260
39123
  * @param fallback - What the game declared under `fallback`.
38261
39124
  * @param uniforms - The read uniforms.
39125
+ * @param backdrop - Whether the filter is a backdrop.
38262
39126
  * @returns The fallback's definition, or `undefined` without one.
38263
- * @throws {Error} When it is no filter of ours, has the same id, has inputs or a fallback, or a uniform of it is not
38264
- * one of this filter's with the same type.
39127
+ * @throws {Error} When it is no filter of ours, has the same id, has inputs or a fallback, a uniform of it is not
39128
+ * one of this filter's with the same type, or it is a backdrop and the filter is not.
38265
39129
  */
38266
- function readFallback(id, fallback, uniforms) {
39130
+ function readFallback(id, fallback, uniforms, backdrop) {
38267
39131
  if (fallback === void 0) return void 0;
38268
39132
  if (!isDefinedFilter(fallback)) throw new Error(`[game] Filter "${id}": fallback is not a filter made by defineFilter.\n Pass what defineFilter returned.`);
38269
39133
  const definition = fallback.filter;
38270
39134
  if (definition.id === id) throw new Error(`[game] Filter "${id}": its fallback has the same id.\n Give the fallback an id of its own.`);
38271
39135
  if (!drawsAlone(definition)) throw new Error(`[game] Filter "${id}": its fallback "${definition.id}" has inputs or a fallback of its own.\n A fallback draws alone and simply: give it neither.`);
39136
+ if (definition.backdrop && !backdrop) throw new Error(`[game] Filter "${id}": its fallback "${definition.id}" is a backdrop, and "${id}" is not.\n A fallback draws in its place on the same view: give it no backdrop, or make "${id}" a backdrop too.`);
38272
39137
  for (const uniform of definition.uniforms) {
38273
39138
  const own = uniforms.find((candidate) => candidate.name === uniform.name);
38274
39139
  if (!(own?.type === uniform.type && own.form === uniform.form)) throw new Error(`[game] Filter "${id}": its fallback "${definition.id}" declares "${uniform.name}", which "${id}" ${own === void 0 ? "does not declare" : "declares as another type"}.\n A fallback draws with the value of ${id}: use a subset of its uniforms.`);
@@ -38318,18 +39183,20 @@ function cacheWithInputs(cache, inputs) {
38318
39183
  * An `area` answers, from the value, the rectangle in the entity's own units outside of which the
38319
39184
  * filter draws nothing: Pixi then renders and fills only that box, not the view's bounds. A
38320
39185
  * `resolution` draws the view and its filters at that many texels per CSS px instead of the
38321
- * canvas's.
38322
- * Register it through the `filters` key of a feature. The README shows a filter with a blur input
38323
- * and one with an `area`.
39186
+ * canvas's. A `backdrop` reads what was drawn under its view inside that box: the body calls
39187
+ * `sampleBack(uv)`, and an input may start `from: "back"`; it is drawn live in every frame.
39188
+ * Register it through the `filters` key of a feature. The README shows a filter with a blur input,
39189
+ * one with an `area` and a backdrop.
38324
39190
  *
38325
39191
  * @param id - The filter id, also the component name; unique, and not one of the built-ins.
38326
39192
  * @param spec - Both bodies, the uniforms, the passes one apply costs, the padding, the inputs, the fallback, the
38327
- * cache category, the area and the resolution.
39193
+ * cache category, the area, the resolution and the backdrop switch.
38328
39194
  * @returns The component type, with the frozen `filter` definition attached.
38329
39195
  * @throws {Error} When the id is empty, the WGSL has no `mainFragment`, the GLSL no `void main(`,
38330
39196
  * a uniform is reserved, badly named or of no known form, the passes or the padding break
38331
39197
  * their rules, an input or the fallback breaks one of its rules, `passes` is given with inputs,
38332
- * `area` is not a function, or `resolution` is not a number from 0 to 4.
39198
+ * `area` is not a function, `resolution` is not a number from 0 to 4, `backdrop` is not a
39199
+ * boolean, or a backdrop has a padding or a cache that keeps it.
38333
39200
  * @example
38334
39201
  * ```ts
38335
39202
  * // features/board/effects.ts: a gold tint a motion hook tweens on a merge, on WebGPU and WebGL.
@@ -38343,21 +39210,23 @@ function defineFilter(id, spec) {
38343
39210
  readSpec(id, spec);
38344
39211
  const read = Object.entries(spec.uniforms ?? {}).map(([name, declaration]) => readUniform(id, name, declaration));
38345
39212
  const uniforms = Object.freeze(read.map((entry) => entry.spec));
38346
- const inputs = readInputs(id, spec.inputs, uniforms);
39213
+ const backdrop = readBackdrop(id, spec.backdrop);
39214
+ const inputs = readInputs(id, spec.inputs, uniforms, backdrop);
38347
39215
  if (spec.inputs !== void 0 && spec.passes !== void 0) throw new Error(`[game] Filter "${id}": passes is counted by the engine for a filter with inputs.\n Leave passes out.`);
38348
- const fallback = readFallback(id, spec.fallback, uniforms);
39216
+ const fallback = readFallback(id, spec.fallback, uniforms, backdrop);
38349
39217
  const passes = spec.passes ?? 1;
38350
39218
  const padding = spec.padding ?? 0;
38351
39219
  checkCost(id, passes, padding, uniforms);
38352
39220
  checkCache(id, spec.cache);
39221
+ if (backdrop) checkBackdrop(id, padding, spec.cache);
38353
39222
  const area = readArea(id, spec.area);
38354
39223
  const resolution = readResolution(id, spec.resolution);
38355
39224
  const origin = callsCanvasPixel(spec.wgsl, spec.glsl) ? KEPT_ORIGIN : void 0;
38356
- const cache = cacheWithInputs(cacheOf(spec.wgsl, spec.glsl, spec.cache), inputs);
39225
+ const cache = cacheWithInputs(cacheOf(spec.wgsl, spec.glsl, spec.cache, backdrop), inputs);
38357
39226
  const filter = Object.freeze({
38358
39227
  id,
38359
- source: assemble(uniforms, inputs, spec.wgsl, origin),
38360
- glsl: assembleGlsl(uniforms, inputs, spec.glsl, origin),
39228
+ source: assemble(uniforms, inputs, spec.wgsl, origin, backdrop),
39229
+ glsl: assembleGlsl(uniforms, inputs, spec.glsl, origin, backdrop),
38361
39230
  uniforms,
38362
39231
  passes,
38363
39232
  padding,
@@ -38366,9 +39235,10 @@ function defineFilter(id, spec) {
38366
39235
  cache,
38367
39236
  origin,
38368
39237
  resolution,
39238
+ backdrop,
38369
39239
  ...area === void 0 ? {} : { area }
38370
39240
  });
38371
- if (spec.cache !== false && (readsScreenIn(spec.wgsl) || readsScreenIn(spec.glsl))) screenReaders.add(filter);
39241
+ if (!backdrop && spec.cache !== false && (readsScreenIn(spec.wgsl) || readsScreenIn(spec.glsl))) screenReaders.add(filter);
38372
39242
  const defaults = Object.fromEntries([
38373
39243
  ...read.map((entry) => [entry.spec.name, entry.value]),
38374
39244
  ["enabled", true],
@@ -38828,7 +39698,9 @@ function brokenMark() {
38828
39698
  * asks the renderer for no frame. A destroy frees the uniform buffers first: `Filter.destroy()`
38829
39699
  * leaves them to a GC that runs after a minute (P10). A filter with inputs draws each place of its
38830
39700
  * chains through Pixi's public `applyFilter` from inside its own `apply`, into pooled textures given
38831
- * back in a `finally`; a stand-in draws in place of a broken kind or instance.
39701
+ * back in a `finally`; a stand-in draws in place of a broken kind or instance. A backdrop is built
39702
+ * with Pixi's `blendRequired`: Pixi copies the canvas under the view before it draws, and the apply
39703
+ * places `sampleBack` on that copy (`activeBackTexture`) and starts the chains `from: "back"` there.
38832
39704
  */
38833
39705
  /**
38834
39706
  * The resolution of every filter but `Blur`: the render target's, so a filter draws at the
@@ -39001,11 +39873,21 @@ function createOurs(pixi, definition, value) {
39001
39873
  type: input.clamp.type
39002
39874
  };
39003
39875
  }
39876
+ if (definition.backdrop) {
39877
+ structures[BACK_SCALE.name] = {
39878
+ value: new Float32Array(BACK_SCALE.size),
39879
+ type: BACK_SCALE.type
39880
+ };
39881
+ structures[BACK_CLAMP.name] = {
39882
+ value: new Float32Array(BACK_CLAMP.size),
39883
+ type: BACK_CLAMP.type
39884
+ };
39885
+ }
39004
39886
  if (definition.origin !== void 0) structures[definition.origin.name] = {
39005
39887
  value: new Float32Array(definition.origin.size),
39006
39888
  type: definition.origin.type
39007
39889
  };
39008
- const group = definition.uniforms.length > 0 || definition.inputs.length > 0 || definition.origin !== void 0 ? new pixi.UniformGroup(structures) : void 0;
39890
+ const group = definition.uniforms.length > 0 || definition.inputs.length > 0 || definition.backdrop || definition.origin !== void 0 ? new pixi.UniformGroup(structures) : void 0;
39009
39891
  const resources = group === void 0 ? {} : { fu: group };
39010
39892
  const empty = pixi.Texture.EMPTY.source;
39011
39893
  for (const input of definition.inputs) {
@@ -39019,7 +39901,8 @@ function createOurs(pixi, definition, value) {
39019
39901
  glProgram,
39020
39902
  resources,
39021
39903
  padding: paddingOf$1(definition, value),
39022
- resolution: resolutionOf(definition.resolution)
39904
+ resolution: resolutionOf(definition.resolution),
39905
+ blendRequired: definition.backdrop
39023
39906
  }),
39024
39907
  group,
39025
39908
  definition,
@@ -39278,10 +40161,85 @@ function createFilter(ectx, pixi, kind, value, probeMap) {
39278
40161
  entity: 0
39279
40162
  });
39280
40163
  if (instance.kind === "wgsl" && instance.definition.inputs.length > 0) wireInputs(ectx, pixi, instance);
40164
+ else if (instance.kind === "wgsl" && instance.definition.backdrop) wireBack(pixi, instance);
39281
40165
  guardApply(ectx, instance, kind);
39282
40166
  return instance;
39283
40167
  }
39284
40168
  /**
40169
+ * What was drawn under the view of the running apply: the texture Pixi's filter system copied the canvas under the
40170
+ * view's box into, or its empty texture outside a backdrop's pop.
40171
+ *
40172
+ * @param pixi - The module the renderer loaded.
40173
+ * @param manager - Pixi's filter system, inside an apply.
40174
+ * @returns The back texture.
40175
+ */
40176
+ function backOf(pixi, manager) {
40177
+ return manager.activeBackTexture ?? pixi.Texture.EMPTY;
40178
+ }
40179
+ /**
40180
+ * Writes the scale and the clamp that place a sample of the body's uv on a texture: one over the size of its backing,
40181
+ * and half a texel inside the part of it its frame covers, rounded to float32. Written only where a value differs, so
40182
+ * Pixi's bind group stays cached; no array is allocated.
40183
+ *
40184
+ * @param uniforms - The values of the uniform group.
40185
+ * @param scaleName - The `vec2` scale uniform.
40186
+ * @param clampName - The `vec4` clamp uniform.
40187
+ * @param texture - The texture the body samples through them.
40188
+ */
40189
+ function writePlacement(uniforms, scaleName, clampName, texture) {
40190
+ const { source } = texture;
40191
+ const scale = uniforms[scaleName];
40192
+ const clamp = uniforms[clampName];
40193
+ const halfX = .5 / source.pixelWidth;
40194
+ const halfY = .5 / source.pixelHeight;
40195
+ if (scale instanceof Float32Array) {
40196
+ writeAt(scale, 0, 1 / source.width, true);
40197
+ writeAt(scale, 1, 1 / source.height, true);
40198
+ }
40199
+ if (clamp instanceof Float32Array) {
40200
+ writeAt(clamp, 0, halfX, true);
40201
+ writeAt(clamp, 1, halfY, true);
40202
+ writeAt(clamp, 2, texture.frame.width / source.width - halfX, true);
40203
+ writeAt(clamp, 3, texture.frame.height / source.height - halfY, true);
40204
+ }
40205
+ }
40206
+ /**
40207
+ * Places a backdrop's `sampleBack` on the back texture of this apply. Pixi binds the texture itself (group 0
40208
+ * binding 3); the engine fills the scale and the clamp, since the copy's backing is sized at the resolution of the
40209
+ * surface under the view, the input's at the view's.
40210
+ *
40211
+ * @param body - The backdrop body.
40212
+ * @param back - What was drawn under the view.
40213
+ */
40214
+ function bindBack(body, back) {
40215
+ const uniforms = body.group?.uniforms;
40216
+ if (uniforms !== void 0) writePlacement(uniforms, BACK_SCALE.name, BACK_CLAMP.name, back);
40217
+ }
40218
+ /**
40219
+ * The apply of a backdrop without inputs, as Pixi calls it: `sampleBack` placed on the copy Pixi made, then the body
40220
+ * drawn from the view's input.
40221
+ *
40222
+ * @param pixi - The module the renderer loaded.
40223
+ * @param body - The backdrop body.
40224
+ * @param manager - Pixi's filter system.
40225
+ * @param input - The view's input texture.
40226
+ * @param output - The output.
40227
+ * @param clearMode - Whether the output is cleared.
40228
+ */
40229
+ function applyBack(pixi, body, manager, input, output, clearMode) {
40230
+ bindBack(body, backOf(pixi, manager));
40231
+ manager.applyFilter(body.filter, input, output, clearMode);
40232
+ }
40233
+ /**
40234
+ * Gives a backdrop body without inputs its own `apply`, which places `sampleBack` before it draws.
40235
+ *
40236
+ * @param pixi - The module the renderer loaded.
40237
+ * @param body - The body, just built.
40238
+ */
40239
+ function wireBack(pixi, body) {
40240
+ body.filter.apply = (manager, input, output, clearMode) => applyBack(pixi, body, manager, input, output, clearMode);
40241
+ }
40242
+ /**
39285
40243
  * The share of the resolution the last place of one input draws at.
39286
40244
  *
39287
40245
  * @param inputs - The chains of the instance.
@@ -39307,8 +40265,12 @@ function stagesOf(inputs) {
39307
40265
  * @param inputs - The chains of the instance.
39308
40266
  */
39309
40267
  function giveBack(pixi, inputs) {
39310
- for (const target of inputs.taken) pixi.TexturePool.returnTexture(target);
39311
- inputs.taken.length = 0;
40268
+ const { taken } = inputs;
40269
+ for (let index = 0; index < taken.length; index += 1) {
40270
+ const target = taken[index];
40271
+ if (target !== void 0) pixi.TexturePool.returnTexture(target);
40272
+ }
40273
+ while (taken.length > 0) taken.pop();
39312
40274
  }
39313
40275
  /**
39314
40276
  * Draws the places of one input, each from the one before, the first from `from`, into a pooled
@@ -39318,7 +40280,7 @@ function giveBack(pixi, inputs) {
39318
40280
  * @param inputs - The chains of the instance.
39319
40281
  * @param index - The input.
39320
40282
  * @param manager - Pixi's filter system, inside an active filter pass.
39321
- * @param from - The view's input, or a kept picture.
40283
+ * @param from - The view's input, the copy of what was drawn under the view, or a kept picture.
39322
40284
  * @param resolution - The resolution of `from`.
39323
40285
  * @param target - Where the last place draws, instead of a pooled texture.
39324
40286
  * @returns The texture the last place drew into; `from` for an empty chain.
@@ -39360,20 +40322,7 @@ function bindInput(instance, index, last) {
39360
40322
  const { resources } = instance.filter;
39361
40323
  if (resources[input.texture] !== source) resources[input.texture] = source;
39362
40324
  if (resources[input.sampler] !== source.style) resources[input.sampler] = source.style;
39363
- const scale = uniforms[input.scale.name];
39364
- const clamp = uniforms[input.clamp.name];
39365
- const halfX = .5 / source.pixelWidth;
39366
- const halfY = .5 / source.pixelHeight;
39367
- if (scale instanceof Float32Array) {
39368
- writeAt(scale, 0, 1 / source.width, true);
39369
- writeAt(scale, 1, 1 / source.height, true);
39370
- }
39371
- if (clamp instanceof Float32Array) {
39372
- writeAt(clamp, 0, halfX, true);
39373
- writeAt(clamp, 1, halfY, true);
39374
- writeAt(clamp, 2, last.frame.width / source.width - halfX, true);
39375
- writeAt(clamp, 3, last.frame.height / source.height - halfY, true);
39376
- }
40325
+ writePlacement(uniforms, input.scale.name, input.clamp.name, last);
39377
40326
  }
39378
40327
  /**
39379
40328
  * Points every input's texture and sampler back at Pixi's empty texture: a filter never holds a
@@ -39394,11 +40343,24 @@ function unbindInputs(pixi, instance) {
39394
40343
  }
39395
40344
  }
39396
40345
  /**
40346
+ * Tells whether the chains of an instance must run again for the resolutions of this apply: the view's input's for a
40347
+ * chain that starts from it, what was drawn under the view's for one that starts from back.
40348
+ *
40349
+ * @param inputs - The chains of the instance.
40350
+ * @param resolution - The resolution of the view's input.
40351
+ * @param backResolution - The resolution of the back; the input's for a filter that reads none.
40352
+ * @returns True when one the chains start from changed.
40353
+ */
40354
+ function resolutionMoved(inputs, resolution, backResolution) {
40355
+ return inputs.fromInput && resolution !== inputs.resolution || inputs.fromBack && backResolution !== inputs.backResolution;
40356
+ }
40357
+ /**
39397
40358
  * The apply of a filter with inputs, as Pixi calls it: a resolution change runs the chains once
39398
- * at the new value, then every place draws, the last texture of each input is bound, and the main
39399
- * body draws from the view's input. Held results are bound instead and no stage draws. The pooled
39400
- * textures go back and the inputs are unbound in a `finally`, also when a place throws; a throw then
39401
- * reaches the guard, which breaks the instance.
40359
+ * at the new value, then every place draws, each chain from the view's input or, `from: "back"`,
40360
+ * from the copy Pixi made of the canvas under the view, the last texture of each input is bound, a
40361
+ * backdrop's `sampleBack` is placed, and the main body draws from the view's input. Held results are
40362
+ * bound instead and no stage draws. The pooled textures go back and the inputs are unbound in a
40363
+ * `finally`, also when a place throws; a throw then reaches the guard, which breaks the instance.
39402
40364
  *
39403
40365
  * @param ectx - Domain context of the effects plugin.
39404
40366
  * @param pixi - The module the renderer loaded.
@@ -39411,16 +40373,23 @@ function unbindInputs(pixi, instance) {
39411
40373
  */
39412
40374
  function applyInputs(ectx, pixi, instance, inputs, manager, input, output, clearMode) {
39413
40375
  const { resolution } = input.source;
39414
- if (inputs.held === void 0 && resolution !== inputs.resolution) evaluateInRender(ectx, pixi, instance, inputs, resolution);
40376
+ const { definition } = instance;
40377
+ const back = definition.backdrop ? backOf(pixi, manager) : input;
40378
+ const backResolution = inputs.fromBack ? back.source.resolution : resolution;
40379
+ if (inputs.held === void 0 && resolutionMoved(inputs, resolution, backResolution)) evaluateInRender(ectx, pixi, instance, inputs, resolution, backResolution);
39415
40380
  if (instance.broken) {
39416
40381
  drawBroken(instance, manager, input, output, clearMode);
39417
40382
  return;
39418
40383
  }
39419
40384
  try {
39420
40385
  for (let index = 0; index < inputs.places.length; index += 1) {
39421
- const last = inputs.held === void 0 ? drawChain(pixi, inputs, index, manager, input, resolution) : inputs.held[index] ?? input;
40386
+ const fromBack = definition.inputs[index]?.from === "back";
40387
+ const start = fromBack ? back : input;
40388
+ const startResolution = fromBack ? backResolution : resolution;
40389
+ const last = inputs.held === void 0 ? drawChain(pixi, inputs, index, manager, start, startResolution) : inputs.held[index] ?? input;
39422
40390
  bindInput(instance, index, last);
39423
40391
  }
40392
+ if (definition.backdrop) bindBack(instance, back);
39424
40393
  manager.applyFilter(instance.filter, input, output, clearMode);
39425
40394
  } finally {
39426
40395
  giveBack(pixi, inputs);
@@ -39442,7 +40411,7 @@ function applyInputs(ectx, pixi, instance, inputs, manager, input, output, clear
39442
40411
  */
39443
40412
  function drawInto(ectx, pixi, instance, inputs, manager, picture, targets) {
39444
40413
  const { resolution } = picture.source;
39445
- if (!instance.broken && resolution !== inputs.resolution) evaluateInRender(ectx, pixi, instance, inputs, resolution);
40414
+ if (!instance.broken && resolution !== inputs.resolution) evaluateInRender(ectx, pixi, instance, inputs, resolution, resolution);
39446
40415
  if (instance.broken) return;
39447
40416
  try {
39448
40417
  for (let index = 0; index < inputs.places.length; index += 1) {
@@ -39462,10 +40431,14 @@ function drawInto(ectx, pixi, instance, inputs, manager, picture, targets) {
39462
40431
  * @param instance - The instance, just built.
39463
40432
  */
39464
40433
  function wireInputs(ectx, pixi, instance) {
39465
- const count = instance.definition.inputs.length;
40434
+ const declared = instance.definition.inputs;
40435
+ const count = declared.length;
39466
40436
  const inputs = {
39467
40437
  stale: true,
39468
40438
  resolution: 0,
40439
+ backResolution: 0,
40440
+ fromInput: declared.some((input) => input.from === "input"),
40441
+ fromBack: declared.some((input) => input.from === "back"),
39469
40442
  version: 0,
39470
40443
  context: { resolution: 0 },
39471
40444
  places: Array.from({ length: count }, () => []),
@@ -39491,7 +40464,9 @@ function wireInputs(ectx, pixi, instance) {
39491
40464
  instance.inputs = inputs;
39492
40465
  instance.filter.apply = (manager, input, output, clearMode) => applyInputs(ectx, pixi, instance, inputs, manager, input, output, clearMode);
39493
40466
  const { resolution } = instance.definition;
39494
- evaluate(ectx, pixi, instance, inputs, resolution > 0 ? resolution : ectx.deps.renderer.host.resolution());
40467
+ const canvas = ectx.deps.renderer.host.resolution();
40468
+ const own = resolution > 0 ? resolution : canvas;
40469
+ evaluate(ectx, pixi, instance, inputs, own, inputs.fromBack ? canvas : own);
39495
40470
  }
39496
40471
  /**
39497
40472
  * Reads one property of a value a chain returned, whatever its type.
@@ -39685,9 +40660,10 @@ function placeSteps(pixi, inputs, index, steps) {
39685
40660
  return changed || rebuilt;
39686
40661
  }
39687
40662
  /**
39688
- * Runs the chain of every input for the instance's value at a resolution and places its steps.
40663
+ * Runs the chain of every input for the instance's value at a resolution and places its steps: a chain that starts
40664
+ * from the view's input at the input's resolution, one that starts from back at the back's.
39689
40665
  * A chain that throws or returns a step it may not draw breaks the instance (reason `"chain"`).
39690
- * The version moves when a place, a scale, a stage uniform or the resolution changed; the passes
40666
+ * The version moves when a place, a scale, a stage uniform or a resolution changed; the passes
39691
40667
  * become the main body plus every place.
39692
40668
  *
39693
40669
  * @param ectx - Domain context of the effects plugin.
@@ -39695,14 +40671,15 @@ function placeSteps(pixi, inputs, index, steps) {
39695
40671
  * @param instance - The instance.
39696
40672
  * @param inputs - Its chains.
39697
40673
  * @param resolution - The resolution of the texture the view is drawn into.
40674
+ * @param backResolution - The resolution of what was drawn under the view; the input's for a filter that reads none.
39698
40675
  */
39699
- function evaluate(ectx, pixi, instance, inputs, resolution) {
40676
+ function evaluate(ectx, pixi, instance, inputs, resolution, backResolution) {
39700
40677
  const kind = ectx.state.kinds.get(instance.kindId);
39701
- let changed = resolution !== inputs.resolution;
40678
+ let changed = resolution !== inputs.resolution || backResolution !== inputs.backResolution;
39702
40679
  let passes = 1;
39703
- inputs.context.resolution = resolution;
39704
40680
  for (const [index, input] of instance.definition.inputs.entries()) {
39705
40681
  let steps;
40682
+ inputs.context.resolution = input.from === "back" ? backResolution : resolution;
39706
40683
  try {
39707
40684
  steps = input.chain(instance.value, inputs.context);
39708
40685
  } catch (error) {
@@ -39718,6 +40695,7 @@ function evaluate(ectx, pixi, instance, inputs, resolution) {
39718
40695
  passes += steps.length;
39719
40696
  }
39720
40697
  inputs.resolution = resolution;
40698
+ inputs.backResolution = backResolution;
39721
40699
  inputs.stale = false;
39722
40700
  instance.passes = passes;
39723
40701
  if (changed) inputs.version += 1;
@@ -39732,9 +40710,10 @@ function evaluate(ectx, pixi, instance, inputs, resolution) {
39732
40710
  * @param instance - The instance.
39733
40711
  * @param inputs - Its chains.
39734
40712
  * @param resolution - The resolution of the texture the view is drawn into.
40713
+ * @param backResolution - The resolution of what was drawn under the view.
39735
40714
  */
39736
- function evaluateInRender(ectx, pixi, instance, inputs, resolution) {
39737
- evaluate(ectx, pixi, instance, inputs, resolution);
40715
+ function evaluateInRender(ectx, pixi, instance, inputs, resolution, backResolution) {
40716
+ evaluate(ectx, pixi, instance, inputs, resolution, backResolution);
39738
40717
  ectx.state.filterSync.all = true;
39739
40718
  }
39740
40719
  /**
@@ -39756,12 +40735,13 @@ function refreshInputs(ectx, pixi, instance, off) {
39756
40735
  inputs.retired.length = 0;
39757
40736
  }
39758
40737
  if (instance.broken || !instance.filter.enabled || off || !inputs.stale) return;
39759
- evaluate(ectx, pixi, instance, inputs, inputs.resolution);
40738
+ evaluate(ectx, pixi, instance, inputs, inputs.resolution, inputs.backResolution);
39760
40739
  }
39761
40740
  /**
39762
40741
  * The body a stand-in of a dev build draws: the fallback with the striped mark over it, or the
39763
40742
  * mark alone. The pair draws the fallback into one pooled texture of the input's frame and
39764
- * resolution, then the mark from it into the output, and gives the texture back in a `finally`.
40743
+ * resolution, then the mark from it into the output, and gives the texture back in a `finally`. A
40744
+ * backdrop fallback has its `sampleBack` placed first.
39765
40745
  *
39766
40746
  * @param pixi - The module the renderer loaded.
39767
40747
  * @param fallback - The checked fallback, or `undefined`.
@@ -39786,6 +40766,7 @@ function markedBody(pixi, fallback, value) {
39786
40766
  request.height = input.frame.height;
39787
40767
  request.resolution = input.source.resolution;
39788
40768
  const pooled = pixi.TexturePool.getOptimalTexture(request);
40769
+ if (body.definition.backdrop) bindBack(body, backOf(pixi, manager));
39789
40770
  try {
39790
40771
  manager.applyFilter(body.filter, input, pooled, true);
39791
40772
  manager.applyFilter(mark.filter, pooled, output, clearMode);
@@ -39815,7 +40796,10 @@ function createStandIn(ectx, pixi, kind, value) {
39815
40796
  const usable = status === "ok" ? fallback : void 0;
39816
40797
  let body;
39817
40798
  if (typeof __MOKU_GAME_DEV__ !== "undefined" && __MOKU_GAME_DEV__ === true) body = markedBody(pixi, usable, value);
39818
- else if (usable !== void 0) body = createOurs(pixi, usable, value);
40799
+ else if (usable !== void 0) {
40800
+ body = createOurs(pixi, usable, value);
40801
+ if (usable.backdrop) wireBack(pixi, body);
40802
+ }
39819
40803
  if (body === void 0) return void 0;
39820
40804
  body.filter.enabled = value.enabled;
39821
40805
  body.areaStale = body.definition.area !== void 0;
@@ -40392,7 +41376,7 @@ function finish(ectx, run, definition, passed) {
40392
41376
  * @param definition - The definition.
40393
41377
  * @param messages - What the compiler said.
40394
41378
  */
40395
- function settle(ectx, run, definition, messages) {
41379
+ function settle$1(ectx, run, definition, messages) {
40396
41380
  const { checks } = ectx.state;
40397
41381
  if (checks.run !== run || checks.status.get(definition) !== "pending") return;
40398
41382
  const errors = messages.filter((message) => message.type === "error");
@@ -40418,7 +41402,7 @@ function checkWgsl(ectx, run, definition) {
40418
41402
  finish(ectx, run, definition, true);
40419
41403
  return;
40420
41404
  }
40421
- device.createShaderModule({ code: definition.source }).getCompilationInfo().then((info) => settle(ectx, run, definition, info.messages), () => settle(ectx, run, definition, []));
41405
+ device.createShaderModule({ code: definition.source }).getCompilationInfo().then((info) => settle$1(ectx, run, definition, info.messages), () => settle$1(ectx, run, definition, []));
40422
41406
  }
40423
41407
  /**
40424
41408
  * Reads the errors of a WebGL info log, one per `ERROR: 0:<line>: <message>` line; a summary line
@@ -41001,14 +41985,27 @@ function cacheOfInstance(instance) {
41001
41985
  return instance.kind === "wgsl" ? instance.definition.cache : CORE_CACHE[instance.kind];
41002
41986
  }
41003
41987
  /**
41988
+ * Tells whether one of the inputs of a definition starts from the view's own input: its chain draws a copy of the
41989
+ * view's subtree. A chain from back draws what was drawn under the view instead.
41990
+ *
41991
+ * @param definition - The definition.
41992
+ * @returns True when one does.
41993
+ */
41994
+ function readsOwnInput(definition) {
41995
+ const { inputs } = definition;
41996
+ for (let index = 0; index < inputs.length; index += 1) if (inputs[index]?.from === "input") return true;
41997
+ return false;
41998
+ }
41999
+ /**
41004
42000
  * Whether an instance shows a blurred copy of what is under it (spec 23 P8.5): the built-in `Blur`, or one of ours
41005
- * with inputs. The predicate an overlay's held loops read (spec 22).
42001
+ * with an input drawn from its own subtree. A backdrop whose chains start from back blurs the scene under the view,
42002
+ * not the view: an overlay holds no loops for it. The predicate an overlay's held loops read (spec 22).
41006
42003
  *
41007
42004
  * @param instance - The instance.
41008
42005
  * @returns True, or `undefined` for every other filter (the slot leaves the key out).
41009
42006
  */
41010
42007
  function blursOf(instance) {
41011
- return instance.kind === "blur" || instance.kind === "wgsl" && instance.definition.inputs.length > 0 ? true : void 0;
42008
+ return instance.kind === "blur" || instance.kind === "wgsl" && readsOwnInput(instance.definition) ? true : void 0;
41012
42009
  }
41013
42010
  /**
41014
42011
  * Tells whether a debug switch turned a kind off (`effects.diagnostics.disable`). The guard is
@@ -42035,7 +43032,7 @@ const EFFECTS_OWNER = Object.freeze({
42035
43032
  name: "effects"
42036
43033
  });
42037
43034
  /** How deep a parent chain is followed, as `rootPoseOf` does; deeper is treated as a loop. */
42038
- const MAX_DEPTH = 32;
43035
+ const MAX_DEPTH$1 = 32;
42039
43036
  /**
42040
43037
  * The one dynamic set of an emitter container (P9): positions, frames, rotations and colours
42041
43038
  * upload every frame; `uvs` stays static, because a frame is set at birth and the same `update()`
@@ -42078,7 +43075,7 @@ function bornFields(size) {
42078
43075
  */
42079
43076
  function topOf(ecs, host) {
42080
43077
  let top = host;
42081
- for (let depth = 0; depth < MAX_DEPTH; depth += 1) {
43078
+ for (let depth = 0; depth < MAX_DEPTH$1; depth += 1) {
42082
43079
  const parent = parentOf$1(ecs, top);
42083
43080
  if (parent === 0) break;
42084
43081
  top = parent;
@@ -44185,6 +45182,10 @@ function submit(ctx, entity, answer) {
44185
45182
  }
44186
45183
  //#endregion
44187
45184
  //#region src/plugins/input/components.ts
45185
+ const scrollableDefaults = {
45186
+ axis: "y",
45187
+ moving: false
45188
+ };
44188
45189
  /**
44189
45190
  * Fresh defaults for a component that names an intent. A function, so each component type gets
44190
45191
  * its own payload object instead of sharing one.
@@ -44284,6 +45285,13 @@ const Pressed = /*#__PURE__*/ tag("Pressed");
44284
45285
  */
44285
45286
  const PointerOver = /*#__PURE__*/ tag("PointerOver");
44286
45287
  /**
45288
+ * On every ui scroll container, internal: ui writes it, input reads it, a game never sees it. A
45289
+ * press that leaves the tap slop along `axis` goes from the pressed view to the innermost scroll
45290
+ * of that axis the view hangs in by its `Parent` chain; a finger that lands on a view inside a
45291
+ * scroll that is `moving` stops it and answers nothing.
45292
+ */
45293
+ const Scrollable = /*#__PURE__*/ component("Scrollable", scrollableDefaults);
45294
+ /**
44287
45295
  * Where the one pointer is, in reference units. Written once per frame by the frame step. `id`
44288
45296
  * names the pointer it follows, `-1` at rest.
44289
45297
  */
@@ -44293,8 +45301,12 @@ const Pointer = /*#__PURE__*/ resource("Pointer", pointerDefaults);
44293
45301
  /**
44294
45302
  * @file input plugin — who is under the finger. Every lookup goes through `renderer.sync.hitTest`,
44295
45303
  * which answers `undefined` without a DOM, and every accept function refuses a view that is
44296
- * already playing its exit. A trace takes a cell only inside a circle smaller than its box.
45304
+ * already playing its exit. A trace takes a cell only inside a circle smaller than its box. The
45305
+ * scroll a press belongs to is found up the pressed view's own `Parent` chain, never under the
45306
+ * point: a view drawn over a scroll from another root keeps its press.
44297
45307
  */
45308
+ /** How deep a `Parent` chain is followed; deeper is treated as a loop. */
45309
+ const MAX_DEPTH = 32;
44298
45310
  /**
44299
45311
  * Resolves what `app.input.*` was pointed at. A `{ projection, key }` goes through
44300
45312
  * `world.projection.entityOf`, which answers for live views only, so a view in the despawn queue
@@ -44377,6 +45389,44 @@ function findPressed(ctx, x, y) {
44377
45389
  return ctx.deps.renderer.sync.hitTest(x, y, (entity) => acceptPress(ctx, entity));
44378
45390
  }
44379
45391
  /**
45392
+ * The innermost scroll a view sits in that moves now: the view itself or the nearest ancestor of
45393
+ * its `Parent` chain that carries a `Scrollable` with `moving` true and is not on its way out. A
45394
+ * finger that lands on the view stops that scroll. A view of another root drawn over the scroll,
45395
+ * a HUD button or a popup, hangs in no chain of it, so it keeps its press. Reads only, allocates
45396
+ * nothing.
45397
+ *
45398
+ * @param ctx - Domain context of the input plugin.
45399
+ * @param entity - The view the hit test found under the finger.
45400
+ * @returns The scroll, or `undefined` when no scroll the view sits in moves.
45401
+ */
45402
+ function movingScrollOf(ctx, entity) {
45403
+ const { ecs } = ctx.deps.world;
45404
+ let current = entity;
45405
+ for (let depth = 0; depth < MAX_DEPTH && current !== 0; depth += 1) {
45406
+ if (ecs.get(current, Scrollable)?.moving === true && !ecs.has(current, Exiting)) return current;
45407
+ current = parentOf$1(ecs, current);
45408
+ }
45409
+ }
45410
+ /**
45411
+ * The innermost scroll of an axis a pressed view sits in: the view itself or the nearest ancestor
45412
+ * of its `Parent` chain that carries a `Scrollable` of that axis and is not on its way out. A list
45413
+ * under a modal is in no chain of the modal's button, so a drift on that button never pans it.
45414
+ * Reads only, allocates nothing.
45415
+ *
45416
+ * @param ctx - Domain context of the input plugin.
45417
+ * @param entity - The pressed view.
45418
+ * @param axis - The dominant axis of the pan.
45419
+ * @returns The scroll, or `undefined` when the view sits in no scroll of that axis.
45420
+ */
45421
+ function scrollOnAxisOf(ctx, entity, axis) {
45422
+ const { ecs } = ctx.deps.world;
45423
+ let current = entity;
45424
+ for (let depth = 0; depth < MAX_DEPTH && current !== 0; depth += 1) {
45425
+ if (ecs.get(current, Scrollable)?.axis === axis && !ecs.has(current, Exiting)) return current;
45426
+ current = parentOf$1(ecs, current);
45427
+ }
45428
+ }
45429
+ /**
44380
45430
  * The topmost drop target at a point.
44381
45431
  *
44382
45432
  * @param ctx - Domain context of the input plugin.
@@ -45400,6 +46450,20 @@ function direction(from, to) {
45400
46450
  return dy >= 0 ? "down" : "up";
45401
46451
  }
45402
46452
  /**
46453
+ * The axis a pan moves on: the dominant one, horizontal on an exact diagonal.
46454
+ *
46455
+ * @param from - Where the press started.
46456
+ * @param to - Where the finger is.
46457
+ * @returns The axis of the larger move.
46458
+ * @example
46459
+ * ```ts
46460
+ * axisOf({ x: 0, y: 0 }, { x: 20, y: -5 }); // "x"
46461
+ * ```
46462
+ */
46463
+ function axisOf$1(from, to) {
46464
+ return Math.abs(to.x - from.x) >= Math.abs(to.y - from.y) ? "x" : "y";
46465
+ }
46466
+ /**
45403
46467
  * Takes or releases the pointer capture of the canvas. A pointer that is already gone — a down
45404
46468
  * and an up inside one frame — cannot be captured; the browser throws and the gesture goes on.
45405
46469
  *
@@ -45418,7 +46482,7 @@ function capture(ctx, on) {
45418
46482
  }
45419
46483
  /**
45420
46484
  * Ends the gesture: the press tag goes, the capture is released and the machine forgets the
45421
- * pointer, whether it moved, the entity and its key.
46485
+ * pointer, whether it moved or caught a scroll, the entity and its key.
45422
46486
  *
45423
46487
  * @param ctx - Domain context of the input plugin.
45424
46488
  */
@@ -45429,16 +46493,30 @@ function toIdle(ctx) {
45429
46493
  ctx.state.phase = "idle";
45430
46494
  ctx.state.pointerId = void 0;
45431
46495
  ctx.state.moved = false;
46496
+ ctx.state.caught = false;
45432
46497
  ctx.state.entity = void 0;
45433
46498
  ctx.state.key = void 0;
45434
46499
  ctx.state.pressedMs = 0;
45435
46500
  }
45436
46501
  /**
46502
+ * Makes a view the pressed one: it is remembered with its projection key and tagged `Pressed`.
46503
+ *
46504
+ * @param ctx - Domain context of the input plugin.
46505
+ * @param entity - The view that takes the press.
46506
+ */
46507
+ function press(ctx, entity) {
46508
+ ctx.state.entity = entity;
46509
+ ctx.state.key = ctx.deps.world.projection.keyOf(entity);
46510
+ ctx.deps.world.ecs.tag(entity, Pressed);
46511
+ }
46512
+ /**
45437
46513
  * Takes the pointer and looks for the view under it. The pointer is taken even where no view is.
45438
46514
  * While the finger rests (`rests`) the next finger that goes down takes the pointer over and this
45439
46515
  * press is cancelled; once it starts a gesture — a drag, a move past the tap slop, a long press, a
45440
- * trace — a second finger stays ignored. A view that carries `Traceable` starts a trace instead of
45441
- * a press: it is not tagged `Pressed`.
46516
+ * trace — a second finger stays ignored. When the view under the finger sits in a moving scroll,
46517
+ * that scroll takes the press: the finger stops it, and the press is caught. A view drawn over a
46518
+ * moving scroll from another root, a HUD or a popup button, keeps its press. A view that carries
46519
+ * `Traceable` starts a trace instead of a press: it is not tagged `Pressed`.
45442
46520
  *
45443
46521
  * @param ctx - Domain context of the input plugin.
45444
46522
  * @param pointer - The `Pointer` resource of this frame.
@@ -45446,9 +46524,10 @@ function toIdle(ctx) {
45446
46524
  * @param sample - The raw sample.
45447
46525
  */
45448
46526
  function onDown(ctx, pointer, point, sample) {
45449
- const { ecs, projection } = ctx.deps.world;
46527
+ const { ecs } = ctx.deps.world;
45450
46528
  ctx.state.pointerId = sample.pointerId;
45451
46529
  ctx.state.moved = false;
46530
+ ctx.state.caught = false;
45452
46531
  ctx.state.start = {
45453
46532
  x: point.x,
45454
46533
  y: point.y
@@ -45460,16 +46539,42 @@ function onDown(ctx, pointer, point, sample) {
45460
46539
  capture(ctx, true);
45461
46540
  const entity = findPressed(ctx, point.x, point.y);
45462
46541
  if (entity === void 0) return;
46542
+ const scroll = movingScrollOf(ctx, entity);
46543
+ if (scroll !== void 0) {
46544
+ ctx.state.caught = true;
46545
+ press(ctx, scroll);
46546
+ return;
46547
+ }
45463
46548
  if (ecs.has(entity, Traceable)) {
45464
46549
  beginTrace(ctx, entity, point);
45465
46550
  return;
45466
46551
  }
45467
- ctx.state.entity = entity;
45468
- ctx.state.key = projection.keyOf(entity);
45469
- ecs.tag(entity, Pressed);
46552
+ press(ctx, entity);
45470
46553
  if (ecs.has(entity, Draggable) && ecs.has(entity, Swipeable)) ctx.log.warn("input: Draggable wins over Swipeable", { view: ctx.state.key });
45471
46554
  }
45472
46555
  /**
46556
+ * The press just left the tap slop: it goes to the scroll it pans, the innermost `Scrollable` of
46557
+ * the pressed view's own `Parent` chain (the view itself included) that moves on the dominant axis
46558
+ * of the move. The view loses `Pressed`, and the press is marked caught: it is a scroll gesture
46559
+ * now, so its release answers nothing, also when the finger came back inside the slop. A
46560
+ * draggable or swipeable view keeps its gesture, a caught press keeps its scroll, and a view that
46561
+ * sits in no scroll of that axis keeps its press, also a popup button drawn over a list.
46562
+ *
46563
+ * @param ctx - Domain context of the input plugin.
46564
+ * @param point - Where the finger is, in reference units.
46565
+ */
46566
+ function handOff(ctx, point) {
46567
+ const { caught, entity, start } = ctx.state;
46568
+ const { ecs } = ctx.deps.world;
46569
+ if (entity === void 0 || caught) return;
46570
+ if (ecs.has(entity, Draggable) || ecs.has(entity, Swipeable)) return;
46571
+ const scroll = scrollOnAxisOf(ctx, entity, axisOf$1(start, point));
46572
+ if (scroll === void 0 || scroll === entity) return;
46573
+ ecs.untag(entity, Pressed);
46574
+ ctx.state.caught = true;
46575
+ press(ctx, scroll);
46576
+ }
46577
+ /**
45473
46578
  * Turns a press on a draggable view into a drag once the finger left the start by more than
45474
46579
  * `dragStartPx`. A draggable view with no projection key cannot be carried: `mute`, `lift` and
45475
46580
  * `settle` belong to a projection, so the gesture is reported and given up.
@@ -45495,13 +46600,15 @@ function onMove(ctx, point) {
45495
46600
  * Answers the release of a press: a tap when the finger stayed inside the slop, a swipe when it
45496
46601
  * went far enough and fast enough. Neither: nothing happened, and nothing has to be undone. Every
45497
46602
  * release inside the slop runs the `onTap` listeners first, so a view that carries only
45498
- * `Touchable` is heard although it answers nothing.
46603
+ * `Touchable` is heard although it answers nothing. A caught press belongs to a scroll — it
46604
+ * stopped a moving one or was handed to one — and answers nothing and runs no listener.
45499
46605
  *
45500
46606
  * @param ctx - Domain context of the input plugin.
45501
46607
  * @param entity - The pressed view.
45502
46608
  * @param point - Where the finger let go, in reference units.
45503
46609
  */
45504
46610
  function answerRelease(ctx, entity, point) {
46611
+ if (ctx.state.caught) return;
45505
46612
  const { ecs } = ctx.deps.world;
45506
46613
  const travelled = distance(ctx.state.start, point);
45507
46614
  if (travelled <= ctx.config.tapSlopPx) {
@@ -45568,13 +46675,24 @@ function rests(ctx) {
45568
46675
  return ctx.state.phase === "pressed" && !ctx.state.moved;
45569
46676
  }
45570
46677
  /**
46678
+ * Tells whether a sample takes the press past the tap slop for the first time: the press has not
46679
+ * moved yet and the finger is farther than `tapSlopPx` from the start.
46680
+ *
46681
+ * @param ctx - Domain context of the input plugin.
46682
+ * @param point - Where the finger is, in reference units.
46683
+ * @returns True in the one sample that turns the press into a moved one.
46684
+ */
46685
+ function leavesSlop(ctx, point) {
46686
+ return ctx.state.phase === "pressed" && !ctx.state.moved && distance(ctx.state.start, point) > ctx.config.tapSlopPx;
46687
+ }
46688
+ /**
45571
46689
  * Feeds one raw sample to the machine. A leave does nothing here. A sample of another pointer is
45572
46690
  * dropped while a pointer is followed: the second finger is ignored. One exception: a press while
45573
46691
  * the followed pointer rests takes the pointer over and cancels the resting press, so a resting
45574
46692
  * thumb or palm never blocks the finger that plays. A move of an idle mouse or pen hit-tests
45575
46693
  * nothing: its point is returned, so the drain moves the hover once per frame. A move of a press
45576
- * past the tap slop marks the press as moved for good. A lost capture ends the gesture like a
45577
- * cancel.
46694
+ * past the tap slop marks the press as moved for good and, in that one sample, hands it to the
46695
+ * scroll it pans. A lost capture ends the gesture like a cancel.
45578
46696
  *
45579
46697
  * @param ctx - Domain context of the input plugin.
45580
46698
  * @param pointer - The `Pointer` resource of this frame.
@@ -45597,7 +46715,10 @@ function handleSample(ctx, pointer, sample) {
45597
46715
  break;
45598
46716
  case "move":
45599
46717
  if (isHover(ctx, pointer, sample)) return point;
45600
- if (ctx.state.phase === "pressed" && !ctx.state.moved && distance(ctx.state.start, point) > ctx.config.tapSlopPx) ctx.state.moved = true;
46718
+ if (leavesSlop(ctx, point)) {
46719
+ ctx.state.moved = true;
46720
+ handOff(ctx, point);
46721
+ }
45601
46722
  if (ctx.state.phase === "tracing") extendTrace(ctx, point);
45602
46723
  else onMove(ctx, point);
45603
46724
  break;
@@ -45770,6 +46891,7 @@ function createInputState(_ctx) {
45770
46891
  phase: "idle",
45771
46892
  pointerId: void 0,
45772
46893
  moved: false,
46894
+ caught: false,
45773
46895
  entity: void 0,
45774
46896
  key: void 0,
45775
46897
  start: {
@@ -49179,6 +50301,31 @@ const Scroll = /*#__PURE__*/ component("Scroll", {
49179
50301
  min: 0
49180
50302
  });
49181
50303
  /**
50304
+ * On every scroll container: where it stands and whether it moves, for a game system and the
50305
+ * read door. `ui` writes it in a frame where the offset or `max` changed, and once more with
50306
+ * `moving: false` when it stops; a game reads it and never writes it.
50307
+ *
50308
+ * @example
50309
+ * ```ts
50310
+ * // A board system dims the events of the order strip while it rests on the orders.
50311
+ * export const stripMist = system({
50312
+ * name: "board.stripMist",
50313
+ * phase: "animate",
50314
+ * query: [ScrollState, OrderStrip],
50315
+ * run: entities => {
50316
+ * for (const [entity, scroll] of entities) {
50317
+ * // scroll: { offset: 318, max: 390, moving: false }
50318
+ * }
50319
+ * }
50320
+ * });
50321
+ * ```
50322
+ */
50323
+ const ScrollState = /*#__PURE__*/ component("ScrollState", {
50324
+ offset: 0,
50325
+ max: 0,
50326
+ moving: false
50327
+ });
50328
+ /**
49182
50329
  * On the root entity of a popup kept beneath another popup (`popup(..., { over })`). Internal to
49183
50330
  * `ui`: not exported from the package root. Every element of that root resolves `is.covered`.
49184
50331
  */
@@ -50911,8 +52058,9 @@ function windowFormOf(node) {
50911
52058
  }
50912
52059
  /**
50913
52060
  * Refuses the props of a scroll its windowed form cannot take: some but not all of `rows`,
50914
- * `rowHeight` and `row`, or a row height that is not above 0. Called before anything is spawned or
50915
- * patched, so a refused scroll leaves no half-made element.
52061
+ * `rowHeight` and `row`, a row height that is not above 0, or `axis: "x"`, since the rows of a
52062
+ * window stand one below the other. Called before anything is spawned or patched, so a refused
52063
+ * scroll leaves no half-made element.
50916
52064
  *
50917
52065
  * @param node - The node being placed.
50918
52066
  * @throws {Error} For a windowed scroll whose props do not hold.
@@ -50922,7 +52070,50 @@ function windowFormOf(node) {
50922
52070
  * ```
50923
52071
  */
50924
52072
  function checkWindow(node) {
50925
- if (node.type === "scroll") windowFormOf(node);
52073
+ if (node.type !== "scroll") return;
52074
+ if (windowFormOf(node) !== void 0 && axisOf(node) === "x") throw new Error("[game] A windowed scroll moves on y only.\n Drop axis \"x\" or use the children form.");
52075
+ }
52076
+ /**
52077
+ * The axis a scroll moves on: `"x"` when its props say so, else `"y"`.
52078
+ *
52079
+ * @param node - The node of a scroll.
52080
+ * @returns The axis.
52081
+ * @example
52082
+ * ```ts
52083
+ * axisOf({ type: "scroll", props: { axis: "x" }, children: [] }); // "x"
52084
+ * ```
52085
+ */
52086
+ function axisOf(node) {
52087
+ return node.props.axis === "x" ? "x" : "y";
52088
+ }
52089
+ /**
52090
+ * The motion record a scroll spawns with: its axis, still at the start, not placed yet. The
52091
+ * first solve puts it at its `offset` prop.
52092
+ *
52093
+ * @param node - The node of the scroll that enters.
52094
+ * @returns A new record.
52095
+ * @example
52096
+ * ```ts
52097
+ * scrollMotionOf({ type: "scroll", props: { axis: "x" }, children: [] }).mode; // "still"
52098
+ * ```
52099
+ */
52100
+ function scrollMotionOf(node) {
52101
+ return {
52102
+ axis: axisOf(node),
52103
+ mode: "still",
52104
+ offset: 0,
52105
+ max: 0,
52106
+ velocity: 0,
52107
+ from: 0,
52108
+ to: 0,
52109
+ duration: 0,
52110
+ elapsed: 0,
52111
+ pointer: 0,
52112
+ start: 0,
52113
+ seen: 0,
52114
+ placed: false,
52115
+ atProp: false
52116
+ };
50926
52117
  }
50927
52118
  /**
50928
52119
  * Builds a spacer: an empty node of a fixed height that never shrinks.
@@ -53348,15 +54539,23 @@ const VISUALS = [
53348
54539
  ];
53349
54540
  /**
53350
54541
  * The input components of an element and the Escape marker; a disabled or covered button drops
53351
- * the answering one and the marker.
54542
+ * the answering one and the marker. A scroll's three hold its runtime state.
53352
54543
  */
53353
54544
  const INPUTS = [
53354
54545
  Tappable,
53355
54546
  Touchable,
53356
54547
  LocalWrite,
53357
54548
  Scroll,
54549
+ Scrollable,
54550
+ ScrollState,
53358
54551
  Escapable
53359
54552
  ];
54553
+ /** The components of a scroll that hold where it stands: added once, never patched by a render. */
54554
+ const SCROLL_STATE = namesOf$1([
54555
+ Scroll,
54556
+ Scrollable,
54557
+ ScrollState
54558
+ ]);
53360
54559
  /**
53361
54560
  * One list of the visual and the input types. Its own function, because lint rule L5 refuses a
53362
54561
  * collection built in a module-scope declaration.
@@ -53371,6 +54570,14 @@ function concatTypes(...lists) {
53371
54570
  const LIVE_TYPES = concatTypes(VISUALS, INPUTS);
53372
54571
  /** The owned fields of a type that gives none to a plugin: one list, never a new one. */
53373
54572
  const NO_OWNED = [];
54573
+ /** The time of a reconcile a test steps by hand: no ms pass, so no scroll moves. */
54574
+ const NO_TIME = Object.freeze({
54575
+ delta: 0,
54576
+ elapsed: 0,
54577
+ scale: 1,
54578
+ frame: 0,
54579
+ idle: false
54580
+ });
53374
54581
  /**
53375
54582
  * Tells whether a list of component values holds one of a type.
53376
54583
  *
@@ -53411,6 +54618,11 @@ const ELEMENT_OWNED = namesOf$1([
53411
54618
  ]);
53412
54619
  /** The style of a scroll's content: one object, so a render never reads as a new style. */
53413
54620
  const CONTENT_STYLE = Object.freeze({ origin: "topLeft" });
54621
+ /** The style of an x scroll's content: its children stand side by side. */
54622
+ const CONTENT_ROW_STYLE = Object.freeze({
54623
+ origin: "topLeft",
54624
+ direction: "row"
54625
+ });
53414
54626
  /**
53415
54627
  * The props, besides `style`, `state` and `content`, that a live element is written from: `bind`,
53416
54628
  * `texture`, `name` and `fit` reach its visual, `intent`, `payload`, `local` and `escape` its
@@ -53448,23 +54660,24 @@ function writesSame(previous, next) {
53448
54660
  /**
53449
54661
  * The children one element is diffed against. A scroll container holds exactly one of them: the
53450
54662
  * content the finger moves, which keeps the rects of its own children and carries them along. Its
53451
- * pivot is its top-left corner, so the scroll step writes its offset straight into `y`; it lays
53452
- * its children out as a column. The content of a windowed scroll holds the slots of its window,
53453
- * the two spacers and the rows, instead of the markup's children.
54663
+ * pivot is its top-left corner, so the scroll step writes its offset straight into `x` or `y`; it
54664
+ * lays its children out as a column, or as a row on the `x` axis. The content of a windowed scroll
54665
+ * holds the slots of its window, the two spacers and the rows, instead of the markup's children.
53454
54666
  *
53455
54667
  * @param node - The node the markup wrote.
53456
54668
  * @param slots - The slots of a windowed scroll, top spacer to bottom spacer.
54669
+ * @param axis - The axis of a scroll, the one it spawned with.
53457
54670
  * @returns The children of the element.
53458
54671
  * @example
53459
54672
  * ```ts
53460
54673
  * childrenOf({ type: "scroll", props: {}, children: [] }).length; // 1
53461
54674
  * ```
53462
54675
  */
53463
- function childrenOf(node, slots) {
54676
+ function childrenOf(node, slots, axis = "y") {
53464
54677
  if (node.type !== "scroll") return node.children;
53465
54678
  return [{
53466
54679
  type: CONTENT$1,
53467
- props: { style: CONTENT_STYLE },
54680
+ props: { style: axis === "x" ? CONTENT_ROW_STYLE : CONTENT_STYLE },
53468
54681
  children: slots ?? node.children
53469
54682
  }];
53470
54683
  }
@@ -53543,16 +54756,15 @@ function styleOf(node) {
53543
54756
  return typeof style === "object" && style !== null ? style : void 0;
53544
54757
  }
53545
54758
  /**
53546
- * Refuses the tags that cannot be placed: a text field that names no local field, a horizontal
53547
- * scroll, and a windowed scroll whose props do not hold.
54759
+ * Refuses the tags that cannot be placed: a text field that names no local field, and a windowed
54760
+ * scroll whose props do not hold.
53548
54761
  *
53549
54762
  * @param node - The node being placed.
53550
- * @throws {Error} For an `input` tag without `local`, for a horizontal scroll and for a windowed
53551
- * scroll whose props do not hold.
54763
+ * @throws {Error} For an `input` tag without `local`, and for a windowed scroll whose props do
54764
+ * not hold.
53552
54765
  */
53553
54766
  function checkTag(node) {
53554
54767
  if (node.type === "input" && typeof node.props.local !== "string") throw new Error("[game] An input needs a local field.\n Write <input local=\"name\" />.");
53555
- if (node.type === "scroll" && node.props.axis === "x") throw new Error("[game] Horizontal scroll arrives after V3.\n Use axis \"y\".");
53556
54768
  checkWindow(node);
53557
54769
  }
53558
54770
  /**
@@ -53575,16 +54787,28 @@ function answerOf(node) {
53575
54787
  /**
53576
54788
  * The input components of an element: a button answers the gate, writes local state, or only
53577
54789
  * swallows the tap; a panel swallows the tap too, so nothing under it answers; a text field takes
53578
- * the tap that starts its editing; a scroll container takes the press that moves its content. A button of a covered popup answers nothing,
53579
- * so only the top popup answers the gate. A button with the `escape` prop that answers also
53580
- * carries `Escapable`, the control the Escape key taps.
54790
+ * the tap that starts its editing; a scroll container takes the press that moves its content and
54791
+ * carries where it stands, on the axis it spawned with. A button of a covered popup answers
54792
+ * nothing, so only the top popup answers the gate. A button with the `escape` prop that answers
54793
+ * also carries `Escapable`, the control the Escape key taps.
53581
54794
  *
53582
54795
  * @param element - The element to make touchable.
53583
54796
  * @returns The component values.
53584
54797
  */
53585
54798
  function inputOf(element) {
53586
54799
  const { type, node, is } = element;
53587
- if (type === "scroll") return [Touchable(), Scroll({ axis: "y" })];
54800
+ if (type === "scroll") {
54801
+ const axis = element.scroll?.axis ?? "y";
54802
+ return [
54803
+ Touchable(),
54804
+ Scroll({ axis }),
54805
+ Scrollable({
54806
+ axis,
54807
+ moving: false
54808
+ }),
54809
+ ScrollState()
54810
+ ];
54811
+ }
53588
54812
  if (type === "panel" || type === "input") return [Touchable()];
53589
54813
  if (type !== "button") return [];
53590
54814
  if (is.disabled || is.covered) return [Touchable()];
@@ -53803,6 +55027,7 @@ function createReconciler(ctx, modules, fields) {
53803
55027
  let slotsVersion = -1;
53804
55028
  let slotsRoots = -1;
53805
55029
  let placedUnder;
55030
+ let rendered = 0;
53806
55031
  /**
53807
55032
  * Registers the key of an element under its root, so `entityOf` and `find` answer for it. A key
53808
55033
  * `ui` gave a node of its own, a spacer's, is never registered.
@@ -53899,13 +55124,16 @@ function createReconciler(ctx, modules, fields) {
53899
55124
  trackHost(state, element);
53900
55125
  registerKey(root, element);
53901
55126
  if (element.type === "input") fields.enter(element);
53902
- if (element.type === "scroll") state.scrolls.add(entity);
55127
+ if (element.type === "scroll") {
55128
+ state.scrolls.add(entity);
55129
+ element.scroll = scrollMotionOf(node);
55130
+ }
53903
55131
  modules.layout.attach(element);
53904
55132
  element.basis = {
53905
55133
  epoch: state.epoch,
53906
55134
  rowHeight: range.rowHeightOf(parent, node)
53907
55135
  };
53908
- diffChildren(root, element, childrenOf(node, range.slotsFor(element)), instance);
55136
+ diffChildren(root, element, childrenOf(node, range.slotsFor(element), element.scroll?.axis), instance);
53909
55137
  root.needsSolve = true;
53910
55138
  return entity;
53911
55139
  }
@@ -54042,11 +55270,12 @@ function createReconciler(ctx, modules, fields) {
54042
55270
  /**
54043
55271
  * Writes the visual and input components of a live element again. A visual or an input the
54044
55272
  * element no longer carries is removed, so a variant can trade the rectangle for a nine-slice and
54045
- * back, and a button that became disabled stops answering the gate. `Scroll` is only ever added:
54046
- * its offset belongs to the finger, and a new look or a new rect never resets it. The look
54047
- * written is recorded as the rest of the visual, so the rest the world holds is always the one
54048
- * the element is drawn with: a restore brings it back to exactly that. The extras of the
54049
- * `components` prop are diffed last. Every call is one write pass of `UiCounters`.
55273
+ * back, and a button that became disabled stops answering the gate. `Scroll`, `Scrollable` and
55274
+ * `ScrollState` are only ever added: where a scroll stands belongs to the finger and the scroll
55275
+ * step, and a new look or a new rect never resets it. The look written is recorded as the rest
55276
+ * of the visual, so the rest the world holds is always the one the element is drawn with: a
55277
+ * restore brings it back to exactly that. The extras of the `components` prop are diffed last.
55278
+ * Every call is one write pass of `UiCounters`.
54050
55279
  *
54051
55280
  * @param element - The element that changed.
54052
55281
  */
@@ -54063,7 +55292,8 @@ function createReconciler(ctx, modules, fields) {
54063
55292
  }
54064
55293
  /**
54065
55294
  * Writes one visual or input value onto a live element: added when the entity lacks it, else
54066
- * its fields patched, but never a tag or `Scroll`, and never a field its type gives to a plugin.
55295
+ * its fields patched, but never a tag or the state of a scroll, and never a field its type gives
55296
+ * to a plugin.
54067
55297
  *
54068
55298
  * @param element - The live element.
54069
55299
  * @param value - The value of this render.
@@ -54073,7 +55303,7 @@ function createReconciler(ctx, modules, fields) {
54073
55303
  ecs.add(element.entity, value);
54074
55304
  return;
54075
55305
  }
54076
- if (value.value === true || value.type === Scroll) return;
55306
+ if (value.value === true || SCROLL_STATE.has(value.type.componentName)) return;
54077
55307
  const owned = ecs.typeOf(value.type.componentName)?.owned ?? NO_OWNED;
54078
55308
  ecs.set(element.entity, asHandle(value.type), livePatch(value.value, owned));
54079
55309
  }
@@ -54152,6 +55382,7 @@ function createReconciler(ctx, modules, fields) {
54152
55382
  */
54153
55383
  function patch(root, element, node, instance) {
54154
55384
  checkWindow(node);
55385
+ warnAxis(element, node);
54155
55386
  const change = compare(root, element, node);
54156
55387
  const motion = node.props.motion;
54157
55388
  const loopChanged = !sameLoop(element.motion?.loop, motion?.loop);
@@ -54172,7 +55403,22 @@ function createReconciler(ctx, modules, fields) {
54172
55403
  rowHeight: change.rowHeight
54173
55404
  };
54174
55405
  }
54175
- diffChildren(root, element, childrenOf(node, range.slotsFor(element)), instance);
55406
+ diffChildren(root, element, childrenOf(node, range.slotsFor(element), element.scroll?.axis), instance);
55407
+ }
55408
+ /**
55409
+ * Warns once when a render gives a live scroll another `axis`: it keeps the one it spawned with.
55410
+ *
55411
+ * @param element - The element being patched.
55412
+ * @param node - The node of this render.
55413
+ */
55414
+ function warnAxis(element, node) {
55415
+ const axis = element.scroll?.axis;
55416
+ if (axis === void 0 || axis === axisOf(node) || element.warned.has("scroll-axis-fixed")) return;
55417
+ element.warned.add("scroll-axis-fixed");
55418
+ ctx.log.warn("ui:scroll-axis-fixed", {
55419
+ key: element.key ?? element.identity,
55420
+ axis
55421
+ });
54176
55422
  }
54177
55423
  /**
54178
55424
  * Counts a patch that brings a loop of another reference that is not really another loop: the
@@ -54829,8 +56075,10 @@ function createReconciler(ctx, modules, fields) {
54829
56075
  * again, the roots a scene placed again, the popups that may leave, the pull of the text being
54830
56076
  * typed, the scroll offsets and the windowed ranges, the bound texts that show a string of
54831
56077
  * another size, the viewport, the dirty roots and the hosted views.
56078
+ *
56079
+ * @param time - The frame's `Time`: the scrolls move by its `delta`.
54832
56080
  */
54833
- function reconcile() {
56081
+ function reconcile(time = NO_TIME) {
54834
56082
  sweep();
54835
56083
  state.reconciles += 1;
54836
56084
  if (state.remount) remountRoots();
@@ -54838,13 +56086,14 @@ function createReconciler(ctx, modules, fields) {
54838
56086
  settlePopups(ctx, state, unmountRoot);
54839
56087
  fields.pull();
54840
56088
  const containers = scrollContainers();
54841
- modules.layout.scroll(containers, lookup);
56089
+ modules.layout.scroll(containers, lookup, time);
54842
56090
  range.step(containers);
54843
56091
  markResizedTexts(ctx, state, modules.layout);
54844
56092
  markResizedIcons(ctx, state, modules.layout);
54845
56093
  const viewportChanged = modules.styles.useViewport(ctx.deps.renderer.viewport.size());
54846
56094
  if (viewportChanged) state.epoch += 1;
54847
56095
  const count = dirtyRoots(viewportChanged);
56096
+ rendered = count;
54848
56097
  for (let index = 0; index < count; index += 1) {
54849
56098
  const root = dirtyList[index];
54850
56099
  if (root !== void 0) reconcileDirtyRoot(root);
@@ -54870,16 +56119,22 @@ function createReconciler(ctx, modules, fields) {
54870
56119
  /**
54871
56120
  * The second system of phase `layout`: every root takes its slot, then one solve per marked
54872
56121
  * root, a root in a slot after the root of its slot and in its content box, then the rects, the
54873
- * rest poses and the hooks, and the hosting of the hosts that came. The hints released since the last frame step are dropped at its end, so
54874
- * the `change.Box` and `change.Transform` hooks the solve plays get the hint the reconcile
54875
- * routed, and the next frame starts with none.
56122
+ * rest poses and the hooks, the scrolls settled on what the solve and the render gave them, and
56123
+ * the hosting of the hosts that came. The hints released since the last frame step are dropped
56124
+ * at its end, so the `change.Box` and `change.Transform` hooks the solve plays get the hint the
56125
+ * reconcile routed, and the next frame starts with none.
56126
+ *
56127
+ * @param time - The frame's `Time`: a scroll left moving asks for a frame only while it runs.
54876
56128
  */
54877
- function solve() {
56129
+ function solve(time = NO_TIME) {
54878
56130
  const viewport = modules.styles.viewport();
54879
56131
  viewportBox.w = viewport?.width ?? 0;
54880
56132
  viewportBox.h = viewport?.height ?? 0;
54881
56133
  assignSlots();
54882
- if (layOutRoots(viewportBox)) ctx.deps.time.wake();
56134
+ const changed = layOutRoots(viewportBox);
56135
+ if (changed) ctx.deps.time.wake();
56136
+ if (changed || rendered > 0) modules.layout.settleScroll(scrollContainers(), lookup, time);
56137
+ rendered = 0;
54883
56138
  settleOverlays(ctx, state, modules.layout);
54884
56139
  if (state.risen.size > 0) {
54885
56140
  hostViews(ctx, state, state.risen);
@@ -55056,7 +56311,8 @@ function sceneLayersOf(ctx) {
55056
56311
  *
55057
56312
  * @param ctx - Domain context of the ui plugin.
55058
56313
  * @param modules - The styles and layout modules, injected in that order.
55059
- * @returns The module: `tree`, `find` and `lint` reach the game, the rest the frame.
56314
+ * @returns The module: `tree`, `find`, `lint`, `fill` and `scroll` reach the game, the rest the
56315
+ * frame.
55060
56316
  */
55061
56317
  function createJsxApi(ctx, modules) {
55062
56318
  const state = ctx.state.jsx;
@@ -55073,6 +56329,7 @@ function createJsxApi(ctx, modules) {
55073
56329
  const frame = createReconciler(ctx, modules, fields);
55074
56330
  const focus = createFocus(ctx, frame.markPointer, topRoot, fields);
55075
56331
  const resolvedOf = (entity) => ctx.deps.world.ecs.get(entity, Text)?.resolved;
56332
+ const lookup = (entity) => state.elements.get(entity);
55076
56333
  const find = (key) => {
55077
56334
  for (const root of sortedRoots(state, layerNames())) {
55078
56335
  const entity = state.byKey.get(root.entity)?.get(key);
@@ -55093,12 +56350,25 @@ function createJsxApi(ctx, modules) {
55093
56350
  fields.fill(entity, value);
55094
56351
  return true;
55095
56352
  },
56353
+ scroll: (key, to) => {
56354
+ const entity = find(key);
56355
+ const element = entity === void 0 ? void 0 : lookup(entity);
56356
+ if (element?.scroll === void 0) {
56357
+ ctx.log.warn("ui:scroll-without-scroll", { key });
56358
+ return false;
56359
+ }
56360
+ if (!modules.layout.scrollTo(element, to, lookup)) {
56361
+ ctx.log.warn("ui:scroll-before-solve", { key });
56362
+ return false;
56363
+ }
56364
+ return true;
56365
+ },
55096
56366
  open: fields.open,
55097
56367
  pointer: (sample) => fields.pointer(sample),
55098
56368
  tapped: (entity) => focus.tapped(entity),
55099
56369
  reconcile: frame.reconcile,
55100
- solve: () => {
55101
- frame.solve();
56370
+ solve: (time) => {
56371
+ frame.solve(time);
55102
56372
  focus.refresh();
55103
56373
  fields.place();
55104
56374
  },
@@ -56203,25 +57473,63 @@ function createPopupHandler(ctx, state, jsx) {
56203
57473
  //#endregion
56204
57474
  //#region src/plugins/ui/layout/scroll.ts
56205
57475
  /**
56206
- * @file ui/layout — vertical scroll: while the container is pressed the content moves with the
56207
- * finger through one `Transform` write. No solve, no motion — the children keep their rects. A
56208
- * windowed scroll knows its content height from `rows × rowHeight`, so its range is kept even
56209
- * when no finger is on it.
57476
+ * @file ui/layout — scroll. A scroll container moves its content on its axis: with the finger,
57477
+ * then in a fling that slows down, or in a glide to a `snap` stop; it starts at its `offset`
57478
+ * prop, glides when the prop changes, and stays inside the range every solve gives it, with hard
57479
+ * edges. The content moves by one `Transform` write on the axis: no solve, no motion, the children
57480
+ * keep their rects. `Scroll` holds the offset the way the windowed range is cut with (0 and below),
57481
+ * `ScrollState` the way a game reads it (0 to `max`), `Scrollable` whether it moves, for `input`.
57482
+ * The exact numbers live on the element's `scroll` record and the samples of the press in the
57483
+ * layout state, written in place; the components get whole reference units. A frame allocates
57484
+ * nothing.
57485
+ */
57486
+ /** How far ahead a release carries its velocity before the nearest `snap` stop is taken, in ms. */
57487
+ const PROJECT_MS = 260;
57488
+ /** What a fling keeps of its velocity per ms. */
57489
+ const DECAY_PER_MS = .998;
57490
+ /** The speed under which a fling stops, in reference units per ms. */
57491
+ const STOP_SPEED = .02;
57492
+ /** The shortest glide, in ms. */
57493
+ const GLIDE_MS = 260;
57494
+ /** The ms each reference unit of distance adds to a glide. */
57495
+ const GLIDE_MS_PER_UNIT = 1.1;
57496
+ /** The longest glide, in ms. */
57497
+ const GLIDE_MAX_MS = 620;
57498
+ /** The samples of the last ms before a release give its velocity. */
57499
+ const VELOCITY_MS = 100;
57500
+ /** A finger that stood still for longer than this before its release lets go with no velocity, in ms. */
57501
+ const STILL_MS = 50;
57502
+ /** The numbers of one sample in the ring: where the finger was on the axis, and when. */
57503
+ const SAMPLE_STRIDE = 2;
57504
+ /** The fewest samples a release velocity is read from. */
57505
+ const MIN_SAMPLES = 2;
57506
+ /**
57507
+ * Keeps an offset from the start inside 0 to `max`.
57508
+ *
57509
+ * @param offset - Where the content would stand, from the start.
57510
+ * @param max - The largest offset.
57511
+ * @returns The offset it stands at.
57512
+ * @example
57513
+ * ```ts
57514
+ * within(420, 390); // 390
57515
+ * ```
56210
57516
  */
57517
+ function within(offset, max) {
57518
+ return Math.min(max, Math.max(0, offset));
57519
+ }
56211
57520
  /**
56212
- * Keeps an offset inside the scrollable range: never below the end of the content, never above
56213
- * the top.
57521
+ * How far a length runs past a window, in whole reference units: 0 when it fits.
56214
57522
  *
56215
- * @param offset - Where the content would go.
56216
- * @param min - The most negative offset, which is viewport height minus content height.
56217
- * @returns The offset the content is written with.
57523
+ * @param length - What is scrolled.
57524
+ * @param window - What shows of it.
57525
+ * @returns The largest offset.
56218
57526
  * @example
56219
57527
  * ```ts
56220
- * clampOffset(40, -300); // 0
57528
+ * extent(696, 306); // 390
56221
57529
  * ```
56222
57530
  */
56223
- function clampOffset(offset, min) {
56224
- return Math.min(0, Math.max(min, offset));
57531
+ function extent(length, window) {
57532
+ return Math.max(0, Math.round(length - window));
56225
57533
  }
56226
57534
  /**
56227
57535
  * The content entity of a scroll container: its single child.
@@ -56235,106 +57543,491 @@ function contentOf(container, lookup) {
56235
57543
  return first === void 0 ? void 0 : lookup(first);
56236
57544
  }
56237
57545
  /**
56238
- * The height of what a container scrolls: `rows × rowHeight` for a windowed scroll, which equals
56239
- * its solved content rect, else the solved content rect.
57546
+ * The largest offset of a scroll on its axis: its content's rect past its own, or
57547
+ * `rows × rowHeight` past its height for a windowed scroll.
56240
57548
  *
56241
- * @param container - The scroll element.
57549
+ * @param container - The scroll element, with a solved rect.
56242
57550
  * @param content - Its content element.
56243
- * @returns The height in reference units.
57551
+ * @param axis - The axis it moves on.
57552
+ * @returns The largest offset, in whole reference units.
56244
57553
  */
56245
- function contentHeightOf(container, content) {
57554
+ function maxOf(container, content, axis) {
57555
+ if (axis === "x") return extent(content.rect.w, container.rect.w);
56246
57556
  const window = container.window;
56247
- return window === void 0 ? content.rect.h : window.rows * window.rowHeight;
57557
+ return extent(window === void 0 ? content.rect.h : window.rows * window.rowHeight, container.rect.h);
57558
+ }
57559
+ /**
57560
+ * The `offset` prop of the scroll's last render: 0 when it is left out or is no finite number.
57561
+ *
57562
+ * @param container - The scroll element.
57563
+ * @returns The offset it asks for, from the start.
57564
+ */
57565
+ function askedOffsetOf(container) {
57566
+ const offset = container.node.props.offset;
57567
+ return typeof offset === "number" && Number.isFinite(offset) ? offset : 0;
57568
+ }
57569
+ /**
57570
+ * The `snap` prop of the scroll's last render.
57571
+ *
57572
+ * @param container - The scroll element.
57573
+ * @returns The stops, or `undefined` when it names none.
57574
+ */
57575
+ function snapOf(container) {
57576
+ const snap = container.node.props.snap;
57577
+ return Array.isArray(snap) && snap.length > 0 ? snap : void 0;
57578
+ }
57579
+ /**
57580
+ * The stop nearest to an offset, each stop kept inside 0 to `max`; the first of two as near.
57581
+ *
57582
+ * @param snap - The stops the scroll names.
57583
+ * @param offset - Where the release would carry it.
57584
+ * @param max - The largest offset.
57585
+ * @param fallback - Where it stays when no stop is a number.
57586
+ * @returns The stop.
57587
+ * @example
57588
+ * ```ts
57589
+ * nearestStop([0, 318, 390], 300, 390, 300); // 318
57590
+ * ```
57591
+ */
57592
+ function nearestStop(snap, offset, max, fallback) {
57593
+ let nearest = fallback;
57594
+ let distance = Number.POSITIVE_INFINITY;
57595
+ for (const candidate of snap) {
57596
+ if (typeof candidate !== "number") continue;
57597
+ const kept = within(candidate, max);
57598
+ if (Math.abs(kept - offset) < distance) {
57599
+ nearest = kept;
57600
+ distance = Math.abs(kept - offset);
57601
+ }
57602
+ }
57603
+ return nearest;
56248
57604
  }
56249
57605
  /**
56250
- * Writes an offset and the range it was clamped to, and moves the content there.
57606
+ * Stops a scroll where it stands.
57607
+ *
57608
+ * @param motion - Its motion.
57609
+ */
57610
+ function stop(motion) {
57611
+ motion.mode = "still";
57612
+ motion.velocity = 0;
57613
+ }
57614
+ /**
57615
+ * Starts a glide to an offset, kept inside the range, on the curve `1 − (1 − p)³` over
57616
+ * `min(620, 260 + distance × 1.1)` ms. A scroll already there stops instead.
57617
+ *
57618
+ * @param motion - Its motion.
57619
+ * @param target - Where it goes, from the start.
57620
+ */
57621
+ function glideTo(motion, target) {
57622
+ const to = within(target, motion.max);
57623
+ const distance = Math.abs(to - motion.offset);
57624
+ if (distance === 0) {
57625
+ stop(motion);
57626
+ return;
57627
+ }
57628
+ motion.mode = "glide";
57629
+ motion.velocity = 0;
57630
+ motion.from = motion.offset;
57631
+ motion.to = to;
57632
+ motion.elapsed = 0;
57633
+ motion.duration = Math.min(GLIDE_MAX_MS, GLIDE_MS + distance * GLIDE_MS_PER_UNIT);
57634
+ }
57635
+ /**
57636
+ * Where a sample of the press starts in the ring: `back` samples before the newest one, 0 being
57637
+ * the newest. The position is at the index, the time right after it.
57638
+ *
57639
+ * @param state - The layout state, which keeps the samples of the press.
57640
+ * @param back - How many samples before the newest; 0 to the number of slots minus 1.
57641
+ * @returns The index of the sample in `scrollRing`.
57642
+ */
57643
+ function slotBefore(state, back) {
57644
+ const slots = state.scrollRing.length / SAMPLE_STRIDE;
57645
+ return (state.scrollHead + slots - 1 - back) % slots * SAMPLE_STRIDE;
57646
+ }
57647
+ /**
57648
+ * Follows the finger on a held scroll. The first frame of a press, a hand-off included, takes the
57649
+ * finger where it is as the start, stops a fling or a glide where it stands, and ends the scroll's
57650
+ * standing at its `offset` prop; the offset then moves against the finger, inside 0 to `max`.
57651
+ * Every frame adds the finger's sample to the ring.
57652
+ *
57653
+ * @param state - The layout state, which keeps the samples of the press.
57654
+ * @param container - The held scroll.
57655
+ * @param motion - Its motion.
57656
+ * @param pointer - The `Pointer` resource of this frame.
57657
+ */
57658
+ function hold(state, container, motion, pointer) {
57659
+ const at = motion.axis === "x" ? pointer.x : pointer.y;
57660
+ const ring = state.scrollRing;
57661
+ const slots = ring.length / SAMPLE_STRIDE;
57662
+ if (motion.mode !== "held" || state.scrolling !== container.entity) {
57663
+ motion.mode = "held";
57664
+ motion.velocity = 0;
57665
+ motion.pointer = at;
57666
+ motion.start = motion.offset;
57667
+ motion.atProp = false;
57668
+ state.scrolling = container.entity;
57669
+ state.scrollSamples = 0;
57670
+ state.scrollMoved = state.scrollClock;
57671
+ }
57672
+ motion.offset = within(motion.start - (at - motion.pointer), motion.max);
57673
+ if (state.scrollSamples > 0 && ring[slotBefore(state, 0)] !== at) state.scrollMoved = state.scrollClock;
57674
+ ring[state.scrollHead * SAMPLE_STRIDE] = at;
57675
+ ring[state.scrollHead * SAMPLE_STRIDE + 1] = state.scrollClock;
57676
+ state.scrollHead = (state.scrollHead + 1) % slots;
57677
+ state.scrollSamples = Math.min(slots, state.scrollSamples + 1);
57678
+ }
57679
+ /**
57680
+ * The velocity of the offset at a release, in units a ms, positive toward `max`: the finger's
57681
+ * move over its samples of the last 100 ms, against the finger. 0 when the finger stood still for
57682
+ * more than 50 ms before it let go, or left a single sample.
57683
+ *
57684
+ * @param state - The layout state, which keeps the samples of the press.
57685
+ * @returns The velocity.
57686
+ */
57687
+ function releaseVelocity(state) {
57688
+ const ring = state.scrollRing;
57689
+ const now = state.scrollClock;
57690
+ if (state.scrollSamples < MIN_SAMPLES || now - state.scrollMoved > STILL_MS) return 0;
57691
+ const newest = slotBefore(state, 0);
57692
+ let oldest = newest;
57693
+ for (let back = 1; back < state.scrollSamples; back += 1) {
57694
+ const slot = slotBefore(state, back);
57695
+ if ((ring[slot + 1] ?? 0) < now - VELOCITY_MS) break;
57696
+ oldest = slot;
57697
+ }
57698
+ const span = (ring[newest + 1] ?? 0) - (ring[oldest + 1] ?? 0);
57699
+ return span > 0 ? ((ring[oldest] ?? 0) - (ring[newest] ?? 0)) / span : 0;
57700
+ }
57701
+ /**
57702
+ * Lets go of a scroll the finger held. With `snap` it glides to the stop nearest to where its
57703
+ * velocity carries in 260 ms; without, it flings, or stands when it was let go too slowly. A
57704
+ * press whose samples a later press took lets go with no velocity.
57705
+ *
57706
+ * @param state - The layout state, which keeps the samples of the press.
57707
+ * @param container - The scroll that was held.
57708
+ * @param motion - Its motion.
57709
+ * @param owned - Whether the samples are this press's.
57710
+ */
57711
+ function letGo(state, container, motion, owned) {
57712
+ const velocity = owned ? releaseVelocity(state) : 0;
57713
+ const snap = snapOf(container);
57714
+ if (snap !== void 0) {
57715
+ glideTo(motion, nearestStop(snap, motion.offset + velocity * PROJECT_MS, motion.max, motion.offset));
57716
+ return;
57717
+ }
57718
+ if (Math.abs(velocity) < STOP_SPEED) {
57719
+ stop(motion);
57720
+ return;
57721
+ }
57722
+ motion.mode = "fling";
57723
+ motion.velocity = velocity;
57724
+ }
57725
+ /**
57726
+ * Moves a flinging scroll by the frame: the offset by its velocity, the velocity down by 0.998 a
57727
+ * ms. It stops under 0.02 units a ms, or on an edge: the edges are hard and nothing passes the
57728
+ * rest of the fling to a parent.
57729
+ *
57730
+ * @param motion - Its motion.
57731
+ * @param time - The frame's `Time`.
57732
+ */
57733
+ function fling(motion, time) {
57734
+ const delta = time.delta;
57735
+ const next = motion.offset + motion.velocity * delta;
57736
+ motion.offset = within(next, motion.max);
57737
+ motion.velocity *= DECAY_PER_MS ** delta;
57738
+ if (motion.offset !== next || Math.abs(motion.velocity) < STOP_SPEED) stop(motion);
57739
+ }
57740
+ /**
57741
+ * Moves a gliding scroll by the frame along `1 − (1 − p)³`; at its end it stands on its target.
57742
+ *
57743
+ * @param motion - Its motion.
57744
+ * @param time - The frame's `Time`.
57745
+ */
57746
+ function glide(motion, time) {
57747
+ motion.elapsed += time.delta;
57748
+ const progress = Math.min(1, motion.elapsed / motion.duration);
57749
+ if (progress === 1) {
57750
+ motion.offset = motion.to;
57751
+ stop(motion);
57752
+ return;
57753
+ }
57754
+ const left = 1 - progress;
57755
+ motion.offset = motion.from + (motion.to - motion.from) * (1 - left * left * left);
57756
+ }
57757
+ /**
57758
+ * Moves the content to a whole offset on the scroll's axis, from where the solve put it: one
57759
+ * `Transform` write, none when it stands there already. The rects of the solve are whole numbers
57760
+ * that Yoga hands out as doubles; rounded, the pose is a small integer, which `Object.assign`
57761
+ * copies into the world without a box.
56251
57762
  *
56252
57763
  * @param ctx - Domain context of the ui plugin.
57764
+ * @param state - The layout state, which keeps the patches.
56253
57765
  * @param container - The scroll element.
56254
57766
  * @param content - Its content element.
56255
- * @param scroll - What its `Scroll` holds now.
57767
+ * @param motion - Its motion.
56256
57768
  */
56257
- function moveContent(ctx, container, content, scroll) {
57769
+ function moveContent(ctx, state, container, content, motion) {
56258
57770
  const ecs = ctx.deps.world.ecs;
56259
- ecs.set(container.entity, Scroll, {
56260
- offset: scroll.offset,
56261
- min: scroll.min
56262
- });
56263
- ecs.set(content.entity, Transform, { y: content.rect.y - container.rect.y + scroll.offset });
57771
+ const pose = ecs.get(content.entity, Transform);
57772
+ const offset = Math.round(motion.offset);
57773
+ if (pose === void 0) return;
57774
+ if (motion.axis === "x") {
57775
+ const x = Math.round(content.rect.x - container.rect.x) - offset;
57776
+ if (pose.x === x) return;
57777
+ state.scrollPatches.x.x = x;
57778
+ ecs.set(content.entity, Transform, state.scrollPatches.x);
57779
+ return;
57780
+ }
57781
+ const y = Math.round(content.rect.y - container.rect.y) - offset;
57782
+ if (pose.y === y) return;
57783
+ state.scrollPatches.y.y = y;
57784
+ ecs.set(content.entity, Transform, state.scrollPatches.y);
56264
57785
  }
56265
57786
  /**
56266
- * Keeps the offset of a scroll inside the range its content height gives, and writes that range,
56267
- * when either moved: a windowed list whose rows shrank, or its first solve.
57787
+ * Writes what changed of a scroll, in whole units: `Scroll` when its offset or range moved,
57788
+ * `ScrollState` when the offset, `max` or `moving` changed, `Scrollable` when `moving` did, and
57789
+ * the content's pose. A frame where nothing changed writes nothing.
56268
57790
  *
56269
57791
  * @param ctx - Domain context of the ui plugin.
56270
- * @param container - The scroll element, with a solved rect.
57792
+ * @param state - The layout state, which keeps the patches.
57793
+ * @param container - The scroll element.
56271
57794
  * @param content - Its content element.
56272
- * @param contentHeight - The height of what it scrolls.
56273
- * @returns The offset the content stands at.
56274
- */
56275
- function keepInRange(ctx, container, content, contentHeight) {
56276
- const scroll = ctx.deps.world.ecs.get(container.entity, Scroll);
56277
- if (scroll === void 0) return 0;
56278
- const min = Math.min(0, container.rect.h - contentHeight);
56279
- const offset = clampOffset(scroll.offset, min);
56280
- if (offset !== scroll.offset || min !== scroll.min) moveContent(ctx, container, content, {
56281
- ...scroll,
56282
- offset,
56283
- min
56284
- });
56285
- return offset;
57795
+ * @param motion - Its motion.
57796
+ */
57797
+ function show(ctx, state, container, content, motion) {
57798
+ const ecs = ctx.deps.world.ecs;
57799
+ const patches = state.scrollPatches;
57800
+ const offset = Math.round(motion.offset);
57801
+ const moving = motion.mode !== "still";
57802
+ const scroll = ecs.get(container.entity, Scroll);
57803
+ const shown = ecs.get(container.entity, ScrollState);
57804
+ const scrollable = ecs.get(container.entity, Scrollable);
57805
+ const rangeMoved = scroll !== void 0 && (scroll.offset !== 0 - offset || scroll.min !== 0 - motion.max);
57806
+ const stateMoved = shown !== void 0 && (shown.offset !== offset || shown.max !== motion.max || shown.moving !== moving);
57807
+ if (rangeMoved) {
57808
+ patches.scroll.offset = 0 - offset;
57809
+ patches.scroll.min = 0 - motion.max;
57810
+ ecs.set(container.entity, Scroll, patches.scroll);
57811
+ }
57812
+ if (stateMoved) {
57813
+ patches.state.offset = offset;
57814
+ patches.state.max = motion.max;
57815
+ patches.state.moving = moving;
57816
+ ecs.set(container.entity, ScrollState, patches.state);
57817
+ }
57818
+ if (scrollable !== void 0 && scrollable.moving !== moving) {
57819
+ patches.scrollable.moving = moving;
57820
+ ecs.set(container.entity, Scrollable, patches.scrollable);
57821
+ }
57822
+ moveContent(ctx, state, container, content, motion);
56286
57823
  }
56287
57824
  /**
56288
- * Re-clamps a live windowed scroll to the content height of its new rows before its range is
56289
- * cut, so a list that shrank below the window moves up in the same frame.
57825
+ * Tells whether a scroll has nothing to do this frame: its first solve has not placed it yet, or
57826
+ * it stands still and no finger is on it.
57827
+ *
57828
+ * @param motion - Its motion.
57829
+ * @param pressed - Whether a finger is on it.
57830
+ * @returns True when the frame leaves it alone.
57831
+ */
57832
+ function isIdle(motion, pressed) {
57833
+ return !motion.placed || !pressed && motion.mode === "still";
57834
+ }
57835
+ /**
57836
+ * Moves one scroll by the frame: the finger while it is pressed, the release when it lets go,
57837
+ * then a fling or a glide, and writes what changed. A still scroll no finger is on costs a tag
57838
+ * check.
56290
57839
  *
56291
57840
  * @param ctx - Domain context of the ui plugin.
57841
+ * @param state - The layout state.
56292
57842
  * @param container - The live scroll element.
56293
- * @param contentHeight - `rows × rowHeight` of this render.
56294
57843
  * @param lookup - How a child entity becomes its element.
56295
- * @returns The offset the content stands at.
57844
+ * @param pointer - The `Pointer` resource of this frame.
57845
+ * @param time - The frame's `Time`.
57846
+ * @returns True while the scroll moves.
56296
57847
  */
56297
- function clampScroll(ctx, container, contentHeight, lookup) {
57848
+ function stepOne(ctx, state, container, lookup, pointer, time) {
57849
+ const ecs = ctx.deps.world.ecs;
57850
+ const pressed = ecs.has(container.entity, Pressed);
57851
+ const released = !pressed && state.scrolling === container.entity;
57852
+ if (released) state.scrolling = void 0;
57853
+ const motion = container.scroll;
57854
+ if (motion === void 0 || isIdle(motion, pressed)) return false;
56298
57855
  const content = contentOf(container, lookup);
56299
- if (content === void 0) return ctx.deps.world.ecs.get(container.entity, Scroll)?.offset ?? 0;
56300
- return keepInRange(ctx, container, content, contentHeight);
57856
+ if (content === void 0 || !ecs.has(container.entity, Scroll)) return false;
57857
+ if (pressed) hold(state, container, motion, pointer);
57858
+ else if (motion.mode === "held") letGo(state, container, motion, released);
57859
+ if (motion.mode === "fling") fling(motion, time);
57860
+ else if (motion.mode === "glide") glide(motion, time);
57861
+ show(ctx, state, container, content, motion);
57862
+ return motion.mode !== "still";
56301
57863
  }
56302
57864
  /**
56303
- * Moves the content of every pressed scroll container with the finger. A windowed container no
56304
- * finger is on keeps its offset inside the range of its rows.
57865
+ * Moves every scroll container by the frame: with the finger while it is pressed, then in a
57866
+ * fling or a glide after it lets go. Time is the frame's `delta`, so a time scale and a pause
57867
+ * apply. While a scroll moves, `time.wake()` keeps the frames coming; at a time scale of 0 no
57868
+ * frame is asked for: a fling or a glide stands where it is and goes on once time runs again,
57869
+ * and a finger's samples wake the loop themselves.
56305
57870
  *
56306
57871
  * @param ctx - Domain context of the ui plugin.
56307
- * @param state - The layout state, which remembers where the drag started.
57872
+ * @param state - The layout state, which keeps the samples of the press.
56308
57873
  * @param containers - The live scroll elements.
56309
57874
  * @param lookup - How a child entity becomes its element.
57875
+ * @param time - The frame's `Time`.
57876
+ */
57877
+ function stepScroll(ctx, state, containers, lookup, time) {
57878
+ const pointer = ctx.deps.world.ecs.resource(Pointer);
57879
+ let moving = false;
57880
+ state.scrollClock += time.delta;
57881
+ for (const container of containers) if (stepOne(ctx, state, container, lookup, pointer, time)) moving = true;
57882
+ if (moving && time.delta > 0) ctx.deps.time.wake();
57883
+ }
57884
+ /**
57885
+ * Takes a new range: the offset keeps inside it at once, and so does a running glide. A scroll
57886
+ * that follows its `offset` prop goes on following it in the new range: standing, it stands at
57887
+ * the prop at once; gliding to it, it glides on to it from where it is. So a prop the first range
57888
+ * was too short for is reached once the content grows, a text measured again when its font
57889
+ * arrives.
57890
+ *
57891
+ * @param motion - The motion of the scroll.
57892
+ * @param max - The largest offset the solve gives it now.
57893
+ */
57894
+ function resize(motion, max) {
57895
+ if (max === motion.max) return;
57896
+ motion.max = max;
57897
+ motion.offset = within(motion.offset, max);
57898
+ motion.from = within(motion.from, max);
57899
+ motion.to = within(motion.to, max);
57900
+ if (!motion.atProp) return;
57901
+ if (motion.mode === "glide") glideTo(motion, motion.seen);
57902
+ else motion.offset = within(motion.seen, max);
57903
+ }
57904
+ /**
57905
+ * Puts a scroll the solve just spawned at its `offset` prop, inside its range, and lets it follow
57906
+ * the prop from there. Its spawn waits in the queue of the phase: these values replace the queued
57907
+ * ones at the flush, before the first draw.
57908
+ *
57909
+ * @param ctx - Domain context of the ui plugin.
57910
+ * @param container - The scroll element, live since this solve.
57911
+ * @param content - Its content element.
57912
+ * @param motion - Its motion, not placed yet.
56310
57913
  */
56311
- function stepScroll(ctx, state, containers, lookup) {
57914
+ function place$1(ctx, container, content, motion) {
56312
57915
  const ecs = ctx.deps.world.ecs;
56313
- const pointer = ecs.resource(Pointer);
57916
+ const rest = content.rest;
57917
+ motion.placed = true;
57918
+ motion.atProp = true;
57919
+ motion.max = maxOf(container, content, motion.axis);
57920
+ motion.seen = askedOffsetOf(container);
57921
+ motion.offset = within(motion.seen, motion.max);
57922
+ const offset = Math.round(motion.offset);
57923
+ const pose = motion.axis === "x" ? {
57924
+ ...rest,
57925
+ x: content.rect.x - container.rect.x - offset
57926
+ } : {
57927
+ ...rest,
57928
+ y: content.rect.y - container.rect.y - offset
57929
+ };
57930
+ ecs.add(container.entity, Scroll({
57931
+ axis: motion.axis,
57932
+ offset: 0 - offset,
57933
+ min: 0 - motion.max
57934
+ }));
57935
+ ecs.add(container.entity, ScrollState({
57936
+ offset,
57937
+ max: motion.max,
57938
+ moving: false
57939
+ }));
57940
+ ecs.add(content.entity, Transform(pose));
57941
+ }
57942
+ /**
57943
+ * Settles a placed scroll on what the solve and the render of this frame gave it: a new range at
57944
+ * once, then a glide to a new `offset` prop, which the scroll follows from then on. A scroll the
57945
+ * finger holds only remembers the prop.
57946
+ *
57947
+ * @param ctx - Domain context of the ui plugin.
57948
+ * @param state - The layout state, which keeps the patches.
57949
+ * @param container - The scroll element.
57950
+ * @param content - Its content element.
57951
+ * @param motion - Its motion, placed.
57952
+ */
57953
+ function settle(ctx, state, container, content, motion) {
57954
+ resize(motion, maxOf(container, content, motion.axis));
57955
+ const asked = askedOffsetOf(container);
57956
+ if (asked !== motion.seen) {
57957
+ motion.seen = asked;
57958
+ if (motion.mode !== "held") {
57959
+ glideTo(motion, asked);
57960
+ motion.atProp = true;
57961
+ }
57962
+ }
57963
+ show(ctx, state, container, content, motion);
57964
+ }
57965
+ /**
57966
+ * Settles every scroll on what the solve and the render of this frame gave it. A scroll the solve
57967
+ * just spawned takes its place at its `offset` prop. A placed one keeps inside a new range at
57968
+ * once, a running glide's target too, and glides to a new `offset` prop; the same value on a
57969
+ * re-render does nothing, and a scroll the finger holds only remembers it. A scroll left moving
57970
+ * wakes the loop the way `stepScroll` does: not at a time scale of 0, where it stands.
57971
+ *
57972
+ * @param ctx - Domain context of the ui plugin.
57973
+ * @param state - The layout state, which keeps the patches.
57974
+ * @param containers - The live scroll elements.
57975
+ * @param lookup - How a child entity becomes its element.
57976
+ * @param time - The frame's `Time`.
57977
+ */
57978
+ function settleScroll(ctx, state, containers, lookup, time) {
57979
+ let moving = false;
56314
57980
  for (const container of containers) {
56315
- const scroll = ecs.get(container.entity, Scroll);
57981
+ const motion = container.scroll;
56316
57982
  const content = contentOf(container, lookup);
56317
- if (scroll === void 0 || content === void 0) continue;
56318
- if (!ecs.has(container.entity, Pressed)) {
56319
- if (state.scrolling === container.entity) state.scrolling = void 0;
56320
- if (container.window !== void 0) keepInRange(ctx, container, content, contentHeightOf(container, content));
56321
- continue;
56322
- }
56323
- if (state.scrolling !== container.entity) {
56324
- state.scrolling = container.entity;
56325
- state.scrollStart = {
56326
- pointerY: pointer.y,
56327
- offset: scroll.offset
56328
- };
56329
- }
56330
- const min = Math.min(0, container.rect.h - contentHeightOf(container, content));
56331
- const offset = clampOffset(state.scrollStart.offset + (pointer.y - state.scrollStart.pointerY), min);
56332
- moveContent(ctx, container, content, {
56333
- ...scroll,
56334
- offset,
56335
- min
56336
- });
57983
+ if (motion === void 0 || content === void 0) continue;
57984
+ if (motion.placed) settle(ctx, state, container, content, motion);
57985
+ else place$1(ctx, container, content, motion);
57986
+ if (motion.mode !== "still") moving = true;
56337
57987
  }
57988
+ if (moving && time.delta > 0) ctx.deps.time.wake();
57989
+ }
57990
+ /**
57991
+ * Re-clamps a live windowed scroll to the content height of its new rows before its range is
57992
+ * cut, so a list that shrank below the window moves up in the same frame.
57993
+ *
57994
+ * @param ctx - Domain context of the ui plugin.
57995
+ * @param state - The layout state, which keeps the patches.
57996
+ * @param container - The live scroll element.
57997
+ * @param contentHeight - `rows × rowHeight` of this render.
57998
+ * @param lookup - How a child entity becomes its element.
57999
+ * @returns The offset the content stands at, 0 and below, as `Scroll` holds it.
58000
+ */
58001
+ function clampScroll(ctx, state, container, contentHeight, lookup) {
58002
+ const motion = container.scroll;
58003
+ const content = contentOf(container, lookup);
58004
+ if (motion === void 0 || !motion.placed || content === void 0) return 0;
58005
+ resize(motion, extent(contentHeight, container.rect.h));
58006
+ show(ctx, state, container, content, motion);
58007
+ return 0 - Math.round(motion.offset);
58008
+ }
58009
+ /**
58010
+ * Puts a placed scroll at an offset at once, inside its range: a fling or a glide stops, the
58011
+ * scroll no longer follows its `offset` prop, and `ScrollState` says it stands. A finger that
58012
+ * holds it goes on from there.
58013
+ *
58014
+ * @param ctx - Domain context of the ui plugin.
58015
+ * @param state - The layout state, which keeps the patches.
58016
+ * @param container - The scroll element.
58017
+ * @param to - Where it goes, from the start; `NaN` leaves it where it is.
58018
+ * @param lookup - How a child entity becomes its element.
58019
+ * @returns True when the scroll took it; false before its first solve placed it.
58020
+ */
58021
+ function scrollTo(ctx, state, container, to, lookup) {
58022
+ const motion = container.scroll;
58023
+ const content = contentOf(container, lookup);
58024
+ if (motion === void 0 || !motion.placed || content === void 0) return false;
58025
+ motion.offset = within(Number.isNaN(to) ? motion.offset : to, motion.max);
58026
+ motion.atProp = false;
58027
+ stop(motion);
58028
+ show(ctx, state, container, content, motion);
58029
+ ctx.deps.time.wake();
58030
+ return true;
56338
58031
  }
56339
58032
  //#endregion
56340
58033
  //#region src/plugins/ui/layout/solve.ts
@@ -56549,7 +58242,9 @@ function textSourceOf(ctx) {
56549
58242
  };
56550
58243
  }
56551
58244
  /**
56552
- * Writes the style of an element onto its node and keeps its measure function in step.
58245
+ * Writes the style of an element onto its node and keeps its measure function in step. An `x`
58246
+ * scroll lays its content out the way a row does: along `x`, as wide as it is, as tall as the
58247
+ * scroll.
56553
58248
  *
56554
58249
  * @param state - The layout state.
56555
58250
  * @param source - What a text element is measured through.
@@ -56558,7 +58253,8 @@ function textSourceOf(ctx) {
56558
58253
  function writeStyle(state, source, element) {
56559
58254
  const node = state.byEntity.get(element.entity);
56560
58255
  if (node === void 0 || state.yoga === void 0) return;
56561
- applyStyleToNode(state.yoga, node, element.style, element.type, element.parentType);
58256
+ const tag = element.scroll?.axis === "x" ? "row" : element.type;
58257
+ applyStyleToNode(state.yoga, node, element.style, tag, element.parentType);
56562
58258
  installMeasure(state, node, element, source);
56563
58259
  }
56564
58260
  /**
@@ -56621,8 +58317,10 @@ function createLayoutApi(ctx) {
56621
58317
  play(ctx, element, hook);
56622
58318
  },
56623
58319
  settled: (element) => canDespawn(element),
56624
- scroll: (containers, lookup) => stepScroll(ctx, state, containers, lookup),
56625
- clampScroll: (container, contentHeight, lookup) => clampScroll(ctx, container, contentHeight, lookup),
58320
+ scroll: (containers, lookup, time) => stepScroll(ctx, state, containers, lookup, time),
58321
+ settleScroll: (containers, lookup, time) => settleScroll(ctx, state, containers, lookup, time),
58322
+ clampScroll: (container, contentHeight, lookup) => clampScroll(ctx, state, container, contentHeight, lookup),
58323
+ scrollTo: (container, to, lookup) => scrollTo(ctx, state, container, to, lookup),
56626
58324
  windowOf,
56627
58325
  popupHandler: (jsx) => createPopupHandler(ctx, state, jsx),
56628
58326
  guideHandler: () => createGuideHandler(ctx),
@@ -57198,12 +58896,12 @@ function openRegistrations(ctx, jsx, layout) {
57198
58896
  name: "ui.reconcile",
57199
58897
  phase: "layout",
57200
58898
  query: [],
57201
- run: jsx.reconcile
58899
+ run: (_rows, frame) => jsx.reconcile(frame.time)
57202
58900
  })), ecs.system(system({
57203
58901
  name: "ui.solve",
57204
58902
  phase: "layout",
57205
58903
  query: [],
57206
- run: jsx.solve
58904
+ run: (_rows, frame) => jsx.solve(frame.time)
57207
58905
  })), ecs.onAdded(Tree, (entity) => {
57208
58906
  const place = projection.keyOf(entity);
57209
58907
  jsx.mountRoot(entity, place?.projection ?? "ui", ecs.get(entity, Layer)?.name ?? "ui");
@@ -57250,6 +58948,7 @@ function stopUi(state) {
57250
58948
  state.layout.nodes = 0;
57251
58949
  state.layout.yoga = void 0;
57252
58950
  state.layout.scrolling = void 0;
58951
+ state.layout.scrollSamples = 0;
57253
58952
  state.layout.iconLine = void 0;
57254
58953
  state.jsx.components.clear();
57255
58954
  state.jsx.roots.clear();
@@ -57540,7 +59239,7 @@ function createModules(ctx) {
57540
59239
  };
57541
59240
  }
57542
59241
  /**
57543
- * Creates the ui API: the four members a game calls, flat.
59242
+ * Creates the ui API: the five members a game calls, flat.
57544
59243
  *
57545
59244
  * @param ctx - Kernel context of the ui plugin.
57546
59245
  * @returns The plugin API.
@@ -57551,7 +59250,8 @@ function createUiApi(ctx) {
57551
59250
  tree: jsx.tree,
57552
59251
  find: jsx.find,
57553
59252
  lint: jsx.lint,
57554
- fill: jsx.fill
59253
+ fill: jsx.fill,
59254
+ scroll: jsx.scroll
57555
59255
  };
57556
59256
  }
57557
59257
  //#endregion
@@ -57653,8 +59353,8 @@ function createJsxState() {
57653
59353
  //#endregion
57654
59354
  //#region src/plugins/ui/layout/state.ts
57655
59355
  /**
57656
- * Creates an empty map. Its own non-exported function, because lint rule L5 refuses a collection
57657
- * built inside an exported declaration.
59356
+ * Creates an empty map. Its own non-exported function, because lint rule L5 refuses a
59357
+ * collection built inside an exported declaration.
57658
59358
  *
57659
59359
  * @returns A map with nothing in it.
57660
59360
  */
@@ -57662,13 +59362,39 @@ function emptyMap() {
57662
59362
  return /* @__PURE__ */ new Map();
57663
59363
  }
57664
59364
  /**
59365
+ * Creates the ring of pointer samples: a position and a time for each of `SAMPLES`. Its own
59366
+ * non-exported function, for the same rule.
59367
+ *
59368
+ * @returns The ring, zeroed.
59369
+ */
59370
+ function sampleRing() {
59371
+ return /* @__PURE__ */ new Float64Array(10);
59372
+ }
59373
+ /**
59374
+ * Creates the patches the scroll step writes with, every field 0 or false.
59375
+ *
59376
+ * @returns The patches.
59377
+ */
59378
+ function scrollPatches() {
59379
+ return {
59380
+ scroll: {
59381
+ offset: 0,
59382
+ min: 0
59383
+ },
59384
+ state: {
59385
+ offset: 0,
59386
+ max: 0,
59387
+ moving: false
59388
+ },
59389
+ scrollable: { moving: false },
59390
+ x: { x: 0 },
59391
+ y: { y: 0 }
59392
+ };
59393
+ }
59394
+ /**
57665
59395
  * Creates the layout branch of the ui state. Everything starts empty: Yoga arrives in `onStart`.
57666
59396
  *
57667
59397
  * @returns The module state.
57668
- * @example
57669
- * ```ts
57670
- * createLayoutState().nodes; // 0
57671
- * ```
57672
59398
  */
57673
59399
  function createLayoutState() {
57674
59400
  return {
@@ -57678,10 +59404,12 @@ function createLayoutState() {
57678
59404
  measured: 0,
57679
59405
  solves: 0,
57680
59406
  scrolling: void 0,
57681
- scrollStart: {
57682
- pointerY: 0,
57683
- offset: 0
57684
- },
59407
+ scrollClock: 0,
59408
+ scrollRing: sampleRing(),
59409
+ scrollHead: 0,
59410
+ scrollSamples: 0,
59411
+ scrollMoved: 0,
59412
+ scrollPatches: scrollPatches(),
57685
59413
  iconLine: void 0,
57686
59414
  order: 0,
57687
59415
  cleanups: []
@@ -57743,9 +59471,10 @@ function createUiState(_ctx) {
57743
59471
  };
57744
59472
  }
57745
59473
  /**
57746
- * Ui plugin: `app.ui.tree()`, `app.ui.find(...)`, `app.ui.lint()`, `app.ui.fill(...)`, the
57747
- * text field `<input local>`, the components `Box`, `LocalWrite` and
57748
- * `DebugTrigger`, and the helpers `defineComponent`, `defineStyle`, `defineTokens` and `popup`.
59474
+ * Ui plugin: `app.ui.tree()`, `app.ui.find(...)`, `app.ui.lint()`, `app.ui.fill(...)`,
59475
+ * `app.ui.scroll(...)`, the text field `<input local>`, the components `Box`, `LocalWrite`,
59476
+ * `DebugTrigger` and `ScrollState`, and the helpers `defineComponent`, `defineStyle`,
59477
+ * `defineTokens` and `popup`.
57749
59478
  *
57750
59479
  * @example
57751
59480
  * ```ts
@@ -59151,6 +60880,7 @@ function cubicBezier(x1, y1, x2, y2) {
59151
60880
  * | renderer | preference | "webgpu", WebGL is Pixi's fallback |
59152
60881
  * | renderer | aspect | { min: 4/3, max: 21/9 }; shallow merge: a game that sets `aspect` gives both fields |
59153
60882
  * | renderer | poolLimit | 256 |
60883
+ * | renderer | adaptiveScale | { floor: 0.75, devices: "phone" }, the render scale of the adaptive frame rate on a GPU-bound phone; false keeps the resolution; shallow merge: a game that sets it gives both fields |
59154
60884
  * | renderer | unsupportedMessage | "This device cannot run the game." |
59155
60885
  * | renderer | loadPixi | () => import("pixi.js") |
59156
60886
  * | renderer | debug | { nineSlice: false, verifyFrames: false, kept: true, overdraw: false }; shallow merge: a game that sets `debug` replaces the whole object, and a switch it leaves out is off, except `kept`, which stays on unless set to false; `verifyFrames` and `overdraw` work in dev and debug builds only |
@@ -59315,4 +61045,4 @@ const screen = [
59315
61045
  uiPlugin
59316
61046
  ];
59317
61047
  //#endregion
59318
- export { mark as $, PROBLEM_RULES as $t, types_exports$11 as A, Alpha as At, Box as B, scenesPlugin as Bt, types_exports$4 as C, Traceable as Ct, types_exports$8 as D, messageDuration as Dt, types_exports$7 as E, messageArgument as Et, types_exports$16 as F, Noise as Ft, defineStyle as G, Frames as Gt, LocalWrite as H, animPlugin as Ht, types_exports$17 as I, Outline as It, HAPTIC_KINDS as J, frameAt as Jt, textPlugin as K, rendererPlugin as Kt, uiPlugin as L, defineFilter as Lt, types_exports$13 as M, ColorMatrix as Mt, types_exports$14 as N, Displacement as Nt, types_exports$9 as O, effectsPlugin as Ot, types_exports$15 as P, Glow as Pt, haptic as Q, PERF_LOG_VERSION as Qt, resolve as R, debugPlugin as Rt, types_exports$3 as S, Touchable as St, types_exports$6 as T, i18nPlugin as Tt, popup as U, AnimPlayer as Ut, DebugTrigger as V, assetsPlugin as Vt, defineTokens as W, Animation as Wt, external as X, PERF_CAUSES as Xt, defineAnimation as Y, MONITOR_SECTIONS as Yt, frames as Z, PERF_GAP_BUCKETS as Zt, defineBundles as _, PointerOver as _t, cubicBezier as a, exit as an, spawn as at, types_exports$1 as b, Swipeable as bt, hint as c, type as cn, tween as ct, defineScene as d, lifecyclePlugin as dn, inputPlugin as dt, SHADER_WHATS as en, parallel as et, tr as f, timePlugin as fn, Draggable as ft, music as g, Pointer as gt, defineEmitter as h, Hovered as ht, screen as i, refuseStart as in, sfx as it, types_exports$12 as j, Blur as jt, types_exports$10 as k, Emitter as kt, schedule as l, modelPlugin as ln, use as lt, flattenFeatures as m, Held as mt, createPlugin as n, worldPlugin as nn, sequence as nt, defineEffect as o, slot as on, spawned as ot, defineFeature as p, clockPlugin as pn, DropTarget as pt, platformPlugin as q, PASS_KINDS as qt, defineGame as r, flowPlugin as rn, set as rt, guide as s, to as sn, stagger as st, createApp as t, DEMAND_REASONS as tn, play$1 as tt, projection as u, SaveUnreadableError as un, wait as ut, load as v, Pressable as vt, types_exports$5 as w, Traced as wt, types_exports$2 as x, Tappable as xt, types_exports as y, Pressed as yt, defineMotion as z, audioPlugin as zt };
61048
+ export { haptic as $, PERF_LOG_VERSION as $t, types_exports$11 as A, Emitter as At, Box as B, audioPlugin as Bt, types_exports$4 as C, Touchable as Ct, types_exports$8 as D, messageArgument as Dt, types_exports$7 as E, i18nPlugin as Et, types_exports$16 as F, Glow as Ft, defineTokens as G, Animation as Gt, LocalWrite as H, assetsPlugin as Ht, types_exports$17 as I, Noise as It, platformPlugin as J, PASS_KINDS as Jt, defineStyle as K, Frames as Kt, uiPlugin as L, Outline as Lt, types_exports$13 as M, Blur as Mt, types_exports$14 as N, ColorMatrix as Nt, types_exports$9 as O, messageDuration as Ot, types_exports$15 as P, Displacement as Pt, frames as Q, PERF_GAP_BUCKETS as Qt, resolve as R, defineFilter as Rt, types_exports$3 as S, Tappable as St, types_exports$6 as T, Traced as Tt, ScrollState as U, animPlugin as Ut, DebugTrigger as V, scenesPlugin as Vt, popup as W, AnimPlayer as Wt, defineAnimation as X, MONITOR_SECTIONS as Xt, HAPTIC_KINDS as Y, frameAt as Yt, external as Z, PERF_CAUSES as Zt, defineBundles as _, Pointer as _t, cubicBezier as a, refuseStart as an, sfx as at, types_exports$1 as b, Pressed as bt, hint as c, to as cn, stagger as ct, defineScene as d, SaveUnreadableError as dn, wait as dt, PROBLEM_RULES as en, mark as et, tr as f, lifecyclePlugin as fn, inputPlugin as ft, music as g, Hovered as gt, defineEmitter as h, Held as ht, screen as i, flowPlugin as in, set as it, types_exports$12 as j, Alpha as jt, types_exports$10 as k, effectsPlugin as kt, schedule as l, type as ln, tween as lt, flattenFeatures as m, clockPlugin as mn, DropTarget as mt, createPlugin as n, DEMAND_REASONS as nn, play$1 as nt, defineEffect as o, exit as on, spawn as ot, defineFeature as p, timePlugin as pn, Draggable as pt, textPlugin as q, rendererPlugin as qt, defineGame as r, worldPlugin as rn, sequence as rt, guide as s, slot as sn, spawned as st, createApp as t, SHADER_WHATS as tn, parallel as tt, projection as u, modelPlugin as un, use as ut, load as v, PointerOver as vt, types_exports$5 as w, Traceable as wt, types_exports$2 as x, Swipeable as xt, types_exports as y, Pressable as yt, defineMotion as z, debugPlugin as zt };