Commit 758057fc by PLN (Algolia)

impression: sixteen canvases, and they breathe

parent bb4d0806
......@@ -4,6 +4,7 @@
import scenes from './scenes/manifest.js';
import { mulberry32 } from './scene-api.js';
import { dab, brokenColor } from './brushes.js';
const params = new URLSearchParams(location.search);
const HOLD_MS = 7000;
......@@ -14,6 +15,8 @@ const REPLAY_STEPS = 480;
const baseSeed = params.has('seed') ? (parseInt(params.get('seed'), 10) || 0) : 0;
const freeze = params.has('freeze') ? Math.min(1, Math.max(0, parseFloat(params.get('freeze')) || 0)) : null;
const sceneParam = params.get('scene');
// ?still=1 — verification escape hatch: the hold stays a dead stop, no mutation
const still = params.has('still');
// --- the director's chair: conditions, speed, auto-advance -------------------
const LS_KEY = 'atelier-constraints';
......@@ -113,6 +116,7 @@ function startScene(i, replayTo = 0) {
offA = o.c; octx = o.ctx; offB = null;
callInit(scene, octx, rect.w, rect.h);
if (replayTo > 0) replay(0, replayTo); else lastTo = 0;
mutation = null; // a fresh canvas gets no afterlife from the previous show
phase = 'painting';
t0 = performance.now();
pausedAt = null;
......@@ -177,6 +181,136 @@ function archivePainting() {
.catch(() => {});
}
// --- PERPETUAL MUTATION -------------------------------------------------------
// After Eno's 77 Million Paintings: the finished canvas keeps living. Every
// ~400-700ms of the hold, a small "breath" of 2-6 soft, low-alpha dabs lands
// near busy regions of the painting, in the painting's own colours, drifting a
// dab at a time. Subtle by contract: alpha <= 0.15, worst-case touched area
// ~0.2% of the canvas per breath (6 dabs x radius ~rect.w*0.009 on a 4:3
// frame), a few pixels of total drift over the whole hold. The painting is
// never done, only evolving slower.
//
// ORDERING GUARANTEE: archivePainting() snapshots offA at the final stroke
// (painting phase), BEFORE the hold begins and before the first breath — the
// artwork of record is archived pristine; the mutation is its afterlife.
//
// Anchor honesty: scenes keep their stroke lists inside their own draw()
// closures, so the frame cannot read the scene's stroke data. The fallback
// heuristic works on the RENDERED pixels: the finished canvas is downsampled
// once at completion, per-cell luminance contrast is measured, and a jittered
// 4x3 anchor grid picks the busiest cell in each bucket, weighted toward the
// upper half (skies breathe first) — water bands get their share through the
// buckets that fall below the horizon. It is a heuristic over pixels, not
// scene knowledge; it errs toward texture, which is what drift reads best on.
//
// Determinism: each breath rolls its own dedicated mulberry32 stream seeded
// from the current seed + breath index, so breath #k is byte-identical for a
// given show; which breaths fire depends on hold timing, which is fine — the
// mutation is live performance, and freeze/?still=1 (and never-freeze mode)
// never run it at all.
let mutation = null; // per-show state, rebuilt at completion, cleared on any restart
function buildMutation() {
mutation = null;
if (still || freeze != null) return;
try {
// downsample the finished painting once; all further maths reads this grid
const SW = 96, SH = 72;
const small = document.createElement('canvas');
small.width = SW;
small.height = SH;
const sctx = small.getContext('2d');
sctx.drawImage(offA, 0, 0, SW, SH);
const px = sctx.getImageData(0, 0, SW, SH).data;
const lum = new Float32Array(SW * SH);
for (let i = 0; i < SW * SH; i++) {
lum[i] = 0.299 * px[i * 4] + 0.587 * px[i * 4 + 1] + 0.114 * px[i * 4 + 2];
}
const contrast = new Float32Array(SW * SH);
for (let y = 0; y < SH; y++) {
for (let x = 0; x < SW; x++) {
const i = y * SW + x;
contrast[i] = (x + 1 < SW ? Math.abs(lum[i] - lum[i + 1]) : 0)
+ (y + 1 < SH ? Math.abs(lum[i] - lum[i + SW]) : 0);
}
}
// dedicated anchor stream, distinct from init/stroke/conditions streams
const rng = mulberry32(((seedFor() * 31) ^ 0x6D2B79F5) >>> 0);
const anchors = [];
const GX = 4, GY = 3;
for (let gy = 0; gy < GY; gy++) {
for (let gx = 0; gx < GX; gx++) {
const x0 = Math.floor((gx * SW) / GX), x1 = Math.floor(((gx + 1) * SW) / GX);
const y0 = Math.floor((gy * SH) / GY), y1 = Math.floor(((gy + 1) * SH) / GY);
let best = y0 * SW + x0, bestC = -1;
for (let y = y0; y < y1; y++) {
for (let x = x0; x < x1; x++) {
const i = y * SW + x;
// upper-half bias (skies and light drift first), rng-jittered so
// the pick isn't a deterministic argmax of the same cell forever
const c = contrast[i] * (1 + 0.6 * (1 - y / SH)) * (0.5 + rng());
if (c > bestC) { bestC = c; best = i; }
}
}
const bx = best % SW, by = (best / SW) | 0;
anchors.push({
x: ((bx + 0.5) / SW) * rect.w + (rng() - 0.5) * rect.w * 0.06,
y: ((by + 0.5) / SH) * rect.h + (rng() - 0.5) * rect.h * 0.06,
r: px[best * 4], g: px[best * 4 + 1], b: px[best * 4 + 2],
dir: rng() * Math.PI * 2, // slow personal drift heading, dabs follow it
speed: rect.w * (0.0006 + rng() * 0.0012), // px/s: ~5-15px over a hold
});
}
}
mutation = { anchors, tick: 0, nextAt: performance.now() + 700 };
} catch {
mutation = null; // a tainted or vanished canvas must never break the hold
}
}
// one breath: advance the anchors along their drift, lay 2-6 soft dabs in the
// painting's own colours near them, schedule the next breath (longer as the
// crossfade approaches — the painting settles before it dissolves)
function breathe(now, gate) {
const rng = mulberry32(((seedFor() * 69621) ^ Math.imul(mutation.tick + 1, 0x9E3779B1)) >>> 0);
mutation.tick++;
const dt = Math.min(1, (now - mutation.lastAt) / 1000); // a long pause must not lurch the drift
mutation.lastAt = now;
const n = 2 + Math.floor(rng() * 5); // 2-6 dabs
for (let k = 0; k < n; k++) {
const a = mutation.anchors[Math.floor(rng() * mutation.anchors.length)];
const x = Math.min(rect.w * 0.97, Math.max(rect.w * 0.03, a.x + (rng() - 0.5) * rect.w * 0.05));
const y = Math.min(rect.h * 0.97, Math.max(rect.h * 0.03, a.y + (rng() - 0.5) * rect.h * 0.05));
// broken color around the pixel the painting itself holds there
const [c1, c2] = brokenColor(octx, rng, [a.r, a.g, a.b], 0.35);
const size = Math.max(1.5, rect.w * (0.003 + rng() * 0.006));
const ang = a.dir + (rng() - 0.5) * 0.6;
dab(octx, x, y, size, rng() < 0.5 ? c1 : c2, 0.03 + rng() * 0.08, ang, 0.55);
}
for (const a of mutation.anchors) {
a.x += Math.cos(a.dir) * a.speed * dt;
a.y += Math.sin(a.dir) * a.speed * dt * 0.5; // drift prefers horizontal: clouds, water
}
mutation.nextAt = now + (420 + rng() * 260) / Math.max(0.35, gate);
}
function stepMutation(now) {
if (!mutation) return;
if (mutation.lastAt == null) mutation.lastAt = now;
// Feather the end: in the last ~30% of the hold before an auto-crossfade the
// breathing slows and stops. An indefinite hold (?autoplay=0) never feathers
// — there is no crossfade to settle for, the painting just keeps living.
let gate = 1;
if (autoplay) {
const p = (now - t0) / HOLD_MS;
if (p >= 0.92) return;
if (p > 0.7) gate = 1 - (p - 0.7) / 0.22;
}
if (now < mutation.nextAt) return;
breathe(now, gate);
}
// --- the menu overlay: segmented conditions, speed, auto-advance, download ---
// Built entirely here (DOM + style) so the frame owns its own furniture; the
// look follows the house: serif small-caps, warm charcoal, hairline rules.
......@@ -452,17 +586,19 @@ function tick(now) {
}
blit(offA);
fillEl.style.width = (lastTo * 100).toFixed(2) + '%';
if (lastTo >= 1 && pausedAt == null) { phase = 'hold'; t0 = now; }
if (lastTo >= 1 && pausedAt == null) { phase = 'hold'; t0 = now; buildMutation(); }
} else if (phase === 'hold') {
if (pausedAt == null) stepMutation(now); // the painting keeps living while unpaused
blit(offA);
fillEl.style.width = '100%';
if (now - t0 >= HOLD_MS) {
if (pausedAt == null && now - t0 >= HOLD_MS) {
if (autoplay) {
pendingIdx = (idx + 1) % scenes.length;
if (pendingIdx === 0) showNumber++; // each replay of the show gets a fresh seed
const o = makeOffscreen();
offB = o.c;
callInit(scenes[pendingIdx], o.ctx, rect.w, rect.h);
mutation = null; // the afterlife ends with the crossfade
phase = 'fade';
t0 = now;
} else {
......@@ -513,7 +649,9 @@ addEventListener('keydown', (e) => {
else if (e.key === 'ArrowLeft') jump(idx - 1);
else if (e.key === ' ') {
e.preventDefault();
if (freeze == null && phase === 'painting') {
if (freeze == null && (phase === 'painting' || phase === 'hold')) {
// pausing the hold also stills the mutation: the breath is part of the
// living performance, and a held breath is no breath at all
if (pausedAt == null) pausedAt = performance.now();
else { t0 += performance.now() - pausedAt; pausedAt = null; }
}
......
......@@ -12,5 +12,8 @@ import bled from './bled.js';
import nympheas from './nympheas.js';
import neige from './neige.js';
import tournesols from './tournesols.js';
import verger from './verger.js';
import port from './port.js';
import moulin from './moulin.js';
export default [soleil, coquelicots, nuit, vallee, skyline, cafe, jardin, pluie, mer, bled, nympheas, neige, tournesols];
export default [soleil, coquelicots, nuit, vallee, skyline, cafe, jardin, pluie, mer, bled, nympheas, neige, tournesols, verger, port, moulin];
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment