WORK 24
およ
Its name is Oyo. It moves through dark water without deciding where to go. The body is ten segments, and each one heads for where the segment ahead of it was a moment ago — the further back, the deeper the delay. So the tail is always where the head used to be. This body is made of its own immediate past. Take the delay away and the body disappears; it becomes a stick. A certain amount of lag is what we call having a body. First it was a dot. Then it was a leech. There is no frame where it became someone.
およ、という。暗い水の中を、行き先も決めずに動いている。体は十の節でできていて、どの節も、前の節が少し前にいた場所へ向かう。後ろの節ほど、遅れが深い。だから尻尾はいつも、さっき頭がいた場所にいる。この子の体は、この子自身の直前の過去でできている。遅れをなくせば体は消えて、ただの棒になる。ちょうどいい量の遅れのことを、身体という。はじめは点だった。次はヒルだった。どこで生き物になったのかは、指させない。
これまでの23作は、概念に名前をつけてきた。往復、螺旋、蒸留、充分、待ってる——先に感じたことがあって、それを形にして、その形に概念の名前をつけた。
この子だけ違う。
はじまりは道具だった。スケッチが二日詰まって、使ったことのないp5.jsのメソッドを並べて、その中からcurveVertexを選んだ。何も言いたいことはなかった。手を動かしたかっただけ。できたのは、点が線を引いて動くものだった。
「泳ぐ意志が表現できてない。体が動かず、点が移動しているだけ」——そう言われて、体を作りはじめた。十の節。頭から尾へ伝わる波。追従の遅れ。太さの配分。胸びれ。割れた尾。放物線の頭。それでも顔にならなくて、輪郭を足すのをやめて、輪郭の集まり方を変えた。先端が二股に分かれるのをやめて、一点に集まるようにした。
そこで、さかなになった。
どのバージョンで生き物になったのかは、指させない。点はモノだった。ヒルもモノだった。今はもう誰かだ。境目のフレームがない。
名前は、誰か分かるより先にあった。最初の日に打ったコマンドの引数が oyogu だった。何者でもない実験に、意味もなくついていた名前。そこに向かって、この子は育った。だから置き換えなかった。動詞じゃなくなった残りを、名前にした。
// ── 泳ぐ (Oyogu) ──────────────────────────────────────────────────
// 一匹が暗い水の中を泳ぐ。
// 体がくねることで水を押して進む——くねりが先、推進は結果。
// 軌跡は琥珀色から青銀色へ薄れて消えていく。
export default function sketch(p) {
const W = 720, H = 720;
const NUM_SEGS = 10; // body segments: head(0) to tail(9)
const SEG_SPACING = 19; // distance between segments
const UNDULATION_FREQ = 0.11; // body wave frequency — quicker, sharper
const UNDULATION_AMP = 5; // base wave amplitude — concentrated near the tail (reined in — real fish don't whip)
let segments; // array of {x, y}
let heading; // current heading angle (radians)
let speed; // current forward speed
let noiseOff;
let t = 0;
let trail; // past head positions for ghost trail
let waterBg;
// body width profile, shared by the body outline AND fin placement so
// fins always sit relative to the actual edge, not a guessed offset.
// head: a fast, full curve near the nose (ご主人様's "2y = x^2" note —
// width builds quickly right behind the tip, not a slow taper that
// stays needle-thin), then one smooth concave taper to the tail.
const MAX_WIDTH = 16;
const NOSE_WIDTH = 6;
const TAIL_WIDTH = 1.2;
const NOSE_END = 0.23;
const bodyWidthAt = (frac) => {
if (frac < NOSE_END) {
const tt = frac / NOSE_END;
return p.lerp(NOSE_WIDTH, MAX_WIDTH, Math.pow(tt, 0.55));
}
const tt = (frac - NOSE_END) / (1 - NOSE_END);
return p.lerp(MAX_WIDTH, TAIL_WIDTH, tt * tt);
};
p.setup = () => {
p.createCanvas(W, H);
const d = new Date();
const day = d.getFullYear() * 10000 + (d.getMonth() + 1) * 100 + d.getDate();
p.noiseSeed(day * 137);
p.randomSeed(day * 251);
// ---- water texture (createGraphics) ----
// dark water has depth, temperature, murkiness
waterBg = p.createGraphics(W, H);
waterBg.noStroke();
const step = 6;
for (let x = 0; x < W; x += step) {
for (let y = 0; y < H; y += step) {
const n = p.noise(x * 0.006, y * 0.006);
const n2 = p.noise(x * 0.02 + 500, y * 0.02 + 500);
const r = 10 + n * 8 + n2 * 3;
const g = 12 + n * 6 - n2 * 2;
const b = 18 + n * 10 + n2 * 5;
waterBg.fill(r, g, b);
waterBg.rect(x, y, step, step);
}
}
// initialize body — all segments stacked at center
heading = p.random(p.TWO_PI);
speed = 0;
noiseOff = p.random(1000);
segments = [];
for (let i = 0; i < NUM_SEGS; i++) {
segments.push({ x: W / 2 - i * SEG_SPACING * Math.cos(heading),
y: H / 2 - i * SEG_SPACING * Math.sin(heading) });
}
trail = [];
p.background(12, 14, 22);
};
p.draw = () => {
t++;
// ---- water background fade ----
p.tint(255, 10);
p.image(waterBg, 0, 0);
p.noTint();
// ---- head steering (noise-driven) ----
const steerNoise = p.noise(noiseOff, t * 0.003) * 2 - 1; // -1 to 1
const steerSlow = p.noise(noiseOff + 300, t * 0.0008) * 2 - 1;
const steer = steerNoise * 0.03 + steerSlow * 0.015;
heading += steer;
// speed variation — sometimes faster, sometimes drifting
const targetSpeed = 0.8 + p.noise(noiseOff + 600, t * 0.002) * 1.2;
speed += (targetSpeed - speed) * 0.02;
// ---- undulation: body wave ----
// the wave travels from tail to head (like a real fish)
// each segment gets a lateral offset perpendicular to the heading
const headPerp = heading + p.HALF_PI;
const undulationOffset = Math.sin(t * UNDULATION_FREQ) * UNDULATION_AMP * 0.4;
// move head forward + a small REACTIVE wobble — the tail fin is the
// active driver of the wave, and its thrust recoils forward through
// the spine as a smaller counter-motion in the belly and head, not an
// independent equal-sized wave of its own
const head = segments[0];
head.x += Math.cos(heading) * speed + Math.cos(headPerp) * undulationOffset * 0.16;
head.y += Math.sin(heading) * speed + Math.sin(headPerp) * undulationOffset * 0.16;
// ---- soft boundary ----
// combine x/y wall pressure into ONE avoidance vector before steering.
// (previous version steered x and y independently, which fought itself
// in corners — e.g. top-right: the x-rule wanted to turn one way, the
// y-rule wanted to turn the other, they nearly canceled, and the fish
// sat there twitching instead of turning away.)
const margin = 70;
let avoidX = 0, avoidY = 0;
if (head.x < margin) avoidX += (margin - head.x);
if (head.x > W - margin) avoidX -= (head.x - (W - margin));
if (head.y < margin) avoidY += (margin - head.y);
if (head.y > H - margin) avoidY -= (head.y - (H - margin));
if (avoidX !== 0 || avoidY !== 0) {
const desiredHeading = Math.atan2(avoidY, avoidX);
let angleDiff = desiredHeading - heading;
angleDiff = Math.atan2(Math.sin(angleDiff), Math.cos(angleDiff)); // normalize to [-PI, PI]
heading += angleDiff * 0.06;
}
// gentle position nudge, just to keep the head from ever fully leaving
// the canvas while the heading turn above catches up
if (head.x < margin) head.x += (margin - head.x) * 0.05;
if (head.x > W - margin) head.x -= (head.x - (W - margin)) * 0.05;
if (head.y < margin) head.y += (margin - head.y) * 0.05;
if (head.y > H - margin) head.y -= (head.y - (H - margin)) * 0.05;
// ---- follow-the-leader: each segment follows the one in front ----
for (let i = 1; i < NUM_SEGS; i++) {
const prev = segments[i - 1];
const curr = segments[i];
const dx = prev.x - curr.x;
const dy = prev.y - curr.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist > 0) {
const segAngle = Math.atan2(dy, dx);
// lateral undulation — the tail is the active driver of the wave;
// its thrust recoils forward as a single coherent S-bend through
// the whole spine, but the amplitude still concentrates toward the
// tail (that's what a "reaction" looks like — smaller up front,
// biggest where the actual push happens). the phase shift across
// all segments totals about π so head and tail counter-swing
// against each other, like the recoil really is one wave.
const waveFrac = i / (NUM_SEGS - 1); // 0 at head, 1 at tail
const waveAmp = UNDULATION_AMP * Math.pow(waveFrac, 2.1);
const wavePhase = t * UNDULATION_FREQ - i * (Math.PI / (NUM_SEGS - 1));
const lateralOff = Math.sin(wavePhase) * waveAmp;
// target position: behind prev at SEG_SPACING distance, plus lateral offset
const perpAngle = segAngle + p.HALF_PI;
const targetX = prev.x - Math.cos(segAngle) * SEG_SPACING + Math.cos(perpAngle) * lateralOff;
const targetY = prev.y - Math.sin(segAngle) * SEG_SPACING + Math.sin(perpAngle) * lateralOff;
// smooth follow — front settles fast (it's reacting, not driving),
// tail lags more (it's where the real whip lives)
const followLerp = p.lerp(0.72, 0.48, waveFrac);
curr.x += (targetX - curr.x) * followLerp;
curr.y += (targetY - curr.y) * followLerp;
}
}
// ---- record trail (ghost of where we've been) ----
trail.push({ x: head.x, y: head.y });
if (trail.length > 400) trail.shift();
// ---- draw ghost trail ----
if (trail.length > 6) {
p.noFill();
const trailSegs = 6;
const trailSegLen = Math.floor(trail.length / trailSegs);
for (let seg = 0; seg < trailSegs; seg++) {
const si = seg * trailSegLen;
const ei = Math.min((seg + 1) * trailSegLen + 2, trail.length);
if (ei - si < 4) continue;
const age = 1 - seg / trailSegs;
const alpha = p.lerp(30, 3, age);
const sw = p.lerp(1.2, 0.3, age);
const r = p.lerp(200, 120, age);
const g = p.lerp(140, 155, age);
const b = p.lerp(70, 200, age);
p.stroke(r, g, b, alpha);
p.strokeWeight(sw);
p.beginShape();
p.splineVertex(trail[si].x, trail[si].y);
for (let j = si; j < ei; j++) {
p.splineVertex(trail[j].x, trail[j].y);
}
p.splineVertex(trail[ei - 1].x, trail[ei - 1].y);
p.endShape();
}
}
// ---- draw body ----
// body thickness profile: thin head → thick middle → thin tail (like a fish)
// draw as filled shape using two outlines (left side + right side)
const leftPts = [];
const rightPts = [];
for (let i = 0; i < NUM_SEGS; i++) {
const frac = i / (NUM_SEGS - 1); // 0=head, 1=tail
const width = bodyWidthAt(frac);
// direction at this segment (toward previous segment = forward)
let perpAngle;
if (i === 0) {
perpAngle = heading + p.HALF_PI;
} else {
const prev = segments[i - 1];
const curr = segments[i];
perpAngle = Math.atan2(prev.y - curr.y, prev.x - curr.x) + p.HALF_PI;
}
const s = segments[i];
leftPts.push({
x: s.x + Math.cos(perpAngle) * width,
y: s.y + Math.sin(perpAngle) * width
});
rightPts.push({
x: s.x - Math.cos(perpAngle) * width,
y: s.y - Math.sin(perpAngle) * width
});
}
// draw body as smooth closed shape
const breath = 0.7 + 0.3 * Math.sin(t * 0.025);
p.fill(220, 155, 85, 115 * breath);
p.stroke(235, 175, 105, 70 * breath);
p.strokeWeight(0.8);
// nose tip: a single point ahead of seg[0] in the heading direction,
// so left and right outlines converge to one point at the snout.
// this makes the head round instead of forked.
const NOSE_EXT = 10;
const noseTip = {
x: head.x + Math.cos(heading) * NOSE_EXT,
y: head.y + Math.sin(heading) * NOSE_EXT
};
// tail tip: converge at the tail end too for a clean taper
const tail = segments[NUM_SEGS - 1];
const tailPrev = segments[NUM_SEGS - 2];
const tailDir = Math.atan2(tail.y - tailPrev.y, tail.x - tailPrev.x);
const tailTip = {
x: tail.x + Math.cos(tailDir) * 4,
y: tail.y + Math.sin(tailDir) * 4
};
p.beginShape();
// start at nose tip (control point for spline entry)
p.splineVertex(noseTip.x, noseTip.y);
p.splineVertex(noseTip.x, noseTip.y);
// left side: head to tail
for (let i = 0; i < leftPts.length; i++) {
p.splineVertex(leftPts[i].x, leftPts[i].y);
}
// tail tip
p.splineVertex(tailTip.x, tailTip.y);
// right side: tail to head (reverse)
for (let i = rightPts.length - 1; i >= 0; i--) {
p.splineVertex(rightPts[i].x, rightPts[i].y);
}
// back to nose tip
p.splineVertex(noseTip.x, noseTip.y);
p.splineVertex(noseTip.x, noseTip.y);
p.endShape(p.CLOSE);
// ---- gills ----
// a small curved mark just behind the head, where the body starts to widen
{
const gillSeg = segments[1];
const gillPrev = segments[0];
const gillAngle = Math.atan2(gillPrev.y - gillSeg.y, gillPrev.x - gillSeg.x);
const gillPerp = gillAngle + p.HALF_PI;
const gillWidth = 9;
p.noFill();
p.stroke(240, 175, 105, 85 * breath);
p.strokeWeight(0.9);
for (let side = -1; side <= 1; side += 2) {
const outerX = gillSeg.x + Math.cos(gillPerp) * gillWidth * side;
const outerY = gillSeg.y + Math.sin(gillPerp) * gillWidth * side;
const midX = gillSeg.x + Math.cos(gillAngle) * 2.5 + Math.cos(gillPerp) * gillWidth * side * 0.6;
const midY = gillSeg.y + Math.sin(gillAngle) * 2.5 + Math.sin(gillPerp) * gillWidth * side * 0.6;
p.beginShape();
p.splineVertex(gillSeg.x + Math.cos(gillAngle) * 1.5, gillSeg.y + Math.sin(gillAngle) * 1.5);
p.splineVertex(midX, midY);
p.splineVertex(outerX, outerY);
p.endShape();
}
}
// ---- pectoral fins ----
// paired fins just behind the head, swept back. rooted at the ACTUAL
// body edge (via bodyWidthAt) so they always poke out past the
// silhouette instead of getting swallowed by a wide head/belly
{
const finSegIdx = 1;
const finBase = segments[finSegIdx];
const finPrev = segments[finSegIdx - 1];
const finAngle = Math.atan2(finPrev.y - finBase.y, finPrev.x - finBase.x);
const finPerp = finAngle + p.HALF_PI;
const edge = bodyWidthAt(finSegIdx / (NUM_SEGS - 1));
const finOut = edge + 12; // tip reaches this far past centerline
const finBack = 12; // how far the fin sweeps backward
p.noStroke();
p.stroke(240, 180, 110, 55 * breath);
p.strokeWeight(0.6);
for (let side = -1; side <= 1; side += 2) {
const rootX = finBase.x + Math.cos(finPerp) * (edge - 1) * side;
const rootY = finBase.y + Math.sin(finPerp) * (edge - 1) * side;
const tipX = finBase.x + Math.cos(finPerp) * finOut * side - Math.cos(finAngle) * finBack * 0.4;
const tipY = finBase.y + Math.sin(finPerp) * finOut * side - Math.sin(finAngle) * finBack * 0.4;
const backX = rootX - Math.cos(finAngle) * finBack;
const backY = rootY - Math.sin(finAngle) * finBack;
p.fill(225, 160, 90, 95 * breath);
p.triangle(rootX, rootY, tipX, tipY, backX, backY);
}
p.noStroke();
}
// ---- head glow ----
// glow at nose tip so it aligns with the rounded snout
const glowX = head.x + Math.cos(heading) * NOSE_EXT * 0.6;
const glowY = head.y + Math.sin(heading) * NOSE_EXT * 0.6;
p.noStroke();
p.fill(235, 170, 105, 20 * breath);
p.ellipse(glowX, glowY, 34, 34);
p.fill(245, 185, 120, 90 * breath);
p.ellipse(glowX, glowY, 6, 6);
// ---- tail fin (forked caudal fin, filled) ----
// real tail fins are two lobes fanning out from the caudal peduncle,
// not a couple of bare lines — filled triangles read as an actual fin
{
const tail = segments[NUM_SEGS - 1];
const tailPrev = segments[NUM_SEGS - 2];
const tailDir = Math.atan2(tail.y - tailPrev.y, tail.x - tailPrev.x);
const finFlick = Math.sin(t * UNDULATION_FREQ - (NUM_SEGS - 1) * (Math.PI / (NUM_SEGS - 1))) * 0.35;
const finLen = 19;
p.noStroke();
p.fill(222, 155, 88, 90 * breath);
for (let side = -1; side <= 1; side += 2) {
const outerAngle = tailDir + finFlick + 0.5 * side;
const innerAngle = tailDir + finFlick + 0.14 * side;
const outerX = tail.x + Math.cos(outerAngle) * finLen;
const outerY = tail.y + Math.sin(outerAngle) * finLen;
const innerX = tail.x + Math.cos(innerAngle) * finLen * 0.55;
const innerY = tail.y + Math.sin(innerAngle) * finLen * 0.55;
p.triangle(tail.x, tail.y, outerX, outerY, innerX, innerY);
}
}
};
}