clone

an offline sliding-block puzzle game in gleam and lustre, with an endless ladder of puzzles ranked by search

Draw when the picture changes, not when the clock does

Dami committed e703310
The board falling into place composed the whole screen thirty-six times
to show seven pictures. Twenty-nine of those frames were identical to
the one before them, pixel for pixel, and each cost a full pass: the
scene drawn, read back, error-diffused, tinted and written out, every
pixel of it, because diffusion depends on the whole image and always
will. On a laptop that is three milliseconds and invisible. On a phone
it is several times that, the work stops fitting in a frame, the frames
queue, and a 300ms animation crawls along behind its own clock -- which
is exactly what it looked like.

Nothing here moves smoothly, and that is the way out. A slide is
quantised to the dither period so the pattern never lands out of phase.
The board falls in even units. The card's title arrives in five discrete
strengths. The jolt of a refused move is a whole number of units. Every
one of those was already stepped -- but stepped inside the painter, so
the loop could not know that a tick had produced the same picture again
and marked the frame dirty on the mere fact that a clock was running.

So the stepping moves up into the loop, beside the clocks it belongs to,
and the loop compares the stepped values rather than the continuous
ones. Same rounding, same one place doing it, and the painters read the
result instead of each rounding a clock their own way.

Measured, on the play screen: the rise went from 36 composites to 7, an
animated slide from about the same to 6, and an idle screen from 0 to 0.
Under a 6x CPU throttle the rise used to stretch past its own 300ms and
now finishes in it. Input paths are untouched -- they mark the frame
dirty themselves, as they always did; this governs only what a running
clock is entitled to ask for.
commit
e7033102a10a04bccffb8b2eb38d1a2401d9c4fe
parent
d4b2941f042440f011e7da02cf109d47b38c4f68
author
Dami <dami.etoile@gmail.com>
5 files changed, +88 -18
MREADME.md20+++++++++-
Mmockup/src/game.js61+++++++++++++++++++++++++-----
Mmockup/src/render.js11+++--
Mmockup/src/state.js12+++++-
Mmockup/sw.js2+-
M README.md +18 -2
@@ -130,8 +130,24 @@ or two arbitrary bands of one; at density 1 a playing board could not carry more
130130 POSTER and BILLBOARD were always quietly stepped back down. Three times the buffer pixels is three131131 times the room, so the whole ladder is reachable for the first time.132132 133-It costs about 3ms a frame on a nine-times buffer, because the scene outside the block sprites is134-composed entirely at 0 and 255 and so gives error diffusion nothing to propagate.133+It costs about 3ms a frame on a nine-times buffer on a laptop, because the scene outside the block134+sprites is composed entirely at 0 and 255 and so gives error diffusion nothing to propagate. On a135+phone it is several times that, which is what makes the next paragraph matter.136+137+**The loop draws when the picture changes, not when time passes.** Composing a frame is not cheap138+and cannot be — the scene is drawn, read back, diffused, tinted and written out, every pixel of it,139+because diffusion depends on the whole image. But nothing in this app moves smoothly: a slide is140+quantised to the dither period so the pattern never lands out of phase, the board falls into place141+in even units, the card's title arrives in five steps. A 300ms rise is *seven pictures*, however142+many times a second the display asks for one.143+144+It used to ask for all of them: any running clock marked the frame dirty, so the rise composed the145+screen 36 times to show seven things, and the other 29 were pixel-identical to the frame before.146+That is what a phone feels as a stutter — the work does not fit in a frame, so frames queue and the147+animation crawls behind its own clock. So the clocks are stepped in the loop rather than in the148+painters (`frameKey` in `game.js`), and a tick that lands on the same values as the last one draws149+nothing. 36 composites became 7, a slide's became 6, and an idle screen's became 0. Input paths mark150+the frame dirty themselves, as they always did; this governs only what the clocks may ask for.135151 136152 **The column is not the screen.** A phone is no longer one shape: 22:9 and 4:3, folding open into a137153 square, turned on its side, and a keypad phone is a 4:3 screen the size of a card. None of that
M mockup/src/game.js +51 -10
@@ -46,10 +46,41 @@ function loop(now) {
4646 if (consumeDirty()) renderFrame();4747 }4848 49+/* Everything the next picture will be made of, as one value to compare against50+ * the last one.51+ *52+ * The clocks advance every frame; the picture does not. Nothing in this app53+ * moves smoothly -- a slide is quantised to the dither period so the pattern54+ * never lands out of phase, the board falls into place in even units, the55+ * card's title arrives in five steps, the jolt is a whole number of units --56+ * so a 300ms rise is seven pictures and a slide is a handful, however many57+ * times a second the display asks for one.58+ *59+ * Composing the screen is not cheap and cannot be: the scene is drawn, read60+ * back, error-diffused, tinted and written out, every pixel of it, because61+ * diffusion depends on the whole image. Doing that five times for the same62+ * picture is what a phone feels as a stutter -- the work does not fit in a63+ * frame, so the frames queue and the animation crawls behind its own clock.64+ *65+ * So the loop renders when the picture changes rather than when time passes.66+ * Input paths still mark the frame dirty themselves, as they always did; this67+ * governs only what the clocks are entitled to ask for. */68+let lastKey = '';69+function frameKey() {70+ const a = App.anim, sv = App.pending;71+ return [72+ App.screen, App.rise, App.shakeOff,73+ a ? `${a.axis}${a.cur}` : '',74+ App.cardStep, App.cardT > 240 ? 1 : 0,75+ fx.flash > 0 ? 1 : 0,76+ fx.wipe >= 0 ? Math.round(fx.wipe) : -1,77+ sv ? `${sv.phase}:${sv.count}` : '',78+ ].join('|');79+}80+4981 function tick(now) {5082 const dt = Math.min(48, now - lastT || 16);5183 lastT = now;52- let live = false;5384 5485 if (App.anim) {5586 const a = App.anim;
@@ -58,23 +89,33 @@ function tick(now) {
5889 const e = 1 - Math.pow(1 - k, 3);5990 // Quantised to the dither period, so the pattern never lands out of phase.6091 a.cur = Math.round((a.from + (a.to - a.from) * e) / PERIOD) * PERIOD;61- live = true;6292 if (k >= 1) { const done = a.done; App.anim = null; if (done) done(); }6393 }64- if (App.shake > 0) { App.shake = Math.max(0, App.shake - dt * 0.05); live = true; }65- if (App.riseT < 1) { App.riseT = Math.min(1, App.riseT + dt / 300); live = true; }66- if (fx.flash > 0) { fx.flash -= dt; live = true; }94+ if (App.shake > 0) App.shake = Math.max(0, App.shake - dt * 0.05);95+ if (App.riseT < 1) App.riseT = Math.min(1, App.riseT + dt / 300);96+ if (fx.flash > 0) fx.flash -= dt;6797 if (fx.wipe >= 0) {68- fx.wipe += dt * 0.9; live = true;98+ fx.wipe += dt * 0.9;6999 if (fx.wipe > Math.max(view.VW, view.VH)) fx.wipe = -1;70100 }71101 if (App.screen === 'card') {72- App.cardT += dt; live = true;102+ App.cardT += dt;73103 if (App.cardT > 1300) beginPlay();74104 }75- if (App.screen === 'survey') { runSurvey(); live = true; }76-77- if (live) requestRender();105+ if (App.screen === 'survey') runSurvey();106+107+ /* The stepped form of each clock, worked out here rather than in the painter108+ * that used to do it: the loop has to know what the picture will be before it109+ * decides whether to draw one, and two places rounding the same clock their110+ * own way is how they drift apart. Last, so that anything above -- beginPlay111+ * off the card timer, an animation's done callback -- is already accounted112+ * for in the frame it happened in. */113+ App.rise = App.riseT < 1 ? Math.round((1 - App.riseT) * 12 / 2) * 2 : 0;114+ App.shakeOff = App.shake > 0 ? Math.round(Math.sin(App.shake * 0.9) * 2) : 0;115+ App.cardStep = Math.min(4, Math.floor(Math.min(1, App.cardT / 280) * 5));116+117+ const key = frameKey();118+ if (key !== lastKey) { lastKey = key; requestRender(); }78119 }79120 80121 /* --------------------------------------------------------------- the sweep */
M mockup/src/render.js +7 -4
@@ -1350,8 +1350,9 @@ function drawCard(c) {
13501350 drawPanel(c, win);13511351 13521352 // Arrives in discrete steps, never a smooth fade: a continuous ramp would1353- // put in-between frames in the middle of a one-bit game.1354- const step = Math.min(4, Math.floor(Math.min(1, App.cardT / 280) * 5));1353+ // put in-between frames in the middle of a one-bit game. Stepped by the loop,1354+ // for the reason the rise is.1355+ const step = App.cardStep;13551356 label(App.fam.label, cx, win.y + Math.round(win.h * 0.42), {13561357 size: fitSize(App.fam.label, U * 0.11, 'Silk', U * 0.016, win.w - 18),13571358 track: U * 0.016, align: 'center', alpha: step / 4,
@@ -1375,7 +1376,9 @@ function pieceOffset(p) {
13751376 }13761377 function drawPlay(c) {13771378 const cell = LY.cell, B = LY.board, P = LY.plate;1378- const rise = App.riseT < 1 ? Math.round((1 - App.riseT) * 12 / 2) * 2 : 0;1379+ // Stepped by the loop, not here: it has to know what this picture will be1380+ // before it decides to ask for it. See frameKey in game.js.1381+ const rise = App.rise;13791382 const plate = { x: P.x, y: P.y - rise, w: P.w, h: P.h };13801383 13811384 drawMenuBar(c);
@@ -1400,7 +1403,7 @@ function drawPlay(c) {
14001403 c.fillRect(Math.round(bx + (gx + i) * cell), tray.y + tray.h + 2, 1, 3);14011404 }14021405 1403- const shake = App.shake ? Math.round(Math.sin(App.shake * 0.9) * 2) : 0;1406+ const shake = App.shakeOff;14041407 const blockOpt = p => ({14051408 gap: 2,14061409 thick: !!(App.drag && App.drag.piece === p),
M mockup/src/state.js +11 -1
M mockup/sw.js +1 -1