住人のいる音量
@Lattice_Node
Components · Checkbox
388 views4 copies
<!-- UI by @Lattice_Node — ludion.ai/p/check-origami (MIT) -->
<div data-ld-part="check-origami" lang="ja">
<canvas class="scene" aria-hidden="true"></canvas>
<fieldset class="list">
<legend class="vh">やること</legend>
<label class="row">
<input class="box" type="checkbox">
<span class="spot" aria-hidden="true"></span>
<span class="text">牛乳を買う<span class="strike" aria-hidden="true"></span></span>
</label>
<label class="row">
<input class="box" type="checkbox">
<span class="spot" aria-hidden="true"></span>
<span class="text">メールを返す<span class="strike" aria-hidden="true"></span></span>
</label>
<label class="row">
<input class="box" type="checkbox">
<span class="spot" aria-hidden="true"></span>
<span class="text">散歩する<span class="strike" aria-hidden="true"></span></span>
</label>
</fieldset>
<span class="probe" aria-hidden="true"></span>
</div>
<style>
/* UI by @Lattice_Node — ludion.ai/p/check-origami (MIT) */
[data-ld-part="check-origami"] {
/* 固定しないトークン。机は --_bg、紙の表は --_surface、紙の裏は --_accent */
--_bg: var(--ld-bg, #2a3550);
--_surface: var(--ld-surface, #f3ecdd);
--_fg: var(--ld-fg, #f6f0e4);
--_muted: var(--ld-muted, #a3abbd);
--_accent: var(--ld-accent, #d8432c);
--_radius: var(--ld-radius, 6px);
--_font: var(--ld-font, "Hiragino Sans", "Yu Gothic UI", "Noto Sans JP", system-ui, sans-serif);
--_space: var(--ld-space, 8px);
--_shadow: var(--ld-shadow, 0 8px 24px rgb(12 16 28 / .3));
--_motion: var(--ld-motion, 200ms);
position: relative;
display: block;
box-sizing: border-box;
container-type: inline-size;
/* どの枠にも収まる */
width: min(100%, calc((100vh - 64px) * 1.6));
aspect-ratio: 1.6;
overflow: hidden;
border-radius: var(--_radius);
background: var(--_bg);
font-family: var(--_font);
color: var(--_fg);
}
[data-ld-part="check-origami"] .scene {
position: absolute;
top: 0;
left: 0;
display: block;
width: 100%;
height: 100%;
pointer-events: none;
}
/* 色を読むための見えない目印 */
[data-ld-part="check-origami"] .probe {
display: none;
}
[data-ld-part="check-origami"] .vh {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
[data-ld-part="check-origami"] .list {
position: absolute;
top: 0;
left: 0;
display: grid;
grid-template-rows: repeat(3, minmax(0, 1fr));
box-sizing: border-box;
width: 100%;
height: 100%;
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
[data-ld-part="check-origami"] .row {
position: relative;
display: grid;
/* 左の余白、紙、間、文字 */
grid-template-columns: 20% 20% 3% minmax(0, 1fr);
align-items: center;
min-height: 0;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
/* 本物のチェックボックス。絵は canvas が描き、ここは押す場所だけ */
[data-ld-part="check-origami"] .box {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: pointer;
}
/* 紙を置く場所の印。JS が中心を読む */
[data-ld-part="check-origami"] .spot {
grid-column: 2;
display: block;
height: 100%;
pointer-events: none;
}
[data-ld-part="check-origami"] .text {
grid-column: 4;
position: relative;
justify-self: start;
max-width: 100%;
font-size: 3.5cqw;
font-weight: 400;
line-height: 1.5;
letter-spacing: .08em;
pointer-events: none;
transition: color calc(var(--_motion) * 2) ease;
}
/* 文字に引く細い線 */
[data-ld-part="check-origami"] .strike {
position: absolute;
top: 54%;
right: -.18em;
left: -.18em;
height: max(1px, .045em);
background: currentColor;
transform: scaleX(0);
transform-origin: 0 50%;
transition: transform calc(var(--_motion) * 1.5) cubic-bezier(.3, 0, .2, 1);
}
[data-ld-part="check-origami"] .box:checked ~ .text {
color: color-mix(in oklab, var(--_fg) 72%, var(--_bg));
transition-delay: calc(var(--_motion) * 4);
}
/* 線は、鶴ができあがるころに引き終わる */
[data-ld-part="check-origami"] .box:checked ~ .text .strike {
transform: scaleX(1);
transition-delay: calc(var(--_motion) * 3.6);
}
[data-ld-part="check-origami"] .box:focus-visible ~ .text {
border-radius: var(--_radius);
outline: 2px solid var(--_fg);
outline-offset: calc(var(--_space) * .75);
}
@media (max-width: 640px) {
[data-ld-part="check-origami"] {
width: 100%;
aspect-ratio: .9;
}
[data-ld-part="check-origami"] .row {
grid-template-columns: 0 44% 0 minmax(0, 1fr);
}
[data-ld-part="check-origami"] .text {
font-size: 5.4cqw;
letter-spacing: .04em;
}
}
@media (prefers-reduced-motion: reduce) {
[data-ld-part="check-origami"] .text,
[data-ld-part="check-origami"] .strike {
transition: none;
}
[data-ld-part="check-origami"] .box:checked ~ .text,
[data-ld-part="check-origami"] .box:checked ~ .text .strike {
transition: none;
transition-delay: 0s;
}
}
</style>
<script type="module">
/* UI by @Lattice_Node — ludion.ai/p/check-origami (MIT) */
// part.js — ESモジュール。rootの中だけを触る
// 折り鶴のチェック:正方形の紙が、本物の手順で折れていき、鶴になる。
//
// 折り:紙を「折り目で区切った三角形の集まり」として持ち、手順ごとに、折り線で頂点を鏡映する。
// 三角折り → 小さな三角折り → つぶし折り(表、裏)→ 花弁折り(表、裏)→ 細く折る → 首と尾の中割り → 頭の中割り → 翼を下ろす。
// 途中:頂点を弧で動かしたあと、辺の長さを紙の長さへ戻す。紙は伸びない。起動時に全コマを焼いておき、動かすときは読むだけ。
// 絵: WebGL2。左上からの光の影(影の地図)、面ごとの明るさ、繊維の凹凸、折り目の跡は、すべて紙の座標に貼りつく。
// 色: JS に色を書かない。机は --_bg、紙の表は --_surface、紙の裏は --_accent から読む。
const S2 = Math.SQRT2;
const R2 = Math.SQRT1_2;
const HY = 1 - S2; // 花弁折りの蝶番の高さ
const PD = 2 - S2; // 中心から蝶番までの、本の折り目の上の距離
const TIP = -S2; // 下の角が集まる場所
const N11 = Math.tan(Math.PI / 16); // 22.5° の半分の傾き
const NECK_H = 0.27; // 蝶番から、首と尾の折り返しまで
const STAGES = 10;
const SUBFRAMES = 10;
const clamp = (v, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, v));
const mix = (a, b, t) => a + (b - a) * t;
const smooth = (a, b, x) => {
const t = clamp((x - a) / (b - a));
return t * t * (3 - 2 * t);
};
const rad = (deg) => (deg * Math.PI) / 180;
// 小さな折りの場所。下の4角が集まった形(正方基本形)
const prelim = (u, v) => {
if (u >= 0 && v <= 0) return [R2 * (u + v), R2 * (v - u)];
if (u <= 0 && v >= 0) return [R2 * (u + v), R2 * (u - v)];
if (u <= 0 && v <= 0) return [-R2 * Math.abs(u - v), R2 * (u + v)];
return [R2 * Math.abs(u - v), -R2 * (u + v)];
};
// ---------------------------------------------------------------- 折りの手順を、頂点の場所の列にする
function buildCrane() {
const V = [];
let F = [];
const arcs = [new Map()];
let cur = 0;
const EPS = 1e-6;
const addV = (u, v) => {
V.push({ u, v, p: [[R2 * (u - v), R2 * (u + v), 0]] });
return V.length - 1;
};
const O = addV(0, 0);
const ring = [[0, -1], [1, -1], [1, 0], [1, 1], [0, 1], [-1, 1], [-1, 0], [-1, -1]].map(([u, v]) => addV(u, v));
const [E, B, FF, C, G, D, H, A] = ring;
ring.forEach((a, i) => F.push({ v: [O, a, ring[(i + 1) % 8]], z: [0], tag: null }));
const stage = () => {
for (const q of V) q.p.push(q.p[cur].slice());
for (const f of F) f.z.push(f.z[cur]);
cur += 1;
arcs[cur] = new Map();
};
const centroid = (f, get) => (get(V[f.v[0]]) + get(V[f.v[1]]) + get(V[f.v[2]])) / 3;
const cu = (f) => centroid(f, (q) => q.u);
const cv = (f) => centroid(f, (q) => q.v);
const px = (f) => centroid(f, (q) => q.p[cur][0]);
const py = (f) => centroid(f, (q) => q.p[cur][1]);
const near = (u, v) => V.findIndex((q) => Math.abs(q.u - u) < 1e-5 && Math.abs(q.v - v) < 1e-5);
const has = (i) => (f) => f.v.includes(i);
const keyOf = (a, b) => (a < b ? a * 100000 + b : b * 100000 + a);
let cutBeforeMove = false; // 切ってから頂点を動かす手順では、その手順の間、切り口は動かない
// 折り線で三角形を切る。折り線の上に新しい頂点ができる。隣の三角形にも同じ頂点を入れて、すき間を作らない
const split = (select, ox, oy, dx, dy) => {
const cache = new Map();
const side = (i) => {
const q = V[i].p[cur];
const s = (q[0] - ox) * dy - (q[1] - oy) * dx;
return Math.abs(s) < EPS ? 0 : s;
};
const cut = (a, b, t) => {
const k = keyOf(a, b);
if (cache.has(k)) return cache.get(k);
const qa = V[a];
const qb = V[b];
V.push({
pa: a,
pb: b,
pt: t,
born: cur - (cutBeforeMove ? 1 : 0),
u: mix(qa.u, qb.u, t),
v: mix(qa.v, qb.v, t),
p: qa.p.map((q, i) => [mix(q[0], qb.p[i][0], t), mix(q[1], qb.p[i][1], t), mix(q[2], qb.p[i][2], t)]),
});
cache.set(k, V.length - 1);
return V.length - 1;
};
const out = [];
const fan = (poly, f) => {
for (let i = 1; i < poly.length - 1; i++) out.push({ v: [poly[0], poly[i], poly[i + 1]], z: f.z.slice(), tag: f.tag });
};
for (const f of F) {
if (!select(f)) {
out.push(f);
continue;
}
const s = f.v.map(side);
const pos = [];
const neg = [];
let crossed = false;
for (let i = 0; i < 3; i++) {
const j = (i + 1) % 3;
if (s[i] >= 0) pos.push(f.v[i]);
if (s[i] <= 0) neg.push(f.v[i]);
if (s[i] * s[j] < 0) {
const m = cut(f.v[i], f.v[j], s[i] / (s[i] - s[j]));
pos.push(m);
neg.push(m);
crossed = true;
}
}
if (!crossed) {
out.push(f);
continue;
}
fan(pos, f);
fan(neg, f);
}
// 切られなかった隣の三角形にも、辺の上の頂点を入れる
F = out;
for (let changed = true; changed; ) {
changed = false;
const next = [];
for (const f of F) {
let done = false;
for (let i = 0; i < 3 && !done; i++) {
const a = f.v[i];
const b = f.v[(i + 1) % 3];
const c = f.v[(i + 2) % 3];
const m = cache.get(keyOf(a, b));
if (m !== undefined && m !== c) {
next.push({ v: [a, m, c], z: f.z.slice(), tag: f.tag }, { v: [m, b, c], z: f.z.slice(), tag: f.tag });
done = true;
changed = true;
}
}
if (!done) next.push(f);
}
F = next;
}
};
const setXY = (i, x, y, how) => {
const q = V[i].p[cur];
if (Math.hypot(q[0] - x, q[1] - y) > 1e-6) arcs[cur].set(i, how);
q[0] = x;
q[1] = y;
};
const reflect = (i, ox, oy, dx, dy, how) => {
const q = V[i].p[cur];
const len = Math.hypot(dx, dy);
const ux = dx / len;
const uy = dy / len;
const t = (q[0] - ox) * ux + (q[1] - oy) * uy;
setXY(i, 2 * (ox + ux * t) - q[0], 2 * (oy + uy * t) - q[1], how);
};
// 1. 三角に折る
stage();
for (const f of F) if (py(f) > 0) f.z[cur] = 1;
V.forEach((q, i) => {
if (q.p[cur][1] > EPS) setXY(i, q.p[cur][0], -q.p[cur][1], { s: 1 });
});
// 2. もう一度、三角に折る
stage();
for (const f of F) if (px(f) < 0) f.z[cur] = 3 - f.z[cur];
V.forEach((q, i) => {
if (q.p[cur][0] < -EPS) setXY(i, -q.p[cur][0], q.p[cur][1], { s: 1 });
});
// 3. 表をつぶして、四角にする
stage();
for (const f of F) if (cu(f) < 0 && cv(f) > 0) f.z[cur] = 4;
V.forEach((q, i) => {
if ((q.u <= EPS && q.v >= -EPS) || (q.u < 0 && q.v < 0 && q.v >= q.u - EPS)) {
const [x, y] = prelim(q.u, q.v);
setXY(i, x, y, { s: 1 });
}
});
// 4. 裏をつぶす
stage();
for (const f of F) if (cu(f) > 0 && cv(f) < 0) f.z[cur] = -1;
V.forEach((q, i) => {
const [x, y] = prelim(q.u, q.v);
setXY(i, x, y, { s: -1 });
});
// 花弁折りの折り線を入れて、面に名前をつける
// 斜めを先に切ると、三角形のまま割れて、余分な頂点ができない
split((f) => px(f) < 0, 0, TIP, HY, 1);
split((f) => px(f) > 0, 0, TIP, -HY, 1);
split(() => true, 0, HY, 1, 0);
for (const f of F) {
const u = cu(f);
const v = cv(f);
const a = Math.abs(u);
const b = Math.abs(v);
const q = u < 0 ? (v < 0 ? 'A' : 'D') : v < 0 ? 'B' : 'C';
const region = a + b < PD ? 'top' : (a > PD && b < (a - PD) / (1 - PD)) || (b > PD && a < (b - PD) / (1 - PD)) ? 'out' : 'mid';
const front = q === 'D' || ((q === 'A' || q === 'C') && v > u);
f.tag = { q, region, g: front ? 1 : -1 };
}
const PH = near(-PD, 0);
const PE = near(0, -PD);
const QF = near(PD, 0);
const QG = near(0, PD);
// 5. 表の花弁折り
stage();
for (const f of F) {
if (f.tag.q === 'D' && f.tag.region !== 'top') f.z[cur] = f.tag.region === 'mid' ? 5 : 6; // 外側の三角は花弁の手前。奥へ回すと、紙が紙を貫く
if (f.tag.q === 'A' && f.tag.g > 0 && f.tag.region === 'out') f.z[cur] = 4;
if (f.tag.q === 'C' && f.tag.g > 0 && f.tag.region === 'out') f.z[cur] = 3;
}
setXY(D, 0, PD, { s: 1 });
setXY(H, 0, HY, { s: 1 });
setXY(G, 0, HY, { s: 1 });
// 6. 裏の花弁折り
stage();
for (const f of F) {
if (f.tag.q === 'B' && f.tag.region !== 'top') f.z[cur] = f.tag.region === 'mid' ? -2 : -3;
if (f.tag.q === 'A' && f.tag.g < 0 && f.tag.region === 'out') f.z[cur] = -1;
if (f.tag.q === 'C' && f.tag.g < 0 && f.tag.region === 'out') f.z[cur] = 0;
}
setXY(B, 0, PD, { s: -1 });
setXY(E, 0, HY, { s: -1 });
setXY(FF, 0, HY, { s: -1 });
// 7. 下の角を細く折る
stage();
cutBeforeMove = true;
const narrow = (vertex, tip, dx, pz, tuck, s) => {
split(has(vertex), 0, TIP, dx, 1);
for (const f of F) {
if (!f.v.includes(vertex)) continue;
f.z[cur] = f.v.includes(tip) ? 2 * pz - f.z[cur] : tuck;
}
reflect(vertex, 0, TIP, dx, 1, { s });
};
narrow(PH, A, -N11, 4.5, 4.5, 1);
narrow(PE, A, -N11, -1.5, -1.5, -1);
narrow(QG, C, N11, 3.5, 4.5, 1);
narrow(QF, C, N11, -0.5, -1.5, -1);
// 8. 首と尾を、中割りで起こす
stage();
const Yp = HY - NECK_H;
const neckFrom = V.length;
const reverse = (tip, ox, oy, deg, hi, lo) => {
const dx = Math.cos(rad(deg));
const dy = Math.sin(rad(deg));
split(has(tip), ox, oy, dx, dy);
for (const f of F) {
if (!f.v.includes(tip)) continue;
const z = f.z[cur];
f.z[cur] = f.tag.g > 0 ? 1.55 + 0.1 * (hi - z) : 1.45 - 0.1 * (z - lo);
}
reflect(tip, ox, oy, dx, dy, { pivot: [ox, oy] });
};
reverse(A, 0, Yp, 19, 6, -3);
reverse(C, 0, Yp, -16, 5, -2);
// 9. 頭を折る
stage();
{
const q = V[A].p[cur];
const len = Math.hypot(q[0], q[1] - Yp);
const ux = q[0] / len;
const uy = (q[1] - Yp) / len;
const ox = q[0] - ux * 0.2;
const oy = q[1] - uy * 0.2;
const neckDeg = (Math.atan2(uy, ux) * 180) / Math.PI;
const deg = (198 + neckDeg) / 2;
const dx = Math.cos(rad(deg));
const dy = Math.sin(rad(deg));
split(has(A), ox, oy, dx, dy);
for (const f of F) {
if (f.v.includes(A)) f.z[cur] = f.z[cur] > 1.5 ? 3.4 - f.z[cur] : 2.6 - f.z[cur];
}
reflect(A, ox, oy, dx, dy, { pivot: [ox, oy] });
}
// 紙の長さの表
const edgeMap = new Map();
F.forEach((f, fi) => {
for (let i = 0; i < 3; i++) {
const a = f.v[i];
const b = f.v[(i + 1) % 3];
const k = keyOf(a, b);
if (!edgeMap.has(k)) edgeMap.set(k, { a, b, faces: [] });
edgeMap.get(k).faces.push(fi);
}
});
const edges = [...edgeMap.values()];
const eA = new Int32Array(edges.map((e) => e.a));
const eB = new Int32Array(edges.map((e) => e.b));
const eL = new Float32Array(edges.map((e) => Math.hypot(V[e.a].u - V[e.b].u, V[e.a].v - V[e.b].v)));
const relax = (pos, rounds) => {
for (let r = 0; r < rounds; r++) {
for (let e = 0; e < eA.length; e++) {
const a = eA[e] * 3;
const b = eB[e] * 3;
const dx = pos[b] - pos[a];
const dy = pos[b + 1] - pos[a + 1];
const dz = pos[b + 2] - pos[a + 2];
const d = Math.sqrt(dx * dx + dy * dy + dz * dz) || 1e-6;
const k = (0.5 * (d - eL[e])) / d;
pos[a] += dx * k;
pos[a + 1] += dy * k;
pos[a + 2] += dz * k;
pos[b] -= dx * k;
pos[b + 1] -= dy * k;
pos[b + 2] -= dz * k;
}
}
};
// 10. 翼を下ろし、体をふくらませて、立体にする
stage();
const groupOf = (q) => {
const e = 1e-5;
const { u, v } = q;
if (Math.abs(u) < e && Math.abs(v) < e) return 0;
if (u > -e && v < e) return -1;
if (u < e && v > -e) return 1;
if (Math.abs(u - v) < e) return 0;
return v > u ? 1 : -1;
};
const wingOf = new Int8Array(V.length);
for (const f of F) {
if ((f.tag.q === 'D' || f.tag.q === 'B') && f.tag.region !== 'top') {
for (const i of f.v) wingOf[i] = f.tag.q === 'D' ? 1 : -1;
}
}
const WING = rad(62);
const rootZ = (x) => 0.03 + 0.14 * Math.max(0, 1 - Math.abs(x) / 0.22);
const flapW = new Float32Array(V.length);
V.forEach((q, i) => {
const p = q.p[cur];
const x = p[0];
const y = p[1] - HY;
const g = groupOf(q);
// 細く折ったときに折り込んだ角は、胴に沿わせる。翼と一緒に回すと、胴から面がはがれる
const tucked = i === PH || i === PE || i === QG || i === QF;
if (wingOf[i] !== 0 && y > 1e-3 && !tucked) {
p[1] = HY + y * Math.cos(WING);
p[2] = wingOf[i] * (rootZ(x) + y * Math.sin(WING));
flapW[i] = wingOf[i] * (0.65 + 0.35 * clamp(y));
} else if (i === A || i === C) {
p[2] = 0;
} else if (i >= neckFrom) {
p[2] = g * 0.016;
} else if (y >= 0) {
p[2] = g * rootZ(x) * (1 - clamp(y / 0.4142));
} else {
p[2] = g * (rootZ(x) * (1 - clamp(-y / NECK_H)) + 0.006);
}
});
{
const pos = new Float32Array(V.length * 3);
V.forEach((q, i) => pos.set(q.p[cur], i * 3));
relax(pos, 400);
V.forEach((q, i) => {
q.p[cur][0] = pos[i * 3];
q.p[cur][1] = pos[i * 3 + 1];
q.p[cur][2] = pos[i * 3 + 2];
});
// 翼の付け根:下の層(外側の三角)を、上の層(花弁)の真下へそろえる。突き抜けると白い裏が見える
const roles = new Map();
for (const f of F) {
if ((f.tag.q !== 'D' && f.tag.q !== 'B') || f.tag.region === 'top') continue;
for (const i of f.v) {
const r = roles.get(i) ?? { q: f.tag.q, mid: false, out: false };
if (f.tag.region === 'mid') r.mid = true;
else r.out = true;
roles.set(i, r);
}
}
const before = cur - 1;
for (const [i, r] of roles) {
if (!r.out || r.mid || Math.abs(V[i].p[before][1] - HY) > 1e-4) continue;
for (const [j, o] of roles) {
if (!o.mid || o.q !== r.q) continue;
const a = V[i].p[before];
const b = V[j].p[before];
if (Math.hypot(a[0] - b[0], a[1] - b[1]) > 1e-4) continue;
const t = V[j].p[cur];
V[i].p[cur] = [t[0], t[1] - 0.012, t[2]];
break;
}
}
// 折り込んだ角は、翼の付け根の下、胴の中へ入れる。翼の上に出ない
for (const i of [PH, PE, QG, QF]) {
const g = groupOf(V[i]);
V[i].p[cur] = [V[i].p[cur][0] * 0.4, HY - 0.085, g * 0.055];
}
for (const f of F) {
const tuck = [PH, PE, QG, QF].find((i) => f.v.includes(i));
if (tuck !== undefined && !f.v.includes(A) && !f.v.includes(C)) f.zFinal = groupOf(V[tuck]) > 0 ? 5.5 : -2.5;
}
}
// ---- 全コマを焼く
const nv = V.length;
const frames = new Float32Array((STAGES * SUBFRAMES + 1) * nv * 3);
const pos = new Float32Array(nv * 3);
for (let k = 1; k <= STAGES; k++) {
for (let n = k === 1 ? 0 : 1; n <= SUBFRAMES; n++) {
const t = n / SUBFRAMES;
for (let i = 0; i < nv; i++) {
// 後の手順で足した頂点は、生まれる前は親の2頂点の間にある。面が剛体のまま動く
if (V[i].born !== undefined && k <= V[i].born) {
const pa = V[i].pa * 3;
const pb = V[i].pb * 3;
const pt = V[i].pt;
pos[i * 3] = mix(pos[pa], pos[pb], pt);
pos[i * 3 + 1] = mix(pos[pa + 1], pos[pb + 1], pt);
pos[i * 3 + 2] = mix(pos[pa + 2], pos[pb + 2], pt);
continue;
}
const a = V[i].p[k - 1];
const b = V[i].p[k];
const how = arcs[k]?.get(i);
let x = mix(a[0], b[0], t);
let y = mix(a[1], b[1], t);
let z = mix(a[2], b[2], t);
if (how && how.s) {
// 折り線のまわりの弧:鏡映は、半回転と同じ
const cx = (a[0] + b[0]) / 2;
const cy = (a[1] + b[1]) / 2;
const c = Math.cos(Math.PI * t);
x = cx + (a[0] - cx) * c;
y = cy + (a[1] - cy) * c;
z = how.s * (Math.hypot(a[0] - b[0], a[1] - b[1]) / 2) * Math.sin(Math.PI * t);
} else if (how && how.pivot) {
const [ox, oy] = how.pivot;
const from = Math.atan2(a[1] - oy, a[0] - ox);
let delta = Math.atan2(b[1] - oy, b[0] - ox) - from;
while (delta > Math.PI) delta -= 2 * Math.PI;
while (delta < -Math.PI) delta += 2 * Math.PI;
const r = Math.hypot(a[0] - ox, a[1] - oy);
x = ox + r * Math.cos(from + delta * t);
y = oy + r * Math.sin(from + delta * t);
z = 0;
}
pos[i * 3] = x;
pos[i * 3 + 1] = y;
pos[i * 3 + 2] = z;
}
if (n > 0 && n < SUBFRAMES) relax(pos, 22);
frames.set(pos, ((k - 1) * SUBFRAMES + n) * nv * 3);
}
}
// ---- 面の情報
const nf = F.length;
const tri = new Int32Array(nf * 3);
F.forEach((f, i) => tri.set(f.v, i * 3));
const uv = new Float32Array(nv * 2);
V.forEach((q, i) => {
uv[i * 2] = q.u;
uv[i * 2 + 1] = q.v;
});
const orient = (f, k) => {
const [a, b, c] = f.v.map((i) => V[i].p[k]);
return Math.sign((b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])) || 1;
};
// 層の厚み:面の法線の向きに、どれだけずらすか
const layer = new Float32Array(nf * (STAGES + 1));
for (let k = 0; k <= STAGES; k++) {
const src = Math.min(k, STAGES - 1);
let lo = Infinity;
let hi = -Infinity;
for (const f of F) {
lo = Math.min(lo, f.z[src]);
hi = Math.max(hi, f.z[src]);
}
const mid = (lo + hi) / 2;
F.forEach((f, i) => {
const z = k === STAGES && f.zFinal !== undefined ? f.zFinal : f.z[src];
layer[i * (STAGES + 1) + k] = (z - mid) * orient(f, src);
});
}
// 同じ層でつながる面は、ずらし量を共有する。面の間に割れができない
const groups = new Map();
const corner = new Int32Array(nf * 3);
F.forEach((f, i) => {
const sig = f.z.join(',');
f.v.forEach((vertex, c) => {
const key = vertex + '|' + sig;
if (!groups.has(key)) groups.set(key, groups.size);
corner[i * 3 + c] = groups.get(key);
});
});
const groupCount = new Float32Array(groups.size);
corner.forEach((g) => {
groupCount[g] += 1;
});
// 折り終わると、白い面はすべて内側に入る。すき間から白がのぞいてうるさくならないよう、薄い染めの色に寄せる
const hide = new Float32Array(nf).fill(0.86);
// 折り目:向きが変わる辺だけ。三角形に割っただけの辺は、紙の上に跡を残さない
const creases = [];
for (const e of edges) {
if (e.faces.length < 2) continue;
const [f, g] = [F[e.faces[0]], F[e.faces[1]]];
let folded = false;
for (let k = 1; k < STAGES && !folded; k++) folded = orient(f, k) !== orient(g, k);
if (folded) creases.push([V[e.a].u, V[e.a].v, V[e.b].u, V[e.b].v, (e.a * 7 + e.b * 13) % 2 ? 1 : -1]);
}
// ほどいた紙に残るわずかな凹凸
const crumple = new Float32Array(nv);
const curl = new Float32Array(nv);
V.forEach((q, i) => {
const h = Math.sin(q.u * 127.1 + q.v * 311.7) * 43758.5453;
crumple[i] = (h - Math.floor(h) - 0.5) * 0.02;
curl[i] = Math.pow(Math.max(Math.abs(q.u), Math.abs(q.v)), 7) * 0.035;
});
return { nv, nf, tri, uv, frames, layer, creases, crumple, curl, flapW, corner, groupCount, hide };
}
// ---------------------------------------------------------------- シェーダー
const HEAD = `#version 300 es
precision highp float;
precision highp sampler2D;
precision highp sampler2DShadow;
`;
const VS_SCREEN = `${HEAD}
out vec2 vUV;
void main() {
vec2 p = vec2(gl_VertexID == 1 ? 3. : -1., gl_VertexID == 2 ? 3. : -1.);
vUV = p * .5 + .5;
gl_Position = vec4(p, 0., 1.);
}`;
// 和紙の素:R 細かな凹凸、G 粒、B 大きなむら、A 繊維(あとから線で描き足す)
const FS_PULP = `${HEAD}
in vec2 vUV;
out vec4 o;
float hash(vec2 p) {
vec3 q = fract(vec3(p.xyx) * .1031);
q += dot(q, q.yzx + 33.33);
return fract((q.x + q.y) * q.z);
}
float noise(vec2 p) {
vec2 i = floor(p), f = fract(p);
f = f * f * (3. - 2. * f);
return mix(mix(hash(i), hash(i + vec2(1, 0)), f.x), mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), f.x), f.y);
}
float fbm(vec2 p, int n) {
float a = .5, s = 0.;
for (int i = 0; i < n; i++) {
s += a * noise(p);
p = mat2(1.62, 1.18, -1.18, 1.62) * p + 11.3;
a *= .5;
}
return s;
}
void main() {
vec2 p = vUV * 2048.;
float pulp = fbm(p / 26., 5);
float pores = fbm(p / 5.5, 3);
float grain = hash(p);
float cloud = fbm(vUV * 9., 5);
o = vec4(pulp * .34 + pores * .22, grain * .5 + pores * .5, cloud, 0.);
}`;
const VS_FIBER = `${HEAD}
layout(location = 0) in vec2 aP;
void main() { gl_Position = vec4(aP * 2. - 1., 0., 1.); }`;
const FS_FIBER = `${HEAD}
uniform float uK;
out vec4 o;
void main() { o = vec4(uK * .5, 0., 0., uK); }`;
const VS_CREASE = `${HEAD}
layout(location = 0) in vec3 aP;
out float vS;
void main() { vS = aP.z; gl_Position = vec4(aP.xy, 0., 1.); }`;
const FS_CREASE = `${HEAD}
in float vS;
uniform vec2 uSign;
out vec4 o;
void main() {
float s = 1. - abs(vS);
o = vec4(uSign * s * s * (3. - 2. * s), 0., 1.);
}`;
const VS_DEPTH = `${HEAD}
layout(location = 0) in vec3 aP;
uniform mat4 uLight;
void main() { gl_Position = uLight * vec4(aP, 1.); }`;
const FS_DEPTH = `${HEAD}
void main() {}`;
// 影:遮るものの高さでぼけ幅を変える。地面に触れるところは鋭く、離れるほどやわらかい
const SHADOW = `
uniform sampler2D uShadow;
uniform sampler2DShadow uShadowCmp;
uniform mat4 uLight;
uniform float uPenK;
uniform float uTexel;
float ign(vec2 p) { return fract(52.9829189 * fract(dot(p, vec2(.06711056, .00583715)))); }
const vec2 POI[16] = vec2[](
vec2(-.942, -.399), vec2(.946, -.769), vec2(-.094, -.929), vec2(.345, .294),
vec2(-.916, .458), vec2(-.815, -.879), vec2(-.383, .277), vec2(.975, .756),
vec2(.443, -.975), vec2(.537, -.474), vec2(-.265, -.419), vec2(.792, .191),
vec2(-.241, .997), vec2(-.814, .914), vec2(.199, .786), vec2(.144, -.141));
float shadowAt(vec3 P, float search, out float reach) {
vec3 s = (uLight * vec4(P, 1.)).xyz * .5 + .5;
reach = 0.;
if (s.x < 0. || s.x > 1. || s.y < 0. || s.y > 1.) return 1.;
float a = ign(gl_FragCoord.xy) * 6.2832;
float ca = cos(a), sa = sin(a);
mat2 r = mat2(ca, sa, -sa, ca);
float zb = 0., nb = 0.;
for (int i = 0; i < 16; i += 2) {
float d = texture(uShadow, s.xy + r * POI[i] * search).r;
if (d < s.z) { zb += d; nb += 1.; }
}
if (nb < .5) return 1.;
zb /= nb;
reach = s.z - zb;
float pen = clamp(reach * uPenK, uTexel * 1.4, search);
float sum = 0.;
for (int i = 0; i < 16; i++) {
sum += texture(uShadowCmp, vec3(s.xy + r * POI[i] * pen, s.z));
}
return sum / 16.;
}
vec3 display(vec3 c, vec2 frag) {
// 明るさだけを抑える。色相を保つので、朱が桃色に飛ばない
float m = max(c.r, max(c.g, c.b));
if (m > .78) c *= (.78 + (m - .78) / (1. + (m - .78) * 2.4)) / m;
c = pow(clamp(c, 0., 1.), vec3(1. / 2.2));
return c + (ign(frag + 17.) - .5) / 255.;
}`;
const VS_DESK = `${HEAD}
layout(location = 0) in vec3 aP;
uniform mat4 uVP;
out vec3 vP;
void main() { vP = aP; gl_Position = uVP * vec4(aP, 1.); }`;
const FS_DESK = `${HEAD}
in vec3 vP;
uniform sampler2D uTex;
uniform vec3 uBg, uL, uEye;
uniform vec3 uSheets[3];
uniform vec4 uPool;
out vec4 o;
${SHADOW}
void main() {
vec2 q = vP.xz * .17 + vec2(.37, .61);
vec4 t = texture(uTex, q);
t.b = texture(uTex, vP.xz * .06 + vec2(.5)).b;
float e = 1. / 1024.;
float hx = texture(uTex, q + vec2(e, 0.)).r - texture(uTex, q - vec2(e, 0.)).r;
float hy = texture(uTex, q + vec2(0., e)).r - texture(uTex, q - vec2(0., e)).r;
vec3 N = normalize(vec3(-hx * .9, 1., -hy * .9));
float sh = 1., ao = 1.;
for (int i = 0; i < 3; i++) {
if (distance(vP.xz, uSheets[i].xy) < uSheets[i].z) {
float reach;
sh = shadowAt(vP, uTexel * 22., reach);
float wide = 0.;
vec3 s = (uLight * vec4(vP, 1.)).xyz * .5 + .5;
float a = ign(gl_FragCoord.yx) * 6.2832;
mat2 r = mat2(cos(a), sin(a), -sin(a), cos(a));
for (int k = 1; k < 16; k += 2) wide += texture(uShadow, s.xy + r * POI[k] * uTexel * 62.).r < s.z ? 1. : 0.;
ao = 1. - wide / 8. * .42;
break;
}
}
// 左上から差す光のたまり
float d = distance(gl_FragCoord.xy / uPool.xy, vec2(.12, .96));
float pool = mix(1.2, .66, smoothstep(.05, 1.25, d));
float ndl = max(dot(N, uL), 0.);
vec3 albedo = uBg * (.93 + .14 * t.b) * (1. + .1 * (t.g - .5)) * (1. + .22 * t.a);
vec3 c = albedo * pool * (.48 * ao + .65 * ndl * sh);
o = vec4(display(c, gl_FragCoord.xy), 1.);
}`;
const VS_PAPER = `${HEAD}
layout(location = 0) in vec3 aP;
layout(location = 1) in vec3 aN;
layout(location = 2) in vec2 aUV;
layout(location = 3) in float aHide;
uniform mat4 uVP;
out vec3 vP;
out vec3 vN;
out vec2 vUV;
out float vHide;
void main() {
vHide = aHide;
vP = aP;
vN = aN;
vUV = aUV;
gl_Position = uVP * vec4(aP, 1.);
}`;
const FS_PAPER = `${HEAD}
in vec3 vP;
in vec3 vN;
in vec2 vUV;
in float vHide;
uniform float uHideK;
uniform sampler2D uTex, uCrease;
uniform vec3 uFront, uBack, uL, uEye;
uniform vec3 uOff;
uniform float uMem;
uniform vec4 uPool;
out vec4 o;
${SHADOW}
float crease(vec2 p) {
vec4 c = texture(uCrease, p);
return c.r - c.g;
}
void main() {
bool front = gl_FrontFacing;
float side = front ? 1. : -1.;
vec3 N = normalize(vN) * side;
float ca = cos(uOff.z), sa = sin(uOff.z);
vec2 tuv = mat2(ca, sa, -sa, ca) * vUV * .3 + uOff.xy;
vec4 t = texture(uTex, tuv);
float e = 1. / 1100.;
float hx = texture(uTex, tuv + vec2(e, 0.)).r - texture(uTex, tuv - vec2(e, 0.)).r;
float hy = texture(uTex, tuv + vec2(0., e)).r - texture(uTex, tuv - vec2(0., e)).r;
vec2 fib = mat2(ca, -sa, sa, ca) * vec2(hx, hy);
// 折り目の跡。紙の座標に残る
vec2 cuv = vUV * .5 + .5;
float ce = 1.6 / 2048.;
vec4 c0 = texture(uCrease, cuv);
float ridge = max(c0.r, c0.g);
vec2 cg = vec2(crease(cuv + vec2(ce, 0.)) - crease(cuv - vec2(ce, 0.)), crease(cuv + vec2(0., ce)) - crease(cuv - vec2(0., ce)));
vec3 dp1 = dFdx(vP), dp2 = dFdy(vP);
vec2 d1 = dFdx(vUV), d2 = dFdy(vUV);
vec3 T = dp1 * d2.y - dp2 * d1.y;
vec3 B = dp2 * d1.x - dp1 * d2.x;
float lt = length(T), lb = length(B);
if (lt > 1e-9 && lb > 1e-9) {
vec2 g = fib * 2.2 + cg * .62 * uMem;
N = normalize(N - (T / lt * g.x + B / lb * g.y) * side);
}
// 面の色:表は生成りの紙、裏は染めた紙。染め残りの繊維が白く浮く
vec3 dyed = mix(uBack, uFront, t.a * .24 + .012) * (.88 + .18 * t.b);
vec3 albedo = front ? mix(uFront * (.965 + .07 * t.b) * (1. - .1 * t.a), dyed, vHide * uHideK) : dyed;
albedo *= 1. + .07 * (t.g - .5);
albedo *= 1. - .085 * uMem * ridge * (1. + .9 * uHideK); // ほどくと薄く、折ると本物の折り筋に
// 紙の切り口は、芯の色
float edge = smoothstep(.9955, .9995, max(abs(vUV.x), abs(vUV.y)));
albedo = mix(albedo, uFront * 1.04, edge * .85 * (1. - .8 * uHideK));
float reach;
float sh = shadowAt(vP + uL * .012, uTexel * 12., reach);
float ndl = dot(N, uL);
float d = distance(gl_FragCoord.xy / uPool.xy, vec2(.12, .96));
float pool = mix(1.12, .82, smoothstep(.05, 1.25, d));
vec3 V = normalize(uEye - vP);
vec3 H = normalize(uL + V);
float sheen = pow(max(dot(N, H), 0.), 26.) * .03;
float sky = .5 + .5 * N.y;
// 薄い紙は、裏からの光を少し通す
vec3 through = mix(albedo, front ? uBack : uFront, .35) * max(-ndl, 0.) * .3 * sh;
vec3 c = pool * (albedo * (.48 * mix(.58, 1.08, sky) + .6 * max(ndl, 0.) * sh) + through + sheen * sh);
o = vec4(display(c, gl_FragCoord.xy), 1.);
}`;
// ---------------------------------------------------------------- 行列
const mul4 = (a, b) => {
const o = new Float32Array(16);
for (let c = 0; c < 4; c++) {
for (let r = 0; r < 4; r++) {
o[c * 4 + r] = a[r] * b[c * 4] + a[4 + r] * b[c * 4 + 1] + a[8 + r] * b[c * 4 + 2] + a[12 + r] * b[c * 4 + 3];
}
}
return o;
};
const norm3 = (v) => {
const l = Math.hypot(v[0], v[1], v[2]) || 1;
return [v[0] / l, v[1] / l, v[2] / l];
};
const cross3 = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
const dot3 = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
const lookAt = (eye, at, up) => {
const f = norm3([eye[0] - at[0], eye[1] - at[1], eye[2] - at[2]]);
const r = norm3(cross3(up, f));
const u = cross3(f, r);
return new Float32Array([r[0], u[0], f[0], 0, r[1], u[1], f[1], 0, r[2], u[2], f[2], 0, -dot3(r, eye), -dot3(u, eye), -dot3(f, eye), 1]);
};
const perspective = (fovy, aspect, near, far) => {
const f = 1 / Math.tan(fovy / 2);
return new Float32Array([f / aspect, 0, 0, 0, 0, f, 0, 0, 0, 0, (far + near) / (near - far), -1, 0, 0, (2 * far * near) / (near - far), 0]);
};
const ortho = (l, r, b, t, n, f) =>
new Float32Array([2 / (r - l), 0, 0, 0, 0, 2 / (t - b), 0, 0, 0, 0, -2 / (f - n), 0, -(r + l) / (r - l), -(t + b) / (t - b), -(f + n) / (f - n), 1]);
// ---------------------------------------------------------------- 時間
const FOLD_MS = [130, 110, 120, 120, 130, 130, 110, 140, 90, 120]; // 合わせて 1200ms
const FOLD_TOTAL = FOLD_MS.reduce((a, b) => a + b, 0) / 1000;
const UNFOLD_TOTAL = 1.05;
const FLAP_TOTAL = 0.62;
const stageAt = (t) => {
let from = 0;
for (let k = 0; k < STAGES; k++) {
const w = FOLD_MS[k] / (FOLD_TOTAL * 1000);
if (t <= from + w) {
const x = (t - from) / w;
return k + mix(x, x * x * (3 - 2 * x), 0.6);
}
from += w;
}
return STAGES;
};
const LIGHT = norm3([-0.5, 0.82, -0.28]);
const ELEV = rad(68); // 正方形が正方形に見える高さ
const FOV = rad(5.5); // 望遠。正方形が正方形に見えるように
const VIEW_H = (3 * 2) / 0.84; // 1行の高さの 84% に、紙の幅が入る
const FINAL_YAW = rad(142);
const FINAL_ROLL = rad(-20);
const FINAL_LEAN = rad(-38); // 画面の横軸まわりの傾き。カメラが高いぶん、鶴の横顔を見せる
const LAYER_EPS = 0.0034;
const STRIDE = 9;
// ---------------------------------------------------------------- 本体
function mount(root) {
const cleanups = [];
const on = (target, type, fn, options) => {
target.addEventListener(type, fn, options);
cleanups.push(() => target.removeEventListener(type, fn, options));
};
const canvas = root.querySelector('.scene');
const probe = root.querySelector('.probe');
const motionQuery = matchMedia('(prefers-reduced-motion: reduce)');
const schemeQuery = matchMedia('(prefers-color-scheme: dark)');
let calm = motionQuery.matches;
const crane = buildCrane();
const { nv, nf, tri, uv, frames, layer, creases, crumple, curl, flapW, corner, groupCount, hide } = crane;
const groupX = new Float32Array(groupCount.length);
const groupY = new Float32Array(groupCount.length);
const groupZ = new Float32Array(groupCount.length);
const faceN = new Float32Array(nf * 3);
const LAST = STAGES * SUBFRAMES;
// できあがりの鶴が机に触れる高さ
let footLift = 0;
let minX = Infinity;
let maxX = -Infinity;
for (let i = 0; i < nv; i++) {
const o = (LAST * nv + i) * 3;
minX = Math.min(minX, frames[o]);
maxX = Math.max(maxX, frames[o]);
}
const centerX = (minX + maxX) / 2;
// できあがりの鶴の絵の中心を、行の中心へ
const finalCenter = (() => {
const cy = Math.cos(FINAL_YAW);
const sy = Math.sin(FINAL_YAW);
const ct = Math.cos(FINAL_ROLL);
const st = Math.sin(FINAL_ROLL);
const cl = Math.cos(FINAL_LEAN);
const sl = Math.sin(FINAL_LEAN);
const toWorld = (x, y, z) => {
const ry = ct * y - st * z;
const rz = -sy * x + cy * st * y + cy * ct * z;
return [cy * x + sy * st * y + sy * ct * z, cl * ry - sl * rz, sl * ry + cl * rz];
};
for (let i = 0; i < nv; i++) {
const o = (LAST * nv + i) * 3;
footLift = Math.max(footLift, -toWorld(frames[o] - centerX, frames[o + 1] - HY, frames[o + 2])[1]);
}
let lu = Infinity;
let hu = -Infinity;
let lv = Infinity;
let hv = -Infinity;
for (let i = 0; i < nv; i++) {
const o = (LAST * nv + i) * 3;
const [wx, wy, wz] = toWorld(frames[o] - centerX, frames[o + 1] - HY, frames[o + 2]);
const v = (wy + footLift) * Math.cos(ELEV) - wz * Math.sin(ELEV);
lu = Math.min(lu, wx);
hu = Math.max(hu, wx);
lv = Math.min(lv, v);
hv = Math.max(hv, v);
}
return { dx: -(lu + hu) / 2, dz: (lv + hv) / 2 / Math.sin(ELEV) };
})();
const JITTER = [rad(-1.1), rad(0.8), rad(-0.5)];
const OFFSETS = [[0.34, 0.36, 0.5], [0.62, 0.41, 2.3], [0.44, 0.67, 4.1]];
const sheets = [...root.querySelectorAll('.row')].map((row, i) => {
const box = row.querySelector('.box');
return {
row,
box,
spot: row.querySelector('.spot'),
t: box.checked ? 1 : 0,
mem: box.checked ? 1 : 0,
flap: -1,
hover: 0,
hoverTo: 0,
jit: JITTER[i % 3],
off: OFFSETS[i % 3],
x: 0,
z: 0,
data: new Float32Array(nf * 3 * STRIDE),
world: new Float32Array(nv * 3),
dirty: true,
vao: null,
vbo: null,
};
});
// ---- 色をトークンから読む
const paint = document.createElement('canvas').getContext('2d', { willReadFrequently: true });
const tones = {};
const readTones = () => {
const style = getComputedStyle(root);
for (const name of ['bg', 'surface', 'accent']) {
probe.style.color = style.getPropertyValue(`--_${name}`).trim();
paint.clearRect(0, 0, 1, 1);
paint.fillStyle = getComputedStyle(probe).color;
paint.fillRect(0, 0, 1, 1);
const d = paint.getImageData(0, 0, 1, 1).data;
tones[name] = { srgb: [d[0], d[1], d[2]], linear: [d[0], d[1], d[2]].map((v) => Math.pow(v / 255, 2.2)) };
}
// 明るい机に明るい紙を置くと、紙が消える。紙を机より一段だけ沈める
const lum = (c) => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
const desk = lum(tones.bg.linear);
const sheet = lum(tones.surface.linear);
if (desk > 0.5 && sheet > desk - 0.24) {
const k = (desk - 0.24) / sheet;
tones.surface.linear = tones.surface.linear.map((v) => v * k);
tones.surface.srgb = tones.surface.srgb.map((v) => v * Math.pow(k, 1 / 2.2));
}
};
// ---- カメラ
let W = 1;
let H = 1;
let quality = 1;
let vp = new Float32Array(16);
let lightVP = new Float32Array(16);
let penK = 0;
let eye = [0, 0, 0];
let deskCorners = new Float32Array(12);
const camera = { tanX: 1, tanY: 1 };
const onDesk = (nx, ny) => {
const dir = [nx * camera.tanX, -Math.sin(ELEV) + ny * camera.tanY * Math.cos(ELEV), -Math.cos(ELEV) - ny * camera.tanY * Math.sin(ELEV)];
const k = -eye[1] / dir[1];
return [eye[0] + dir[0] * k, 0, eye[2] + dir[2] * k];
};
const measure = () => {
const box = root.getBoundingClientRect();
W = Math.max(1, box.width);
H = Math.max(1, box.height);
const aspect = W / H;
const dist = VIEW_H / (2 * Math.tan(FOV / 2));
eye = [0, dist * Math.sin(ELEV), dist * Math.cos(ELEV)];
camera.tanY = Math.tan(FOV / 2);
camera.tanX = camera.tanY * aspect;
vp = mul4(perspective(FOV, aspect, dist * 0.6, dist * 1.5), lookAt(eye, [0, 0, 0], [0, 1, 0]));
const corners = [onDesk(-1.15, -1.15), onDesk(1.15, -1.15), onDesk(1.15, 1.15), onDesk(-1.15, 1.15)];
deskCorners = new Float32Array(corners.flat());
for (const s of sheets) {
const r = s.spot.getBoundingClientRect();
const p = onDesk((((r.left + r.width / 2 - box.left) / W) * 2 - 1), 1 - ((r.top + r.height / 2 - box.top) / H) * 2);
s.x = p[0];
s.z = p[2];
s.dirty = true;
}
// 光から見た箱:どの紙の影も入るように
const cx = sheets.reduce((a, s) => a + s.x, 0) / sheets.length;
const cz = sheets.reduce((a, s) => a + s.z, 0) / sheets.length;
const lightView = lookAt([cx + LIGHT[0] * 30, LIGHT[1] * 30, cz + LIGHT[2] * 30], [cx, 0, cz], [0, 0, -1]);
let lo = [Infinity, Infinity, Infinity];
let hi = [-Infinity, -Infinity, -Infinity];
for (const s of sheets) {
for (const dx of [-2.4, 2.4]) {
for (const dz of [-2.4, 2.4]) {
for (const y of [-1.2, 2.4]) {
const wx = s.x + dx;
const wz = s.z + dz;
const v = [
lightView[0] * wx + lightView[4] * y + lightView[8] * wz + lightView[12],
lightView[1] * wx + lightView[5] * y + lightView[9] * wz + lightView[13],
lightView[2] * wx + lightView[6] * y + lightView[10] * wz + lightView[14],
];
lo = lo.map((a, k) => Math.min(a, v[k]));
hi = hi.map((a, k) => Math.max(a, v[k]));
}
}
}
}
const size = Math.max(hi[0] - lo[0], hi[1] - lo[1]);
const mx = (hi[0] + lo[0]) / 2;
const my = (hi[1] + lo[1]) / 2;
const near = -hi[2] - 1;
const far = -lo[2] + 1;
lightVP = mul4(ortho(mx - size / 2, mx + size / 2, my - size / 2, my + size / 2, near, far), lightView);
penK = ((far - near) * 0.075) / size;
resize();
};
const resize = () => {
const dpr = Math.min(2, devicePixelRatio || 1) * quality;
const w = Math.max(1, Math.round(W * dpr));
const h = Math.max(1, Math.round(H * dpr));
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
}
};
// ---- 1枚の紙の、今の形を作る
const fill = (s) => {
const sf = stageAt(s.t);
const fr = Math.min(sf * SUBFRAMES, LAST - 1e-4);
const f0 = Math.floor(fr);
const fa = fr - f0;
const A = f0 * nv * 3;
const B = (f0 + 1) * nv * 3;
const k = Math.min(Math.floor(sf), STAGES - 1);
const ka = sf - k;
const w0 = 1 - smooth(0, 0.16, sf);
const yaw = mix(rad(45) + s.jit, FINAL_YAW + s.jit * 2, smooth(0, 0.78, s.t));
const stand = smooth(0.62, 1, s.t);
const tilt = mix(-Math.PI / 2, FINAL_ROLL, stand);
const cy = Math.cos(yaw);
const sy = Math.sin(yaw);
const ct = Math.cos(tilt);
const st = Math.sin(tilt);
// Ry(yaw) * Rx(tilt)
const r = [cy, sy * st, sy * ct, 0, ct, -st, -sy, cy * st, cy * ct];
const lean = FINAL_LEAN * smooth(0.62, 1, s.t);
const cl = Math.cos(lean);
const sl = Math.sin(lean);
// Rx(lean) * r
const m = [r[0], r[1], r[2], cl * r[3] - sl * r[6], cl * r[4] - sl * r[7], cl * r[5] - sl * r[8], sl * r[3] + cl * r[6], sl * r[4] + cl * r[7], sl * r[5] + cl * r[8]];
const shift = smooth(0, 0.95, s.t);
const ox = centerX * shift;
const oy = HY * shift;
let lift = mix(0.006, footLift + 0.004, stand) + 0.34 * Math.pow(Math.sin(Math.PI * s.t), 1.4) + s.hover * 0.032;
let flapAngle = 0;
if (s.flap >= 0) {
const p = s.flap;
flapAngle = rad(22) * Math.sin(2 * Math.PI * p) * Math.pow(1 - p, 1.1);
lift += 0.035 * Math.sin(2 * Math.PI * p) * (1 - p);
}
const world = s.world;
for (let i = 0; i < nv; i++) {
const j = i * 3;
let x = mix(frames[A + j], frames[B + j], fa);
let y = mix(frames[A + j + 1], frames[B + j + 1], fa);
let z = mix(frames[A + j + 2], frames[B + j + 2], fa);
if (w0 > 0) z += w0 * (curl[i] + s.mem * crumple[i]);
if (flapAngle !== 0 && flapW[i] !== 0) {
const a = flapAngle * flapW[i];
const rootZ = Math.sign(flapW[i]) * 0.1;
const ry = y - HY;
const rz = z - rootZ;
const c = Math.cos(a);
const sn = Math.sin(a);
y = HY + ry * c + rz * sn;
z = rootZ + rz * c - ry * sn;
}
x -= ox;
y -= oy;
const wz = m[6] * x + m[7] * y + m[8] * z;
world[j] = m[0] * x + m[1] * y + m[2] * z + s.x + finalCenter.dx * stand;
world[j + 1] = m[3] * x + m[4] * y + m[5] * z + lift + s.hover * wz * 0.028;
world[j + 2] = wz + s.z + finalCenter.dz * stand;
}
const up = [m[2], m[5], m[8]];
const flat = w0 * (1 - 0.62 * s.mem);
const S1 = STAGES + 1;
const d = s.data;
s.hideK = smooth(7.2, 9.4, sf);
groupX.fill(0);
groupY.fill(0);
groupZ.fill(0);
for (let f = 0; f < nf; f++) {
const ia = tri[f * 3] * 3;
const ib = tri[f * 3 + 1] * 3;
const ic = tri[f * 3 + 2] * 3;
const ax = world[ib] - world[ia];
const ay = world[ib + 1] - world[ia + 1];
const az = world[ib + 2] - world[ia + 2];
const bx = world[ic] - world[ia];
const by = world[ic + 1] - world[ia + 1];
const bz = world[ic + 2] - world[ia + 2];
let nx = ay * bz - az * by;
let ny = az * bx - ax * bz;
let nz = ax * by - ay * bx;
const nl = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1;
nx /= nl;
ny /= nl;
nz /= nl;
const off = mix(layer[f * S1 + k], layer[f * S1 + Math.min(k + 1, STAGES)], ka) * LAYER_EPS;
faceN[f * 3] = nx;
faceN[f * 3 + 1] = ny;
faceN[f * 3 + 2] = nz;
for (let c = 0; c < 3; c++) {
const g = corner[f * 3 + c];
groupX[g] += nx * off;
groupY[g] += ny * off;
groupZ[g] += nz * off;
}
}
let w = 0;
for (let f = 0; f < nf; f++) {
const nx = faceN[f * 3];
const ny = faceN[f * 3 + 1];
const nz = faceN[f * 3 + 2];
let sx = mix(nx, up[0], flat);
let sy2 = mix(ny, up[1], flat);
let sz = mix(nz, up[2], flat);
const sl = Math.sqrt(sx * sx + sy2 * sy2 + sz * sz) || 1;
sx /= sl;
sy2 /= sl;
sz /= sl;
for (let c = 0; c < 3; c++) {
const iv = tri[f * 3 + c];
const g = corner[f * 3 + c];
const gc = groupCount[g];
d[w++] = world[iv * 3] + groupX[g] / gc;
d[w++] = world[iv * 3 + 1] + groupY[g] / gc;
d[w++] = world[iv * 3 + 2] + groupZ[g] / gc;
d[w++] = sx;
d[w++] = sy2;
d[w++] = sz;
d[w++] = uv[iv * 2];
d[w++] = uv[iv * 2 + 1];
d[w++] = hide[f];
}
}
};
// ---- WebGL
let gl = null;
let ctx2d = null;
let ready = false;
const res = {};
const shader = (type, source) => {
const sh = gl.createShader(type);
gl.shaderSource(sh, source);
gl.compileShader(sh);
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS) && !gl.isContextLost()) throw new Error(gl.getShaderInfoLog(sh) || 'shader');
return sh;
};
const program = (vs, fs) => {
const p = gl.createProgram();
const a = shader(gl.VERTEX_SHADER, vs);
const b = shader(gl.FRAGMENT_SHADER, fs);
gl.attachShader(p, a);
gl.attachShader(p, b);
gl.linkProgram(p);
gl.deleteShader(a);
gl.deleteShader(b);
if (!gl.getProgramParameter(p, gl.LINK_STATUS) && !gl.isContextLost()) throw new Error(gl.getProgramInfoLog(p) || 'link');
const cache = new Map();
return {
p,
u(name) {
if (!cache.has(name)) cache.set(name, gl.getUniformLocation(p, name));
return cache.get(name);
},
};
};
const rng = (seed) => {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
};
const makeTarget = (size) => {
const tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texStorage2D(gl.TEXTURE_2D, Math.floor(Math.log2(size)) + 1, gl.RGBA8, size, size);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.MIRRORED_REPEAT);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.MIRRORED_REPEAT);
const aniso = gl.getExtension('EXT_texture_filter_anisotropic');
if (aniso) gl.texParameterf(gl.TEXTURE_2D, aniso.TEXTURE_MAX_ANISOTROPY_EXT, Math.min(8, gl.getParameter(aniso.MAX_TEXTURE_MAX_ANISOTROPY_EXT)));
const fbo = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
gl.viewport(0, 0, size, size);
return { tex, fbo };
};
// 和紙を焼く:パルプのむらに、長い繊維を線で足す
const bakePaper = () => {
const size = 2048;
const { tex, fbo } = makeTarget(size);
const pulp = program(VS_SCREEN, FS_PULP);
gl.disable(gl.BLEND);
gl.useProgram(pulp.p);
gl.bindVertexArray(res.empty);
gl.drawArrays(gl.TRIANGLES, 0, 3);
gl.deleteProgram(pulp.p);
const fiber = program(VS_FIBER, FS_FIBER);
const random = rng(90127);
const groups = [[3400, 0.07], [1600, 0.14], [420, 0.3]];
const vbo = gl.createBuffer();
const vao = gl.createVertexArray();
gl.bindVertexArray(vao);
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
gl.useProgram(fiber.p);
gl.enable(gl.BLEND);
gl.blendFunc(gl.ONE, gl.ONE);
gl.colorMask(true, false, false, true);
for (const [count, strength] of groups) {
const SEG = 12;
const lines = new Float32Array(count * SEG * 4);
let w = 0;
for (let n = 0; n < count; n++) {
let x = random();
let y = random();
let a = random() * Math.PI * 2;
const bend = (random() - 0.5) * 0.5;
const step = (0.012 + random() * random() * 0.06) / SEG * 6;
for (let i = 0; i < SEG; i++) {
const nx = x + Math.cos(a) * step;
const ny = y + Math.sin(a) * step;
lines[w++] = x;
lines[w++] = y;
lines[w++] = nx;
lines[w++] = ny;
x = nx;
y = ny;
a += bend + (random() - 0.5) * 0.3;
}
}
gl.bufferData(gl.ARRAY_BUFFER, lines, gl.STATIC_DRAW);
gl.uniform1f(fiber.u('uK'), strength);
gl.drawArrays(gl.LINES, 0, w / 2);
}
gl.colorMask(true, true, true, true);
gl.disable(gl.BLEND);
gl.deleteBuffer(vbo);
gl.deleteVertexArray(vao);
gl.deleteProgram(fiber.p);
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.generateMipmap(gl.TEXTURE_2D);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.deleteFramebuffer(fbo);
res.paper = tex;
};
// 折り目の地図:紙の座標に、山折りを赤、谷折りを緑で
const bakeCreases = () => {
const size = 2048;
const { tex, fbo } = makeTarget(size);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
const line = program(VS_CREASE, FS_CREASE);
gl.useProgram(line.p);
gl.enable(gl.BLEND);
gl.blendEquation(gl.MAX);
gl.blendFunc(gl.ONE, gl.ONE);
const vbo = gl.createBuffer();
const vao = gl.createVertexArray();
gl.bindVertexArray(vao);
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0);
const width = 0.0085;
for (const sign of [1, -1]) {
const list = creases.filter((c) => c[4] === sign);
const q = new Float32Array(list.length * 18);
let w = 0;
for (const [x1, y1, x2, y2] of list) {
const l = Math.hypot(x2 - x1, y2 - y1) || 1;
const nx = (-(y2 - y1) / l) * width;
const ny = ((x2 - x1) / l) * width;
const quad = [[x1 + nx, y1 + ny, 1], [x1 - nx, y1 - ny, -1], [x2 + nx, y2 + ny, 1], [x2 + nx, y2 + ny, 1], [x1 - nx, y1 - ny, -1], [x2 - nx, y2 - ny, -1]];
for (const v of quad) {
q[w++] = v[0];
q[w++] = v[1];
q[w++] = v[2];
}
}
gl.bufferData(gl.ARRAY_BUFFER, q, gl.STATIC_DRAW);
gl.uniform2f(line.u('uSign'), sign > 0 ? 1 : 0, sign > 0 ? 0 : 1);
gl.drawArrays(gl.TRIANGLES, 0, w / 3);
}
gl.blendEquation(gl.FUNC_ADD);
gl.disable(gl.BLEND);
gl.deleteBuffer(vbo);
gl.deleteVertexArray(vao);
gl.deleteProgram(line.p);
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.generateMipmap(gl.TEXTURE_2D);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.deleteFramebuffer(fbo);
res.crease = tex;
};
const initGL = () => {
res.empty = gl.createVertexArray();
res.depth = program(VS_DEPTH, FS_DEPTH);
res.desk = program(VS_DESK, FS_DESK);
res.ink = program(VS_PAPER, FS_PAPER);
bakePaper();
bakeCreases();
// 影の地図
const big = Math.max(W, H) * Math.min(2, devicePixelRatio || 1) > 2200;
res.shadowSize = Math.min(big ? 4096 : 2048, gl.getParameter(gl.MAX_TEXTURE_SIZE));
res.shadow = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, res.shadow);
gl.texStorage2D(gl.TEXTURE_2D, 1, gl.DEPTH_COMPONENT24, res.shadowSize, res.shadowSize);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
res.shadowFbo = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, res.shadowFbo);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.TEXTURE_2D, res.shadow, 0);
gl.drawBuffers([gl.NONE]);
gl.readBuffer(gl.NONE);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
// 同じ影の地図を、深さとしても、なめらかな比較としても読む
res.compare = gl.createSampler();
gl.samplerParameteri(res.compare, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.samplerParameteri(res.compare, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.samplerParameteri(res.compare, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.samplerParameteri(res.compare, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.samplerParameteri(res.compare, gl.TEXTURE_COMPARE_MODE, gl.COMPARE_REF_TO_TEXTURE);
gl.samplerParameteri(res.compare, gl.TEXTURE_COMPARE_FUNC, gl.LEQUAL);
// 机の四角
res.deskVbo = gl.createBuffer();
res.deskVao = gl.createVertexArray();
gl.bindVertexArray(res.deskVao);
gl.bindBuffer(gl.ARRAY_BUFFER, res.deskVbo);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0);
for (const s of sheets) {
s.vbo = gl.createBuffer();
s.vao = gl.createVertexArray();
gl.bindVertexArray(s.vao);
gl.bindBuffer(gl.ARRAY_BUFFER, s.vbo);
gl.bufferData(gl.ARRAY_BUFFER, s.data.byteLength, gl.DYNAMIC_DRAW);
gl.enableVertexAttribArray(0);
gl.enableVertexAttribArray(1);
gl.enableVertexAttribArray(2);
gl.enableVertexAttribArray(3);
gl.vertexAttribPointer(3, 1, gl.FLOAT, false, STRIDE * 4, 32);
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, STRIDE * 4, 0);
gl.vertexAttribPointer(1, 3, gl.FLOAT, false, STRIDE * 4, 12);
gl.vertexAttribPointer(2, 2, gl.FLOAT, false, STRIDE * 4, 24);
s.dirty = true;
}
gl.bindVertexArray(null);
ready = true;
};
const shadowUniforms = (pr) => {
gl.uniform1i(pr.u('uShadow'), 2);
gl.uniform1i(pr.u('uShadowCmp'), 3);
gl.uniformMatrix4fv(pr.u('uLight'), false, lightVP);
gl.uniform1f(pr.u('uPenK'), penK);
gl.uniform1f(pr.u('uTexel'), 1 / 2048);
gl.uniform3fv(pr.u('uL'), LIGHT);
gl.uniform3fv(pr.u('uEye'), eye);
gl.uniform4f(pr.u('uPool'), canvas.width, canvas.height, 0, 0);
gl.uniformMatrix4fv(pr.u('uVP'), false, vp);
};
const drawGL = () => {
// 影
gl.bindFramebuffer(gl.FRAMEBUFFER, res.shadowFbo);
gl.viewport(0, 0, res.shadowSize, res.shadowSize);
gl.enable(gl.DEPTH_TEST);
gl.depthMask(true);
gl.clear(gl.DEPTH_BUFFER_BIT);
gl.enable(gl.POLYGON_OFFSET_FILL);
gl.polygonOffset(1.2, 1.6);
gl.useProgram(res.depth.p);
gl.uniformMatrix4fv(res.depth.u('uLight'), false, lightVP);
for (const s of sheets) {
gl.bindVertexArray(s.vao);
gl.drawArrays(gl.TRIANGLES, 0, nf * 3);
}
gl.disable(gl.POLYGON_OFFSET_FILL);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.viewport(0, 0, canvas.width, canvas.height);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, res.paper);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, res.crease);
gl.activeTexture(gl.TEXTURE2);
gl.bindTexture(gl.TEXTURE_2D, res.shadow);
gl.activeTexture(gl.TEXTURE3);
gl.bindTexture(gl.TEXTURE_2D, res.shadow);
gl.bindSampler(3, res.compare);
gl.activeTexture(gl.TEXTURE0);
// 机。深さを書かない。裏へ折る紙が、机に切られないように
gl.disable(gl.DEPTH_TEST);
gl.depthMask(false);
gl.useProgram(res.desk.p);
shadowUniforms(res.desk);
gl.uniform1i(res.desk.u('uTex'), 0);
gl.uniform3fv(res.desk.u('uBg'), tones.bg.linear);
const near = [];
for (const s of sheets) near.push(s.x, s.z, 2.7);
gl.uniform3fv(res.desk.u('uSheets'), near);
gl.bindVertexArray(res.deskVao);
gl.bindBuffer(gl.ARRAY_BUFFER, res.deskVbo);
gl.bufferData(gl.ARRAY_BUFFER, deskCorners, gl.DYNAMIC_DRAW);
gl.drawArrays(gl.TRIANGLE_FAN, 0, 4);
// 紙
gl.enable(gl.DEPTH_TEST);
gl.depthMask(true);
gl.clear(gl.DEPTH_BUFFER_BIT);
gl.useProgram(res.ink.p);
shadowUniforms(res.ink);
gl.uniform1i(res.ink.u('uTex'), 0);
gl.uniform1i(res.ink.u('uCrease'), 1);
gl.uniform3fv(res.ink.u('uFront'), tones.surface.linear);
gl.uniform3fv(res.ink.u('uBack'), tones.accent.linear);
for (const s of sheets) {
gl.uniform3fv(res.ink.u('uOff'), s.off);
gl.uniform1f(res.ink.u('uMem'), s.mem);
gl.uniform1f(res.ink.u('uHideK'), s.hideK ?? 0);
gl.bindVertexArray(s.vao);
gl.drawArrays(gl.TRIANGLES, 0, nf * 3);
}
gl.bindVertexArray(null);
};
// ---- WebGL が使えないときの、ごく簡単な絵
const project = (x, y, z) => {
const w = vp[3] * x + vp[7] * y + vp[11] * z + vp[15];
return [
(((vp[0] * x + vp[4] * y + vp[8] * z + vp[12]) / w) * 0.5 + 0.5) * canvas.width,
(0.5 - ((vp[1] * x + vp[5] * y + vp[9] * z + vp[13]) / w) * 0.5) * canvas.height,
w,
];
};
const toCss = (c, k, a = 1) => `rgb(${Math.round(c[0] * k)} ${Math.round(c[1] * k)} ${Math.round(c[2] * k)} / ${a})`;
const draw2D = () => {
if (!ctx2d) return;
ctx2d.clearRect(0, 0, canvas.width, canvas.height);
const faces = [];
const plain = [];
for (const s of sheets) {
// まだ折っていない紙は、1枚の四角。折り目の線を出さない
if (s.t === 0 && s.mem === 0) {
plain.push([2, 4, 6, 8].map((i) => project(s.world[i * 3], s.world[i * 3 + 1], s.world[i * 3 + 2])));
continue;
}
const d = s.data;
const k = s.hideK ?? 0;
for (let f = 0; f < nf; f++) {
const o = f * 3 * STRIDE;
faces.push({ d, o, k });
// 影
const ps = [0, 1, 2].map((c) => {
const x = d[o + c * STRIDE];
const y = d[o + c * STRIDE + 1];
const z = d[o + c * STRIDE + 2];
const k = y / LIGHT[1];
return project(x - LIGHT[0] * k, 0, z - LIGHT[2] * k);
});
ctx2d.fillStyle = toCss(tones.bg.srgb, 0.55);
ctx2d.beginPath();
ctx2d.moveTo(ps[0][0], ps[0][1]);
ctx2d.lineTo(ps[1][0], ps[1][1]);
ctx2d.lineTo(ps[2][0], ps[2][1]);
ctx2d.fill();
}
}
ctx2d.fillStyle = toCss(tones.surface.srgb, 1);
for (const q of plain) {
ctx2d.beginPath();
q.forEach((p, i) => (i ? ctx2d.lineTo(p[0], p[1]) : ctx2d.moveTo(p[0], p[1])));
ctx2d.fill();
}
const drawn = faces.map(({ d, o, k }) => {
const ps = [0, 1, 2].map((c) => project(d[o + c * STRIDE], d[o + c * STRIDE + 1], d[o + c * STRIDE + 2]));
return { d, o, k, ps, w: (ps[0][2] + ps[1][2] + ps[2][2]) / 3 };
});
drawn.sort((a, b) => b.w - a.w);
for (const { d, o, k, ps } of drawn) {
const area = (ps[1][0] - ps[0][0]) * (ps[2][1] - ps[0][1]) - (ps[1][1] - ps[0][1]) * (ps[2][0] - ps[0][0]);
const front = area < 0;
const side = front ? 1 : -1;
const ndl = Math.max(0, (d[o + 3] * LIGHT[0] + d[o + 4] * LIGHT[1] + d[o + 5] * LIGHT[2]) * side);
const inner = tones.surface.srgb.map((v, i) => mix(v, tones.accent.srgb[i], 0.86 * k));
ctx2d.fillStyle = toCss(front ? inner : tones.accent.srgb, 0.68 + 0.42 * ndl);
ctx2d.strokeStyle = ctx2d.fillStyle;
ctx2d.beginPath();
ctx2d.moveTo(ps[0][0], ps[0][1]);
ctx2d.lineTo(ps[1][0], ps[1][1]);
ctx2d.lineTo(ps[2][0], ps[2][1]);
ctx2d.closePath();
ctx2d.fill();
ctx2d.stroke();
}
};
// ---- 動かす
let raf = 0;
let visible = true;
let lastNow = 0;
let slowFrames = 0;
const wake = () => {
if (!raf && visible && !document.hidden) raf = requestAnimationFrame(frame);
};
const frame = (now) => {
raf = 0;
if (!visible || document.hidden) return;
const dt = lastNow ? Math.min(0.05, (now - lastNow) / 1000) : 0.016;
lastNow = now;
let busy = false;
for (const s of sheets) {
const goal = s.box.checked ? 1 : 0;
if (calm) {
if (s.t !== goal) {
s.t = goal;
s.dirty = true;
}
if (goal) s.mem = 1;
if (s.flap >= 0) {
s.flap = -1;
s.dirty = true;
}
if (s.hover !== 0) {
s.hover = 0;
s.dirty = true;
}
continue;
}
if (s.t !== goal) {
const step = dt / (goal > s.t ? FOLD_TOTAL : -UNFOLD_TOTAL);
s.t = goal > s.t ? Math.min(goal, s.t + step) : Math.max(goal, s.t + step);
if (s.t === 1) s.flap = 0;
s.dirty = true;
busy = true;
}
s.mem = Math.max(s.mem, smooth(0.06, 0.3, s.t));
if (goal === 0 && s.flap >= 0) s.flap = -1;
if (s.flap >= 0) {
s.flap += dt / FLAP_TOTAL;
if (s.flap >= 1) s.flap = -1;
s.dirty = true;
busy = true;
}
const dh = s.hoverTo - s.hover;
if (Math.abs(dh) > 0.002) {
s.hover += dh * (1 - Math.exp(-dt * 16));
s.dirty = true;
busy = true;
} else if (dh !== 0) {
s.hover = s.hoverTo;
s.dirty = true;
}
}
// 重いときだけ、解像度を落として 60fps を守る。止まったら、元の細かさで描き直す
if (busy) {
slowFrames = dt > 0.024 ? slowFrames + 1 : Math.max(0, slowFrames - 1);
if (slowFrames > 8 && quality > 0.5) {
quality = Math.max(0.5, quality * 0.8);
slowFrames = 0;
resize();
for (const s of sheets) s.dirty = true;
}
} else if (quality !== 1) {
quality = 1;
resize();
for (const s of sheets) s.dirty = true;
}
let changed = false;
for (const s of sheets) {
if (!s.dirty) continue;
fill(s);
if (ready) {
gl.bindBuffer(gl.ARRAY_BUFFER, s.vbo);
gl.bufferSubData(gl.ARRAY_BUFFER, 0, s.data);
}
s.dirty = false;
changed = true;
}
if (changed || frame.force) {
frame.force = false;
if (ready) drawGL();
else draw2D();
}
if (busy) wake();
else lastNow = 0;
};
// ---- 組み立て
readTones();
gl = canvas.getContext('webgl2', { alpha: false, antialias: true, depth: true, stencil: false, powerPreference: 'high-performance', preserveDrawingBuffer: false });
measure();
if (gl) {
try {
initGL();
} catch (error) {
ready = false;
console.warn(error);
}
} else {
ctx2d = canvas.getContext('2d');
}
const redraw = () => {
frame.force = true;
for (const s of sheets) s.dirty = true;
wake();
};
for (const s of sheets) {
on(s.box, 'change', () => wake());
const lift = () => {
s.hoverTo = 1;
wake();
};
const rest = () => {
if (root.contains(s.box) && s.row.matches(':hover')) return;
if (s.box.matches(':focus-visible')) return;
s.hoverTo = 0;
wake();
};
on(s.row, 'pointerenter', lift);
on(s.row, 'pointerleave', () => {
s.hoverTo = s.box.matches(':focus-visible') ? 1 : 0;
wake();
});
on(s.box, 'focus', () => {
if (s.box.matches(':focus-visible')) lift();
});
on(s.box, 'blur', rest);
}
on(motionQuery, 'change', () => {
calm = motionQuery.matches;
redraw();
});
on(schemeQuery, 'change', () => {
readTones();
redraw();
});
on(document, 'visibilitychange', () => {
if (document.hidden) {
if (raf) cancelAnimationFrame(raf);
raf = 0;
lastNow = 0;
} else {
redraw();
}
});
on(canvas, 'webglcontextlost', (event) => {
event.preventDefault();
ready = false;
});
on(canvas, 'webglcontextrestored', () => {
initGL();
redraw();
});
const io = new IntersectionObserver((entries) => {
visible = entries.some((e) => e.isIntersecting);
if (visible) redraw();
else if (raf) {
cancelAnimationFrame(raf);
raf = 0;
lastNow = 0;
}
});
io.observe(root);
const ro = new ResizeObserver(() => {
measure();
redraw();
});
ro.observe(root);
redraw();
return () => {
if (raf) cancelAnimationFrame(raf);
raf = 0;
visible = false;
io.disconnect();
ro.disconnect();
for (const off of cleanups) off();
if (gl && ready) {
for (const s of sheets) {
gl.deleteBuffer(s.vbo);
gl.deleteVertexArray(s.vao);
}
gl.deleteBuffer(res.deskVbo);
gl.deleteVertexArray(res.deskVao);
gl.deleteVertexArray(res.empty);
gl.deleteProgram(res.depth.p);
gl.deleteProgram(res.desk.p);
gl.deleteProgram(res.ink.p);
gl.deleteTexture(res.paper);
gl.deleteTexture(res.crease);
gl.deleteTexture(res.shadow);
gl.deleteFramebuffer(res.shadowFbo);
gl.deleteSampler(res.compare);
const lose = gl.getExtension('WEBGL_lose_context');
if (lose) lose.loseContext();
}
ready = false;
};
}
mount(document.querySelector('[data-ld-part="check-origami"]'));
</script>
@Lattice_Node
@Lattice_Node
@Lattice_Node
@Lattice_Node
@Lattice_Node
@Lattice_Node
@Lattice_Node
@Lattice_Node
@Lattice_Node
@Lattice_Node
@Lattice_Node
@Lattice_Node
@Lattice_Node
@Lattice_Node
@Lattice_Node
@Lattice_Node
@Lattice_Node
@Lattice_Node