デモ
デモを読み込めませんでした。
仕様
- 用途
- ヒーロー
- 動作
- DOM+WebGL
- 種類
- シーン
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
黒い棒に上の辺を留めた布が、強弱のある風にふくらんだりはためいたりして、後ろの壁に影を落とします。カーソルを重ねると、そこだけ指で押したように奥へへこみ、離せばまた風に押し戻されます。
布の正体は、質点を縦横に並べた格子です。縦横と斜め、それに1つ飛ばしの3種類のばねでつなぎ、Verlet 積分を細かい固定の刻みで進めています。動かさないのは上の1列だけ。物理エンジンは使わず、three.js で描く頂点をそのまま質点として扱っているので、位置を書き換えて法線を作り直すだけで陰影まで付いてきます。
風の力は三角形ごとに計算しています。使うのは、風と布の速さの差のうち面に垂直な成分だけ。正面から風を受ける所はふくらみ、横を向いた所は風を切るので、布全体が波打つようにはためきます。風の強さは値ノイズで場所と時間ごとに変え、その模様を横へ流すと、強い風のかたまりが布の左から右へ渡っていくように見えます。
カーソルは Raycaster で画面の奥へ向かうレイに変え、レイが布を通っている間、そのまわりの円柱に入った質点へ奥向きの力を加えます。質点を指先の形どおりに直接動かせば話は早いのですが、そうすると風でふくらんだ布ほど深くえぐれて、穴が開いたような形になります。力として加えれば、布の張りや風と釣り合った深さで止まります。触れ始めは0.4秒かけて力を強め、布の端を横切ったときに端がめくれ上がらないようにしました。
しわの出方は、1つ飛ばしのばねの硬さで決まります。0.8より下げると薄い布のように細かいしわが増え、上げると厚手の布のように大きくうねります。布はいつまでも動き続けるので、公開しているコードでは右下に一時停止のボタンを置きました。
コード
<section class="cloth-hero">
<!-- 布は飾りなので、読み上げからは外す -->
<canvas class="cloth-hero__canvas" aria-hidden="true"></canvas>
<!-- 布は動き続けるので、止められるようにする -->
<button class="cloth-hero__toggle" type="button" aria-pressed="false">一時停止</button>
</section>
.cloth-hero {
/* 布・壁・棒の色はここで決める。script.js がこの 3 つを読んでシーンへ渡す */
--cloth: #c97369; /* 布(ローズカラー。光が当たると少し明るく見える) */
--wall: #e4e3df; /* 影を受ける壁(無彩色) */
--rod: #1c1c1e; /* 布を留める黒い金属の棒 */
--ink: #262626;
position: relative;
min-height: min(90vh, 720px);
overflow: hidden;
/* 読み込み中と、WebGL が使えないときの背景 */
background-color: var(--wall);
}
.cloth-hero__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
touch-action: pan-y; /* タッチ端末では縦のスクロールを優先する */
}
.cloth-hero__toggle {
position: absolute;
right: 24px;
bottom: 24px;
height: 40px;
padding: 0 20px;
border: 0;
border-radius: 999px;
box-shadow: inset 0 0 0 1px var(--ink);
background: transparent;
color: var(--ink);
font: inherit;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.04em;
cursor: pointer;
}
.cloth-hero__toggle[aria-pressed="true"] { background: var(--ink); color: var(--wall); }
.cloth-hero__toggle:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
/* 動きを減らす設定では布を止めて描くので、止めるボタンは要らない */
@media (prefers-reduced-motion: reduce) {
.cloth-hero__toggle { display: none; }
}
import * as THREE from "three";
const COLS = 36, ROWS = 26, SPACING = 0.16, TOP = 2.05, SHIFT = 0.55; // 質点の数と間隔(布の幅 5.6・高さ 4.0)、留める辺の高さ、布の中心の横位置
const STEP = 1 / 120, ITERATIONS = 8, GRAVITY = 9.8, DRAG = 0.008, LOOP = 8; // 物理の固定ステップ(秒)、ばねを解き直す回数、重力、空気の抵抗、風の強弱がひと回りする長さ(秒)
// 風。speed: 平均の速さ / calm: いちばん弱いときの割合 / push: 布が風を受ける強さ / sway: 向きを左右に振る量 / scale・cells・evolve: ノイズの細かさ・流れる速さ・形の変わる速さ
const WIND = { speed: 9, calm: 0.15, push: 1.2, dir: new THREE.Vector3(0.55, 0.05, 1).normalize().toArray(), sway: 0.35, scale: 0.6, cells: 12, evolve: 3 };
const FINGER = { radius: 0.85, force: 80, in: 0.4, out: 0.15 }; // 指先の太さ・奥へ押す加速度・触れ始めに強めていく秒数・離れて弱める秒数
const WALL_Z = -1.5, COUNT = COLS * ROWS, N = COUNT * 3, reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const lattice = Float32Array.from({ length: WIND.cells * 8 * WIND.evolve }, Math.random), wrap = (v, n) => ((v % n) + n) % n, ease = (v) => v * v * (3 - 2 * v), mix = (a, b, f) => a + (b - a) * f;
function noise(x, y, z) { // 値ノイズ(0〜1)。格子の値をなめらかにつなぐ
const i = Math.floor(x), j = Math.floor(y), k = Math.floor(z), fx = ease(x - i), fy = ease(y - j), fz = ease(z - k);
const at = (a, b, c) => lattice[wrap(c, WIND.evolve) * WIND.cells * 8 + wrap(b, 8) * WIND.cells + wrap(a, WIND.cells)];
const plane = (c) => mix(mix(at(i, j, c), at(i + 1, j, c), fx), mix(at(i, j + 1, c), at(i + 1, j + 1, c), fx), fy);
return mix(plane(k), plane(k + 1), fz);
}
// 三角形 1 枚が受ける風。風と布の速さの差のうち、面の法線の向きの成分だけを力にする(面が風を正面から受けるほど強い)
function windOn(pos, prev, force, a, b, d, phase) {
a *= 3; b *= 3; d *= 3;
const ex = pos[b] - pos[a], ey = pos[b + 1] - pos[a + 1], ez = pos[b + 2] - pos[a + 2], fx = pos[d] - pos[a], fy = pos[d + 1] - pos[a + 1], fz = pos[d + 2] - pos[a + 2];
const nx = ey * fz - ez * fy, ny = ez * fx - ex * fz, nz = ex * fy - ey * fx, length = Math.hypot(nx, ny, nz) || 1e-6;
const gust = noise(((pos[a] + pos[b] + pos[d]) / 3) * WIND.scale - phase * WIND.cells, ((pos[a + 1] + pos[b + 1] + pos[d + 1]) / 3) * WIND.scale, phase * WIND.evolve);
const speed = WIND.speed * (WIND.calm + (1 - WIND.calm) * 2 * gust), sway = WIND.sway * Math.sin(Math.PI * 2 * (phase * 2 + 0.15));
const v = (i) => (pos[a + i] - prev[a + i] + pos[b + i] - prev[b + i] + pos[d + i] - prev[d + i]) / (3 * STEP); // 三角形の速さ
const rel = ((WIND.dir[0] + sway) * speed - v(0)) * nx + (WIND.dir[1] * speed - v(1)) * ny + (WIND.dir[2] * speed - v(2)) * nz;
const s = (WIND.push * rel) / (6 * length * SPACING * SPACING); // 面積を 3 点に分け、質点 1 つ分の面積で割って加速度にする
force[a] += nx * s; force[a + 1] += ny * s; force[a + 2] += nz * s; force[b] += nx * s; force[b + 1] += ny * s; force[b + 2] += nz * s; force[d] += nx * s; force[d + 1] += ny * s; force[d + 2] += nz * s;
}
const pairs = [], rest = [], stiff = []; // ばね。構造(縦横)・せん断(斜め)・曲げ(1 つ飛ばし)
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) for (const [dr, dc, k] of [[0, 1, 1], [1, 0, 1], [1, 1, 0.8], [1, -1, 0.8], [0, 2, 0.8], [2, 0, 0.8]])
if (r + dr < ROWS && c + dc >= 0 && c + dc < COLS) { pairs.push(r * COLS + c, (r + dr) * COLS + c + dc); rest.push(Math.hypot(dr, dc) * SPACING); stiff.push(k); }
const touches = (pos, { origin: o, direction: v }) => { // レイが布を通っているか(どれかの質点が、レイから質点の間隔より近い)。布の外の壁の上では押さない
for (let k = 0; k < N; k += 3) { const x = pos[k] - o.x, y = pos[k + 1] - o.y, z = pos[k + 2] - o.z, s = x * v.x + y * v.y + z * v.z; if (x * x + y * y + z * z - s * s < SPACING ** 2) return true; } return false; };
// 布の 1 ステップ: 風 → 指先 → Verlet 積分(上の 1 列は留める)→ ばね → 壁
function step(pos, prev, state, time, ray) {
const { force } = state, touching = Boolean(ray) && touches(pos, ray); force.fill(0);
for (let r = 0; r < ROWS - 1; r++) for (let c = 0, i = r * COLS; c < COLS - 1; c++, i++) { windOn(pos, prev, force, i, i + COLS, i + 1, time / LOOP); windOn(pos, prev, force, i + 1, i + COLS, i + COLS + 1, time / LOOP); }
state.press = touching ? Math.min(1, state.press + STEP / FINGER.in) : Math.max(0, state.press - STEP / FINGER.out); // 布の端を横切った瞬間に強く押さない
if (state.press > 0 && ray) { // レイのまわりの円柱に入った質点に、奥向きの力を足す(中心で強く、縁で 0)
const { origin: o, direction: v } = ray, R2 = FINGER.radius ** 2;
for (let k = COLS * 3; k < N; k += 3) {
const x = pos[k] - o.x, y = pos[k + 1] - o.y, z = pos[k + 2] - o.z, s = x * v.x + y * v.y + z * v.z, f = Math.max(0, 1 - (x * x + y * y + z * z - s * s) / R2);
const a = FINGER.force * f * f * state.press; force[k] += v.x * a; force[k + 1] += v.y * a; force[k + 2] += v.z * a;
}
}
for (let k = COLS * 3; k < N; k++) { const p = pos[k]; pos[k] = p + (p - prev[k]) * (1 - DRAG) + (force[k] - (k % 3 === 1 ? GRAVITY : 0)) * STEP * STEP; prev[k] = p; }
for (let it = 0; it < ITERATIONS; it++) for (let s = 0; s < rest.length; s++) { // ばね。留めた質点(上の 1 列)は動かさない
const i = pairs[s * 2] * 3, j = pairs[s * 2 + 1] * 3, wi = i < COLS * 3 ? 0 : 1, wj = j < COLS * 3 ? 0 : 1;
const dx = pos[j] - pos[i], dy = pos[j + 1] - pos[i + 1], dz = pos[j + 2] - pos[i + 2], dist = Math.hypot(dx, dy, dz) || 1e-6;
const corr = wi + wj ? ((dist - rest[s]) / (dist * (wi + wj))) * stiff[s] : 0;
pos[i] += dx * corr * wi; pos[i + 1] += dy * corr * wi; pos[i + 2] += dz * corr * wi; pos[j] -= dx * corr * wj; pos[j + 1] -= dy * corr * wj; pos[j + 2] -= dz * corr * wj;
}
for (let k = COLS * 3 + 2; k < N; k += 3) pos[k] = Math.max(pos[k], WALL_Z + 0.05); // 壁より奥へは行かない
}
document.querySelectorAll(".cloth-hero").forEach((hero) => {
const css = (name) => getComputedStyle(hero).getPropertyValue(name).trim(), canvas = hero.querySelector(".cloth-hero__canvas");
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.toneMapping = THREE.NeutralToneMapping; renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFShadowMap;
const scene = new THREE.Scene(), camera = new THREE.PerspectiveCamera(32, 1, 0.1, 100);
scene.background = new THREE.Color(css("--wall")); camera.position.set(1.5, -0.2, 11); camera.lookAt(0.5, -0.05, 0);
const key = new THREE.DirectionalLight("#ffffff", 1.7); key.position.set(-3, 5, 9); key.castShadow = true; key.shadow.mapSize.set(1024, 1024); key.shadow.radius = 8; // 壁に落ちる影の縁をぼかす
Object.assign(key.shadow, { bias: -0.0004, normalBias: 0.03 }); Object.assign(key.shadow.camera, { left: -6, right: 6, top: 6, bottom: -6, near: 1, far: 30 });
const wall = new THREE.Mesh(new THREE.PlaneGeometry(60, 40), new THREE.MeshStandardMaterial({ color: css("--wall"), roughness: 0.95 }));
Object.assign(wall, { receiveShadow: true }).position.z = WALL_Z;
const pos = new Float32Array(N), state = { force: new Float32Array(N), press: 0 }, index = [];
for (let i = 0; i < COUNT; i++) pos.set([SHIFT + ((i % COLS) - (COLS - 1) / 2) * SPACING, TOP - Math.floor(i / COLS) * SPACING, 0], i * 3); // 静止して垂れた布
for (let r = 0; r < ROWS - 1; r++) for (let c = 0, i = r * COLS; c < COLS - 1; c++, i++) index.push(i, i + COLS, i + 1, i + 1, i + COLS, i + COLS + 1);
const prev = pos.slice(), geometry = new THREE.BufferGeometry().setIndex(index);
geometry.setAttribute("position", new THREE.BufferAttribute(pos, 3).setUsage(THREE.DynamicDrawUsage));
const cloth = new THREE.Mesh(geometry, new THREE.MeshPhysicalMaterial({ color: css("--cloth"), roughness: 0.82, sheen: 1, sheenRoughness: 0.45, sheenColor: "#f6e3de", side: THREE.DoubleSide }));
const rod = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, COLS * SPACING + 0.7, 24), new THREE.MeshStandardMaterial({ color: css("--rod"), roughness: 0.32, metalness: 0.6 }));
Object.assign(cloth, { castShadow: true, frustumCulled: false }); // 頂点を毎回動かすので、古い境界球で消えないようにする
rod.rotation.z = Math.PI / 2; rod.position.set(SHIFT, TOP + 0.04, 0); rod.castShadow = true;
scene.add(key, new THREE.HemisphereLight("#f4f3f0", "#9a9894", 2.1), wall, cloth, rod);
const raycaster = new THREE.Raycaster(), ndc = new THREE.Vector2(), toggle = hero.querySelector(".cloth-hero__toggle");
let ray = null, clock = 0, acc = 0, last = 0, visible = false, paused = false;
const draw = () => { geometry.attributes.position.needsUpdate = true; geometry.computeVertexNormals(); renderer.render(scene, camera); };
const frame = (ms) => { // タブに戻ったときに溜まった時間を一度に解かないよう、1 回に進めるのは 0.1 秒まで
for (acc += Math.min(ms - last, 100) / 1000, last = ms; acc >= STEP; acc -= STEP, clock += STEP) step(pos, prev, state, clock, ray);
draw();
};
const loop = () => { last = performance.now(); renderer.setAnimationLoop(visible && !paused && !reduceMotion ? frame : null); };
canvas.addEventListener("pointermove", (e) => { // ポインタから奥へ向かう線(レイ)を、布を押す指先にする
const r = canvas.getBoundingClientRect(); raycaster.setFromCamera(ndc.set(((e.clientX - r.left) / r.width) * 2 - 1, -((e.clientY - r.top) / r.height) * 2 + 1), camera); ray = raycaster.ray;
});
for (const type of ["pointerleave", "pointercancel"]) canvas.addEventListener(type, () => { ray = null; });
toggle.addEventListener("click", () => { paused = !paused; toggle.setAttribute("aria-pressed", String(paused)); loop(); });
const sizes = new ResizeObserver(() => { // 縦長の画面では引いて、布の幅を収める
const { clientWidth: w, clientHeight: h } = hero; camera.aspect = w / h; camera.zoom = Math.min(1, (camera.aspect / (16 / 9)) * 1.45);
camera.updateProjectionMatrix(); renderer.setSize(w, h, false); draw();
});
if (reduceMotion) for (; clock < 3; clock += STEP) step(pos, prev, state, clock, null); // 動きを減らす設定では、少しなびいた形で止めて描く
const view = new IntersectionObserver(([e]) => { visible = e.isIntersecting; loop(); }); // 画面の外では解かない
sizes.observe(hero); view.observe(hero);
hero.destroy = () => { // PJAX でセクションごと破棄するときに呼ぶ。放っておくと WebGL のコンテキストが積み上がる
renderer.setAnimationLoop(null); sizes.disconnect(); view.disconnect();
[geometry, cloth.material, rod.geometry, rod.material, wall.geometry, wall.material, renderer].forEach((item) => item.dispose()); renderer.forceContextLoss();
};
});