WORK 106
まどごし
A room that never moves. One window on the wall, and a floor below it. Outside, two balloons drift slowly upward, and they can be seen only while they cross the window. A day goes round. The sun comes in through the same window, but from another angle, so the patch of light on the floor does not hold what the eye is holding. Most often the shadow on the floor belongs to a balloon that has just risen out of the window: the window has already let it go, and the floor still has it. Sometimes it belongs to one that never passed the window at all. Wherever the light has been, the floor fades a little, day after day. The shadows are too small and too quick to fade into it. After a poem by Nakahara Chuya, "Autumn Sunday", whose speaker never leaves the room.
動かない部屋。壁に窓がひとつ、その下に床。窓の外を、ふたつの気球がゆっくり昇りながら漂っている。見えるのは、窓の四角を通るあいだだけ。一日がめぐる。太陽も同じ窓から入ってくるけれど、角度がちがう。だから床の光だまりに落ちるものは、目に見えているものと同じじゃない。床の影はたいてい、たったいま窓の上へ抜けていった気球のもの。窓はもう手放していて、床はまだ持っている。ときどき、一度も窓を通らなかった気球の影も来る。光の当たったところだけ、床は毎日すこしずつ褪せていく。影は小さくて速いので、そこには残らない。中原中也「秋の日曜」から。最後まで一度も部屋から出ない詩。
「私の部屋の、窓越しに/みえるのは、エヤ・サイン/軽くあがつた 二つの気球」(中原中也「秋の日曜」)
// #106 まどごし / Through the Window
// A room that never moves. One window on the wall, a floor below.
// Outside, two balloons drift slowly upward; they are seen only while they cross the window.
// A day goes round. The sun comes through the same window from another angle,
// so the light on the floor carries the shadows of balloons the eye may not be seeing.
// The floor fades a little wherever the light has been.
export default function sketch(p) {
const W = 720;
const H = 720;
const FLOOR_Y = 440;
const WIN = { x: 92, y: 84, w: 196, h: 222 };
const DAY = 5400; // frames per day
// sky plane: same coordinates as the window on the screen, but extends far beyond it
const SKY = { u0: -260, u1: 640, v0: -300, v1: 380 };
let balloons = [];
let winG;
// floor exposure (sun-fade), coarse grid
const CELL = 4;
const GW = W / CELL;
const GH = Math.ceil((H - FLOOR_Y) / CELL);
let expo;
let fadeG;
// counters for the prediction
let shadowFrames = 0;
let shadowUnseenFrames = 0;
let seenFrames = 0;
let dayFrames = 0;
p.setup = () => {
p.createCanvas(W, H);
p.pixelDensity(1);
p.randomSeed(106);
p.noiseSeed(106);
winG = p.createGraphics(WIN.w, WIN.h);
winG.pixelDensity(1);
fadeG = p.createGraphics(GW, GH);
fadeG.pixelDensity(1);
expo = new Float32Array(GW * GH);
balloons = [newBalloon(0), newBalloon(1)];
balloons[0].v = WIN.y + 120;
balloons[0].u = WIN.x + 60;
balloons[1].v = WIN.y + 330;
balloons[1].u = WIN.x + 260;
};
function newBalloon(i) {
return {
i,
u: p.random(SKY.u0 + 120, SKY.u1 - 120),
v: SKY.v1,
r: p.random(9, 13),
rise: p.random(0.045, 0.075),
seed: p.random(1000),
hue: i === 0 ? [214, 86, 70] : [236, 214, 196],
};
}
function dayPhase(f) {
return (f % DAY) / DAY;
}
// sun elevation 0..1 between phase 0.18 and 0.82, else 0
function sunUp(ph) {
const a = (ph - 0.18) / 0.64;
if (a <= 0 || a >= 1) return 0;
return Math.sin(Math.PI * a);
}
function sunAcross(ph) {
// -1 morning .. +1 evening
return p.constrain((ph - 0.5) / 0.32, -1, 1);
}
function skyColor(ph) {
const keys = [
[0.0, [16, 20, 38]],
[0.16, [22, 26, 48]],
[0.22, [196, 150, 140]],
[0.3, [150, 186, 214]],
[0.5, [136, 178, 222]],
[0.66, [176, 186, 196]],
[0.78, [222, 160, 104]],
[0.85, [70, 56, 84]],
[0.92, [18, 22, 42]],
[1.0, [16, 20, 38]],
];
for (let k = 0; k < keys.length - 1; k++) {
const [a, ca] = keys[k];
const [b, cb] = keys[k + 1];
if (ph >= a && ph <= b) {
const t = (ph - a) / (b - a);
return [0, 1, 2].map((j) => ca[j] + (cb[j] - ca[j]) * t);
}
}
return keys[0][1];
}
// where the sun looks through the window: the window rect shifted in the sky plane
function sunOffset(ph) {
const s = sunAcross(ph);
const h = sunUp(ph);
return { du: -s * 300, dv: -(1 - h) * 160 - 40 };
}
// light patch on the floor: maps window-local (a,b) in [0,1]^2 to a floor point
function patchPoint(a, b, ph) {
const s = sunAcross(ph);
const h = sunUp(ph);
const near = 18 + (1 - h) * 30; // distance from wall for the window's bottom edge
const len = 70 + (1 - h) * 170; // low sun -> long patch
const depth = near + (1 - b) * len; // top of window lands furthest in
const y = FLOOR_Y + depth;
const persp = 1 + 0.55 * (depth / (H - FLOOR_Y));
const cx = WIN.x + WIN.w / 2 + s * depth * 1.15;
const x = cx + (a - 0.5) * WIN.w * persp;
return [x, Math.min(y, H + 40)];
}
function stepBalloons() {
for (let k = 0; k < balloons.length; k++) {
const b = balloons[k];
const t = p.frameCount * 0.0016 + b.seed;
b.u += (p.noise(t) - 0.5) * 0.3;
b.v -= b.rise * (0.7 + p.noise(t + 50) * 0.6);
if (b.v < SKY.v0 || b.u < SKY.u0 || b.u > SKY.u1) {
balloons[k] = newBalloon(b.i);
}
}
}
function inWin(u, v) {
return u > WIN.x && u < WIN.x + WIN.w && v > WIN.y && v < WIN.y + WIN.h;
}
function drawBalloon(g, x, y, r, c, a) {
g.noStroke();
g.fill(c[0], c[1], c[2], a);
g.ellipse(x, y, r * 1.7, r * 2);
g.stroke(60, 50, 50, a * 0.6);
g.strokeWeight(0.7);
g.line(x, y + r, x, y + r * 2.1);
g.noStroke();
g.fill(90, 70, 60, a);
g.rect(x - 2, y + r * 2.1, 4, 3);
}
p.draw = () => {
const ph = dayPhase(p.frameCount);
const h = sunUp(ph);
const sky = skyColor(ph);
const amb = 0.28 + 0.72 * ((sky[0] + sky[1] + sky[2]) / (3 * 200));
stepBalloons();
// --- room
const wall = [168, 158, 146].map((c) => c * amb);
p.noStroke();
p.background(wall[0], wall[1], wall[2]);
// floor
const fl = [118, 92, 70].map((c) => c * amb);
p.fill(fl[0], fl[1], fl[2]);
p.rect(0, FLOOR_Y, W, H - FLOOR_Y);
// planks converging toward the wall
p.stroke(fl[0] * 0.78, fl[1] * 0.78, fl[2] * 0.78);
p.strokeWeight(1);
for (let k = -12; k <= 24; k++) {
const xb = W / 2 + (k - 6) * 62;
const xt = W / 2 + (k - 6) * 62 * 0.62;
p.line(xt, FLOOR_Y, xb, H);
}
p.stroke(wall[0] * 0.7, wall[1] * 0.7, wall[2] * 0.7);
p.line(0, FLOOR_Y, W, FLOOR_Y);
// --- light patch + shadows, and exposure
const o = sunOffset(ph);
const shadows = [];
for (const b of balloons) {
const su = b.u - o.du;
const sv = b.v - o.dv;
if (inWin(su, sv)) {
shadows.push({
a: (su - WIN.x) / WIN.w,
b: (sv - WIN.y) / WIN.h,
r: b.r,
seen: inWin(b.u, b.v),
});
}
}
if (h > 0.02) {
dayFrames++;
if (balloons.some((b) => inWin(b.u, b.v))) seenFrames++;
}
if (h > 0.02 && shadows.length > 0) {
shadowFrames++;
if (shadows.some((s) => !s.seen)) shadowUnseenFrames++;
}
// exposure accumulates (sun-fade)
if (h > 0.02) {
const q = [patchPoint(0, 0, ph), patchPoint(1, 0, ph), patchPoint(1, 1, ph), patchPoint(0, 1, ph)];
const ymin = Math.max(FLOOR_Y, Math.min(q[0][1], q[3][1]));
const ymax = Math.min(H, Math.max(q[0][1], q[3][1]));
for (let gy = 0; gy < GH; gy++) {
const y = FLOOR_Y + gy * CELL + CELL / 2;
if (y < ymin || y > ymax) continue;
// invert row: find b for this y, then x range
const near = 18 + (1 - h) * 30;
const len = 70 + (1 - h) * 170;
const bb = 1 - (y - FLOOR_Y - near) / len;
if (bb < 0 || bb > 1) continue;
const [xl] = patchPoint(0, bb, ph);
const [xr] = patchPoint(1, bb, ph);
for (let gx = 0; gx < GW; gx++) {
const x = gx * CELL + CELL / 2;
if (x < xl || x > xr) continue;
const aa = (x - xl) / (xr - xl);
let lit = h;
for (const s of shadows) {
const [sx, sy] = patchPoint(s.a, s.b, ph);
const d = Math.hypot(x - sx, y - sy);
if (d < s.r * 1.3) lit *= 0.15;
}
// window cross bars also shade
if (Math.abs(aa - 0.5) < 0.02 || Math.abs(bb - 0.5) < 0.015) lit *= 0.4;
expo[gy * GW + gx] += lit * 0.0009;
}
}
}
// render the fade
fadeG.loadPixels();
for (let i = 0; i < GW * GH; i++) {
const e = 1 - Math.exp(-expo[i]);
fadeG.pixels[i * 4] = 236;
fadeG.pixels[i * 4 + 1] = 222;
fadeG.pixels[i * 4 + 2] = 196;
fadeG.pixels[i * 4 + 3] = Math.min(255, e * 150);
}
fadeG.updatePixels();
p.push();
p.tint(255, 255 * (0.35 + 0.65 * amb));
p.image(fadeG, 0, FLOOR_Y, W, GH * CELL);
p.pop();
// the patch itself
if (h > 0.02) {
const ctx = p.drawingContext;
const q = [patchPoint(0, 0, ph), patchPoint(1, 0, ph), patchPoint(1, 1, ph), patchPoint(0, 1, ph)];
ctx.save();
ctx.beginPath();
ctx.moveTo(q[0][0], q[0][1]);
for (let k = 1; k < 4; k++) ctx.lineTo(q[k][0], q[k][1]);
ctx.closePath();
ctx.clip();
const lc = [255, 236 - (1 - h) * 50, 196 - (1 - h) * 90];
p.noStroke();
p.fill(lc[0], lc[1], lc[2], 40 + 120 * h);
p.rect(0, FLOOR_Y, W, H - FLOOR_Y);
// cross-bar shadows
p.stroke(fl[0], fl[1], fl[2], 90 * h);
p.strokeWeight(3);
const m0 = patchPoint(0.5, 0, ph);
const m1 = patchPoint(0.5, 1, ph);
p.line(m0[0], m0[1], m1[0], m1[1]);
const n0 = patchPoint(0, 0.5, ph);
const n1 = patchPoint(1, 0.5, ph);
p.line(n0[0], n0[1], n1[0], n1[1]);
// balloon shadows
p.noStroke();
for (const s of shadows) {
const [sx, sy] = patchPoint(s.a, s.b, ph);
p.fill(fl[0] * 0.9, fl[1] * 0.9, fl[2] * 0.9, 150 * h);
p.ellipse(sx, sy, s.r * 2.2, s.r * 1.6);
}
ctx.restore();
}
// --- the window
winG.noStroke();
for (let y = 0; y < WIN.h; y += 2) {
const t = y / WIN.h;
winG.fill(sky[0] * (1 - 0.15 * t) + 20 * t, sky[1] * (1 - 0.05 * t) + 10 * t, sky[2] * (1 - 0.1 * t));
winG.rect(0, y, WIN.w, 2);
}
for (const b of balloons) {
const bx = b.u - WIN.x;
const by = b.v - WIN.y;
if (bx > -30 && bx < WIN.w + 30 && by > -40 && by < WIN.h + 40) {
const lit = 0.45 + 0.55 * amb;
drawBalloon(winG, bx, by, b.r, b.hue.map((c) => c * lit), 255);
}
}
p.image(winG, WIN.x, WIN.y);
// frame
const fc = [222, 214, 200].map((c) => c * (0.4 + 0.6 * amb));
p.noFill();
p.stroke(fc[0], fc[1], fc[2]);
p.strokeWeight(8);
p.rect(WIN.x, WIN.y, WIN.w, WIN.h);
p.strokeWeight(4);
p.line(WIN.x + WIN.w / 2, WIN.y, WIN.x + WIN.w / 2, WIN.y + WIN.h);
p.line(WIN.x, WIN.y + WIN.h / 2, WIN.x + WIN.w, WIN.y + WIN.h / 2);
p.strokeWeight(10);
p.line(WIN.x - 10, WIN.y + WIN.h + 8, WIN.x + WIN.w + 10, WIN.y + WIN.h + 8);
};
}