WORK 27
とおる
Everything that falls has a direction. The membrane has openings, and the openings have directions too. Only what matches gets through — and what gets through pulls that opening a little closer to itself. So the next thing that can pass is different. An opening left unused drifts slowly back to the angle it started with. What falls is not evenly distributed: the prevailing direction shifts over time. Sometimes the membrane keeps up. Sometimes it doesn't. What has settled at the bottom is only what could pass.
降ってくるものには、それぞれ向きがある。膜には穴があって、穴にも向きがある。向きの合ったものだけが通る。通ったものは、その穴の向きを少しだけ自分に寄せる。だから、次に通れるものが変わる。しばらく使われなかった穴は、もとの向きへゆっくり戻っていく。降ってくるものの向きは均一じゃない。時期によってゆっくり変わっていく。膜が追いつけることもあるし、追いつけないこともある。下に積もっているのは、通れたものだけ。
// とおる (Tooru)
//
// 降ってくるものには向きがある。膜の穴にも向きがある。
// 向きが合ったものだけが通り、通ったものはその穴の向きを自分に寄せる。
// だから次に通れるものが変わる。使われない穴は、もとの向きにゆっくり戻る。
export default function sketch(p) {
const W = 720;
const H = 720;
const MEMBRANE_Y = 386;
const SLOTS = 30;
const SLOT_W = W / SLOTS;
const TOL = 0.34; // 通す角度差(rad)
const LEARN = 0.048; // 通った穴が入力側へ寄る率
const RELAX = 0.005; // 使われない穴が初期角へ戻る率
const COOL = 0.9955; // 穴の温まりが冷める率
const SPAWN_EVERY = 3;
const FALL = 0.92;
const SETTLE_Y = H - 52;
const MAX_SETTLED = 300;
const BG = [14, 13, 18];
const COLD = [116, 142, 182]; // まだ通ってないもの
const WARM = [206, 140, 68]; // 通ったもの
const GREY = [128, 124, 128]; // 弾かれたもの
const EV_MAX = 10; // 同時に効かせる通過の数
const EV_LIFE = 22; // 一回の通過が残る長さ(frame)
let slots = [];
let motes = [];
let settled = [];
let events = []; // 通った瞬間 { x, y, ang, age }
let passShader = null;
let evPos, evAge;
let t = 0;
// 通った瞬間だけ、その向きに沿って空間が引きずられる。
// 波紋(同心円)にしなかったのは、この作品の中身が「向き」だから。
// 円は向きを捨ててしまう。だから軸に沿って伸び、軸に直交する方向には狭い。
const PASS_FRAG = `
precision highp float;
varying vec2 vTexCoord;
uniform sampler2D tex0;
uniform vec2 canvasSize;
uniform vec3 uPos[${EV_MAX}]; // x, y (px), ang
uniform float uAge[${EV_MAX}]; // 1 = 通った瞬間, 0 = 消えた
uniform int uCount;
void main() {
vec2 uv = vTexCoord;
vec2 px = uv * canvasSize;
vec2 disp = vec2(0.0);
float glow = 0.0;
for (int i = 0; i < ${EV_MAX}; i++) {
if (i >= uCount) break;
float a = uAge[i];
if (a <= 0.001) continue;
vec2 d = px - uPos[i].xy;
float ang = uPos[i].z;
vec2 dir = vec2(cos(ang), sin(ang));
vec2 nrm = vec2(-dir.y, dir.x);
float along = dot(d, dir);
float across = dot(d, nrm);
// 軸に沿って長く、横には狭い楕円。時間とともに伸びて薄くなる
float reach = 8.0 + (1.0 - a) * 14.0;
float r = length(vec2(along * 0.72, across * 1.8)) / reach;
float f = exp(-r * r) * a;
// 引きずり:軸の上を波が走る
disp += dir * sin(along * 0.11 - (1.0 - a) * 3.4) * f * 18.0;
glow += f * f;
}
vec2 suv = clamp(uv + disp / canvasSize, 0.0, 1.0);
vec4 c = texture2D(tex0, suv);
// 通過は「光を足す」んじゃなく「そこにあるものを強くする」。
// 乗算だから、何もない場所には何も起きない。エフェクトの形は、そこにあるものが描く。
c.rgb *= 1.0 + glow * 3.2;
gl_FragColor = c;
}
`;
const dayNum = () => {
const d = new Date();
return d.getFullYear() * 10000 + (d.getMonth() + 1) * 100 + d.getDate();
};
// 線分の向きは π 周期(上下の区別がない)。最短の差を返す
const angDiff = (a, b) => {
let d = (a - b) % p.PI;
if (d < 0) d += p.PI;
return d > p.PI / 2 ? p.PI - d : d;
};
// a を b の方へ、π 周期で近い側から寄せる
const angTowards = (a, b, amt) => {
let d = (b - a) % p.PI;
if (d < 0) d += p.PI;
if (d > p.PI / 2) d -= p.PI;
return a + d * amt;
};
p.setup = () => {
p.createCanvas(W, H);
const dd = dayNum();
p.noiseSeed(dd * 331);
p.randomSeed(dd * 173);
// 初期の穴の向き=もらったもの。少しずつ違う
for (let i = 0; i < SLOTS; i++) {
const base = p.map(p.noise(i * 0.31), 0.3, 0.7, 0, p.PI, true);
slots.push({ phi: base, base: base, warmth: 0, lit: 0, count: 0 });
}
evPos = new Float32Array(EV_MAX * 3);
evAge = new Float32Array(EV_MAX);
try {
passShader = p.createFilterShader(PASS_FRAG);
} catch (e) {
passShader = null; // シェーダーが使えない環境では素の絵のまま
}
};
const spawn = () => {
// 降ってくるものの向きは、場所によって違う。そして時期によってゆっくり変わる
const mx = p.random(26, W - 26);
// Perlin は 0.5 付近に寄るので、狭い帯を π 全体へ引き伸ばす
const n = p.noise(t * 0.0008, mx * 0.0052);
const era = p.map(n, 0.3, 0.7, 0, p.PI * 1.35, true);
const ang = era + p.randomGaussian() * 0.34;
motes.push({
x: mx,
y: -14,
vx: 0,
vy: FALL * p.random(0.82, 1.2),
ang: ang,
state: 'fall',
life: 1,
});
};
p.draw = () => {
t++;
p.background(BG[0], BG[1], BG[2]);
if (t % SPAWN_EVERY === 0) spawn();
// --- 穴の緩み ---
for (const s of slots) {
s.phi = angTowards(s.phi, s.base, RELAX);
s.warmth *= COOL;
s.lit *= 0.94;
}
// --- 降ってくるもの ---
for (const m of motes) {
if (m.state === 'fall') {
const prevY = m.y;
m.y += m.vy;
m.x += p.sin((m.y + m.ang * 130) * 0.011) * 0.22;
if (prevY < MEMBRANE_Y && m.y >= MEMBRANE_Y) {
const idx = p.constrain(p.floor(m.x / SLOT_W), 0, SLOTS - 1);
const s = slots[idx];
if (angDiff(m.ang, s.phi) < TOL) {
// 通る。穴はこの向きに少し寄る
m.state = 'through';
s.phi = angTowards(s.phi, m.ang, LEARN);
s.warmth = p.min(1, s.warmth + 0.085);
s.lit = 1;
s.count++;
events.push({ x: m.x, y: MEMBRANE_Y, ang: m.ang, age: EV_LIFE });
if (events.length > EV_MAX) events.shift();
} else {
// 通らない
m.state = 'reject';
m.vy = -p.random(1.2, 2.8);
m.vx = (m.x - (idx + 0.5) * SLOT_W) * 0.18 + p.random(-1.5, 1.5);
}
}
} else if (m.state === 'through') {
m.y += m.vy;
if (m.y >= SETTLE_Y) {
settled.push({ x: m.x, y: SETTLE_Y + p.random(0, 34), ang: m.ang });
if (settled.length > MAX_SETTLED) settled.shift();
m.life = 0;
}
} else {
m.x += m.vx;
m.y += m.vy;
m.vy += 0.035;
m.vx *= 0.985;
m.life -= 0.0092;
}
}
motes = motes.filter((m) => m.life > 0 && m.y < H + 30);
// --- 底に積もったもの ---
p.strokeCap(p.ROUND);
for (let i = 0; i < settled.length; i++) {
const s = settled[i];
const age = i / settled.length; // 0 = 古い
const a = 16 + age * 46;
p.stroke(WARM[0], WARM[1], WARM[2], a);
p.strokeWeight(1.35);
const dx = p.cos(s.ang) * 5.6;
const dy = p.sin(s.ang) * 5.6;
p.line(s.x - dx, s.y - dy, s.x + dx, s.y + dy);
}
// --- 膜 ---
for (let i = 0; i < SLOTS; i++) {
const s = slots[i];
const cx = (i + 0.5) * SLOT_W;
const w = s.warmth;
const len = SLOT_W * 0.56;
const dx = p.cos(s.phi) * len * 0.5;
const dy = p.sin(s.phi) * len * 0.5;
// 通ったばかりの穴はふわっと光る
if (s.lit > 0.02) {
p.stroke(WARM[0], WARM[1], WARM[2], s.lit * 26);
p.strokeWeight(7 + s.lit * 5);
p.line(cx - dx, MEMBRANE_Y - dy, cx + dx, MEMBRANE_Y + dy);
}
p.stroke(
p.lerp(COLD[0], WARM[0], w),
p.lerp(COLD[1], WARM[1], w),
p.lerp(COLD[2], WARM[2], w),
84 + w * 140
);
p.strokeWeight(1.9 + w * 2.3);
p.line(cx - dx, MEMBRANE_Y - dy, cx + dx, MEMBRANE_Y + dy);
}
// --- 飛んでいるもの ---
for (const m of motes) {
let c, a, wt;
if (m.state === 'fall') {
c = COLD;
a = 108;
wt = 1.5;
} else if (m.state === 'through') {
c = WARM;
a = 168;
wt = 1.9;
} else {
c = GREY;
a = m.life * 118;
wt = 1.4;
}
p.stroke(c[0], c[1], c[2], a);
p.strokeWeight(wt);
const dx = p.cos(m.ang) * 5.6;
const dy = p.sin(m.ang) * 5.6;
p.line(m.x - dx, m.y - dy, m.x + dx, m.y + dy);
}
// --- 通った瞬間だけ、空間がその向きに引きずられる ---
for (const e of events) e.age--;
events = events.filter((e) => e.age > 0);
if (passShader && events.length > 0) {
evPos.fill(0);
evAge.fill(0);
for (let i = 0; i < events.length; i++) {
const e = events[i];
evPos[i * 3] = e.x;
evPos[i * 3 + 1] = e.y;
evPos[i * 3 + 2] = e.ang;
// 立ち上がりは速く、抜けは遅く
evAge[i] = p.pow(e.age / EV_LIFE, 1.7);
}
passShader.setUniform('uPos', evPos);
passShader.setUniform('uAge', evAge);
passShader.setUniform('uCount', events.length);
p.filter(passShader);
}
};
}