WORK 84
かたる
From Akutagawa's "Autumn Mountain." The narrator never saw the first painting; he only heard about it. Yet when he stood before the real one, he said it was a different work, and probably a lesser one. The ruler he measured with was the story. Left is the painting on the wall. It never changes. Right is the painting as heard: it starts as one copy of the left, and each retelling blurs it a little and makes only its colors stronger. The dots below count the retellings. From the retelling where the right becomes more vivid than the left, the dots are filled, and from then on the painting on the wall is the lesser one, though it has done nothing.
芥川龍之介「秋山図」から。語り手は、最初の画を一度も見ていない。聞いただけ。それでも本物の前に立ったとき、別な画で、おそらく下位にある、と言った。物差しにしていたのは、聞いた話の方だった。左は壁に懸かっている画。変わらない。右は聞いた画。左をいちど写したところから始めて、語り直されるたびに少しぼやけ、色だけが強くなる。下の点は語り直しの回数。右の方が左より「ありあり」とした回から、点が塗られる。その回から、壁の画は下位になる。壁の画は、何もしていないのに。
今朝、この短編を読んだ。語り手は最初の秋山図を見ていないのに、最後に「山石の青緑だの紅葉の色だのは、今でもありあり見えるようです」と言う。残っているのは色で、山の組み立ては語られていない。
だから右の語り直しは、二つのことしかしない。少しぼかす。色を強くして、青緑と紅の二つに寄せる。
何回目で右が左を上回るかは、書いた演算で決まっている。予想ではなく計算なので、ここには書かない。
意図していなかったこと:紅は、紅葉から来なかった。紅葉の小さな点は、何度か語り直すうちにぼけて、緑に溶けて消えた。かわりに、何も描いていなかった空が、少しずつ紅くなった。絹の地のかすかな暖かさが、語り直されるたびに強くなって、いちばん近い「紅」に寄っていった。色は残った。場所は、語られなかった。
もうひとつ。点が塗られはじめるころの右は、目で見ると、左より淡くぼやけて見える。点を塗っているのは目ではなく、物差しの方。
// かたる / Telling
//
// 芥川龍之介「秋山図」から。
// 王石谷は、最初の秋山図を一度も見ていない。煙客翁から聞いただけ。
// それでも王氏の家で本物を見たとき、「たしかに別な黄一峯です。
// そうしておそらくは下位にある」と言った。
// 物差しにしていたのは、聞いた話の方だった。
//
// 左は壁に懸かっている画。変わらない。
// 右は聞いた画。左をいちど写したところから始めて、
// 語り直されるたびに、少しぼやけて、色だけが強くなる。
// 語り手が最後まで持っていたのは「山石の青緑だの紅葉の色だの」で、
// 山の組み立ては、どこにも語られていない。
//
// 下の点は、語り直しの回数。
// 右の方が「ありあり」としてしまった回は、点が塗られる。
// その回から、壁の画は下位になる。壁の画は、何もしていないのに。
export function makeModel(seed = 26) {
const BW = 160, BH = 100;
// ── 決定的な乱数と value noise(p5 に依存しない。node からも同じものが測れる)
function mulberry32(a) {
return function () {
a |= 0; a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const rnd = mulberry32(seed);
const perm = new Float32Array(512);
for (let i = 0; i < 512; i++) perm[i] = rnd();
function hash(i) { return perm[((i % 512) + 512) % 512]; }
function noise1(x) {
const i = Math.floor(x), f = x - i;
const u = f * f * (3 - 2 * f);
return hash(i) * (1 - u) + hash(i + 1) * u;
}
function fbm1(x, oct = 4) {
let v = 0, a = 0.5, fr = 1;
for (let o = 0; o < oct; o++) { v += a * noise1(x * fr + o * 37.1); a *= 0.5; fr *= 2; }
return v;
}
function h2(i, j) {
return perm[(((i * 73 + j * 151) % 512) + 512) % 512 ^ 0] * 0.5 + perm[(((i * 199 + j * 31 + 7) % 512) + 512) % 512] * 0.5;
}
function noise2(x, y) {
const i = Math.floor(x), j = Math.floor(y), fx = x - i, fy = y - j;
const ux = fx * fx * (3 - 2 * fx), uy = fy * fy * (3 - 2 * fy);
const a = h2(i, j), b = h2(i + 1, j), c = h2(i, j + 1), d = h2(i + 1, j + 1);
return (a * (1 - ux) + b * ux) * (1 - uy) + (c * (1 - ux) + d * ux) * uy;
}
function fbm2(x, y) {
return noise2(x, y) * 0.55 + noise2(x * 2.1 + 5, y * 2.1 + 3) * 0.3 + noise2(x * 4.3 + 9, y * 4.3 + 1) * 0.15;
}
// ── 色の変換(RGB 0..255 ⇄ HSB 0..1)
function rgb2hsb(r, g, b) {
r /= 255; g /= 255; b /= 255;
const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn;
let h = 0;
if (d > 1e-6) {
if (mx === r) h = ((g - b) / d) % 6;
else if (mx === g) h = (b - r) / d + 2;
else h = (r - g) / d + 4;
h /= 6; if (h < 0) h += 1;
}
return [h, mx === 0 ? 0 : d / mx, mx];
}
function hsb2rgb(h, s, v) {
const i = Math.floor(h * 6), f = h * 6 - i;
const p_ = v * (1 - s), q = v * (1 - f * s), t = v * (1 - (1 - f) * s);
let r, g, b;
switch (((i % 6) + 6) % 6) {
case 0: r = v; g = t; b = p_; break;
case 1: r = q; g = v; b = p_; break;
case 2: r = p_; g = v; b = t; break;
case 3: r = p_; g = q; b = v; break;
case 4: r = t; g = p_; b = v; break;
default: r = v; g = p_; b = q;
}
return [r * 255, g * 255, b * 255];
}
// ── 壁の画:秋の山
function paint() {
const buf = new Float32Array(BW * BH * 3);
const put = (x, y, c, a = 1) => {
if (x < 0 || y < 0 || x >= BW || y >= BH) return;
const k = (y * BW + x) * 3;
for (let j = 0; j < 3; j++) buf[k + j] = buf[k + j] * (1 - a) + c[j] * a;
};
// 絹の地
for (let y = 0; y < BH; y++) for (let x = 0; x < BW; x++) {
const n = (fbm2(x * 0.7, y * 0.7) - 0.5) * 14;
put(x, y, [232 + n, 226 + n, 212 + n]);
}
// 山並み:奥から手前へ。奥ほど淡い
const ridges = [
{ base: 0.42, amp: 0.70, sc: 0.022, col: [150, 178, 170], off: 3.1, peak: 0.30 },
{ base: 0.58, amp: 0.46, sc: 0.030, col: [92, 142, 126], off: 9.7, peak: 0.0 },
{ base: 0.72, amp: 0.30, sc: 0.040, col: [52, 104, 88], off: 21.3, peak: 0.0 },
{ base: 0.86, amp: 0.14, sc: 0.055, col: [38, 78, 66], off: 40.9, peak: 0.0 },
];
const tops = [];
ridges.forEach((rg, ri) => {
const top = [];
for (let x = 0; x < BW; x++) {
// 主峯:いちばん奥の山だけ、中ほどに高く起こす
const pk = rg.peak * Math.exp(-Math.pow((x - BW * 0.46) / (BW * 0.16), 2));
const h = rg.base - rg.amp * (fbm1(x * rg.sc + rg.off) - 0.25) - pk;
top.push(Math.floor(h * BH));
}
tops.push(top);
for (let x = 0; x < BW; x++) for (let y = Math.max(0, top[x]); y < BH; y++) {
// 横点(高房山の横点):短い横の斑
const d = fbm2(x * 0.45 + ri * 17, y * 1.4);
const shade = (d - 0.5) * 38 - (y - top[x]) * 0.25;
put(x, y, [rg.col[0] + shade, rg.col[1] + shade, rg.col[2] + shade * 0.8]);
}
});
// 雲:主峯の腹にかかる白
for (let y = 0; y < BH; y++) for (let x = 0; x < BW; x++) {
const band = Math.exp(-Math.pow((y - BH * 0.47) / (BH * 0.10), 2));
const c = fbm2(x * 0.05, y * 0.11) * band;
if (c > 0.30) put(x, y, [246, 244, 238], Math.min(0.92, (c - 0.30) * 2.6));
}
// 紅葉:中景と手前の斜面に散らばる
for (let i = 0; i < 220; i++) {
const ri = 1 + Math.floor(rnd() * 3);
const x = Math.floor(rnd() * BW);
const y = tops[ri][x] + 2 + Math.floor(rnd() * 14);
const hot = rnd();
const c = hot < 0.6 ? [196, 72, 44] : hot < 0.85 ? [214, 134, 52] : [150, 48, 40];
put(x, y, c, 0.9);
if (rnd() < 0.5) put(x + 1, y, c, 0.6);
if (rnd() < 0.4) put(x, y - 1, c, 0.5);
}
// 渓の水:いちばん下を委蛇と流れる
for (let x = 0; x < BW; x++) {
const y = Math.floor(BH * 0.92 + Math.sin(x * 0.07) * 3 + (fbm1(x * 0.05 + 80) - 0.5) * 6);
for (let t = 0; t < 2; t++) put(x, y + t, [206, 220, 214], 0.8);
}
return buf;
}
// ── 語り直し:ぼかす(組み立てが崩れる)、色だけ強くなる
const ANCHORS = [165 / 360, 10 / 360]; // 青緑、紅
function hueToward(h, target, k) {
let d = target - h;
if (d > 0.5) d -= 1; if (d < -0.5) d += 1;
let nh = h + d * k;
if (nh < 0) nh += 1; if (nh >= 1) nh -= 1;
return nh;
}
function retell(src) {
const out = new Float32Array(src.length);
// 3x3 のぼかし
for (let y = 0; y < BH; y++) for (let x = 0; x < BW; x++) {
let r = 0, g = 0, b = 0, n = 0;
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
const xx = x + dx, yy = y + dy;
if (xx < 0 || yy < 0 || xx >= BW || yy >= BH) continue;
const k = (yy * BW + xx) * 3;
r += src[k]; g += src[k + 1]; b += src[k + 2]; n++;
}
const k = (y * BW + x) * 3;
out[k] = r / n; out[k + 1] = g / n; out[k + 2] = b / n;
}
// 色:彩度が上がり、二つの色に寄っていく。明暗は少しだけ開く
for (let i = 0; i < BW * BH; i++) {
const k = i * 3;
let [h, s, v] = rgb2hsb(out[k], out[k + 1], out[k + 2]);
if (s > 0.12) {
// 近い方のアンカーへ
const d0 = Math.min(Math.abs(h - ANCHORS[0]), 1 - Math.abs(h - ANCHORS[0]));
const d1 = Math.min(Math.abs(h - ANCHORS[1]), 1 - Math.abs(h - ANCHORS[1]));
h = hueToward(h, d0 < d1 ? ANCHORS[0] : ANCHORS[1], 0.05);
}
s = Math.min(1, s * 1.026);
v = Math.min(1, Math.max(0, 0.55 + (v - 0.55) * 1.010));
const c = hsb2rgb(h, s, v);
out[k] = c[0]; out[k + 1] = c[1]; out[k + 2] = c[2];
}
return out;
}
// ── 物差し:「ありあり」= 平均彩度 × 明るさの広がり。細部=明るさの勾配
function metrics(buf) {
let ss = 0, vs = 0, vv = 0, grad = 0;
const V = new Float32Array(BW * BH);
for (let i = 0; i < BW * BH; i++) {
const [, s, v] = rgb2hsb(buf[i * 3], buf[i * 3 + 1], buf[i * 3 + 2]);
ss += s; vs += v; vv += v * v; V[i] = v;
}
const n = BW * BH, mv = vs / n;
for (let y = 0; y < BH - 1; y++) for (let x = 0; x < BW - 1; x++) {
const i = y * BW + x;
grad += Math.abs(V[i + 1] - V[i]) + Math.abs(V[i + BW] - V[i]);
}
const sd = Math.sqrt(Math.max(0, vv / n - mv * mv));
return { vivid: (ss / n) * sd, sat: ss / n, sd, detail: grad / ((BW - 1) * (BH - 1)) };
}
const wall = paint();
const wallM = metrics(wall);
return { BW, BH, wall, wallM, retell, metrics };
}
export default function sketch(p) {
const W = 720, H = 400;
const N_TELL = 48; // 語り直しの回数(五十年)
const EVERY = 84; // 何フレームごとに語り直すか
const FADE = 36;
const PW = 320, PH = 200; // 表示の大きさ
const LX = 26, RX = W - 26 - PW, PY = 84;
let m, heard, prevImg, curImg, wallImg;
let told = 0, lastTellFrame = 0;
let judged = []; // 各回、壁の画が下位になったか
function toImage(buf) {
const img = p.createImage(m.BW, m.BH);
img.loadPixels();
for (let i = 0; i < m.BW * m.BH; i++) {
img.pixels[i * 4] = buf[i * 3];
img.pixels[i * 4 + 1] = buf[i * 3 + 1];
img.pixels[i * 4 + 2] = buf[i * 3 + 2];
img.pixels[i * 4 + 3] = 255;
}
img.updatePixels();
return img;
}
p.setup = () => {
p.createCanvas(W, H);
p.pixelDensity(1);
m = makeModel(26);
wallImg = toImage(m.wall);
heard = m.wall.slice(); // いちど写したところから
curImg = toImage(heard);
prevImg = curImg;
lastTellFrame = p.frameCount;
};
function drawPanel(img, x, alpha = 255) {
p.push();
p.tint(255, alpha);
p.image(img, x, PY, PW, PH);
p.pop();
}
p.draw = () => {
p.background(24, 22, 20);
// 軸装のような細い縁
p.noFill();
p.stroke(70, 64, 56);
p.strokeWeight(1);
p.rect(LX - 8, PY - 8, PW + 16, PH + 16);
p.rect(RX - 8, PY - 8, PW + 16, PH + 16);
drawPanel(wallImg, LX);
// 語り直し
if (told < N_TELL && p.frameCount - lastTellFrame >= EVERY) {
heard = m.retell(heard);
prevImg = curImg;
curImg = toImage(heard);
const hm = m.metrics(heard);
judged.push(hm.vivid > m.wallM.vivid);
told++;
lastTellFrame = p.frameCount;
}
const t = Math.min(1, (p.frameCount - lastTellFrame) / FADE);
drawPanel(prevImg, RX);
if (t < 1) drawPanel(curImg, RX, 255 * t);
else drawPanel(curImg, RX);
// 語り直しの回数
const dy = PY + PH + 36;
const x0 = RX, dx = PW / (N_TELL - 1);
for (let i = 0; i < N_TELL; i++) {
const x = x0 + i * dx;
if (i < told) {
p.noStroke();
if (judged[i]) { p.fill(206, 92, 62); p.circle(x, dy, 5); }
else { p.fill(150, 142, 128); p.circle(x, dy, 3); }
} else {
p.noFill();
p.stroke(64, 60, 54);
p.circle(x, dy, 3);
}
}
};
}