デモ
デモを読み込めませんでした。
仕様
- 用途
- カーソル
- 動作
- WebGL
- 種類
- シーン
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
川底の写真の上でカーソルを動かすと、通った跡から次々に波紋が生まれて広がり、輪の下の小石が水越しに見ているように揺らぎます。カーソルを止めれば新しい波紋はもう生まれず、残った輪が広がりながら静まっていきます。見出しやボタンは HTML のまま上に重ねてあるので、ゆがむのは写真だけです。
押した所から波紋を1つ広げるのとは違い、ここでは軌跡に沿って波紋が生まれ続けます。水面は時間を追って波の方程式を解くのではなく、カーソルの位置に0.12秒ごとに置いた「しずく」から、毎回その場で計算することにしました。しずくが持っているのは位置と経過時間と強さだけで、シェーダーはしずくごとの輪の波を足し合わせ、水面の傾きのぶんだけ写真をずらして描きます。前のコマの水面を使わないので、計算用の描画先を行き来させる仕組みが要らず、コードも短く済みます。
合わせておかなければならないのが、しずくを置く間隔と波の山の間隔です。輪は1秒に高さの36%ずつ広がるので、0.12秒で輪が進む距離を、そのまま山の間隔にしました。ここがずれると、続けて置いたしずくどうしで山と谷が打ち消し合い、カーソルの近くの波紋がぼやけて消えてしまいます。しずくの強さはそのあいだにカーソルが動いた距離に比例させたので、ゆっくり動かせば弱い輪が重なり、速く動かせば強い輪が間を空けて並びます。
波紋の見え方を決めるのは、輪が弱まる速さ(1.3秒)と、写真をずらす量です。弱まるまでを長くすると波紋が遠くまで届き、ずらす量を増やすと小石が大きく揺らぎます。写真は WebGL がテクスチャとして読み込むので、同じオリジンに置いてください。PJAX でページを差し替えるサイトでは、ヒーローを外す前に destroyRipple() を呼び、WebGL のコンテキストを破棄します。
コード
<section class="ripple-hero" aria-labelledby="ripple-hero-title">
<!-- 波紋でゆがむのは画像だけ。WebGL がテクスチャとして読むので、画像は同じオリジンに置く -->
<img class="ripple-hero__image" src="/img/riverbed.jpg" alt="" width="1600" height="900">
<!-- 波紋を描く canvas。飾りなので、スクリーンリーダーからは外す -->
<canvas class="ripple-hero__canvas" aria-hidden="true" hidden></canvas>
<div class="ripple-hero__inner">
<p class="ripple-hero__eyebrow">Riverside inn</p>
<h1 class="ripple-hero__title" id="ripple-hero-title">川のほとりで、<br>ゆっくり眠る。</h1>
<p class="ripple-hero__lead">ここに説明文が入ります。</p>
<div class="ripple-hero__actions">
<a class="ripple-hero__button" href="/rooms/">空室を見る</a>
<a class="ripple-hero__button ripple-hero__button--line" href="/about/">宿のご案内</a>
</div>
</div>
</section>
.ripple-hero {
--ink: #ffffff; /* 画像の上の文字 */
--mute: #dedcd6; /* 英字のラベル */
--line: rgba(255, 255, 255, 0.2);
--on-ink: #111111; /* 白いボタンの文字 */
--gutter: clamp(24px, 7vw, 96px);
position: relative;
display: grid;
align-items: center;
min-height: min(90vh, 720px);
padding: 96px var(--gutter);
box-sizing: border-box;
overflow: hidden;
isolation: isolate;
color: var(--ink);
background: #6f6656; /* 画像を読み込むまでの背景 */
}
/* 画像と canvas は同じ大きさで重ねる。canvas は画像を object-fit: cover と同じ切り抜きで描く */
.ripple-hero__image,
.ripple-hero__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.ripple-hero__canvas[hidden] { display: none; }
/* 文字の側を濃くするオーバーレイ。波紋の上に重ね、文字は動かさない */
.ripple-hero::before {
content: "";
position: absolute;
inset: 0;
z-index: 1;
background: linear-gradient(90deg, rgba(0, 0, 0, 0.55), rgba(0, 0, 0, 0.12) 70%);
pointer-events: none;
}
.ripple-hero__inner { position: relative; z-index: 1; }
.ripple-hero__eyebrow {
margin: 0;
color: var(--mute);
font-size: 13px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.ripple-hero__title {
margin: 24px 0 0;
font-size: clamp(40px, 8vw, 72px);
font-weight: 800;
line-height: 1.05;
letter-spacing: -0.02em;
word-break: keep-all; /* 和文の見出しを語の途中で折らない */
}
.ripple-hero__lead { max-width: 432px; margin: 24px 0 0; font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; }
.ripple-hero__actions { display: flex; flex-wrap: wrap; gap: 16px; margin-top: 48px; }
.ripple-hero__button {
display: inline-flex;
align-items: center;
height: 48px;
padding: 0 32px;
border-radius: 999px;
background: var(--ink);
color: var(--on-ink);
font-size: 15px;
font-weight: 600;
letter-spacing: 0.04em;
text-decoration: none;
white-space: nowrap;
}
.ripple-hero__button--line {
background: transparent;
box-shadow: inset 0 0 0 1px var(--ink);
color: var(--ink);
}
.ripple-hero__button:focus-visible {
outline: 2px solid var(--ink);
outline-offset: 3px;
}
import * as THREE from "three";
// 波の決まり(長さはヒーローの高さを 1 とした単位)
const DT = 0.12, LIFE = 2.4, SLOTS = Math.round(LIFE / DT) + 1; // しずくを置く間隔と、消えるまでの秒数
const SPEED = 0.36, WAVE = SPEED * DT; // 輪が広がる速さ(高さ / 秒)。山の間隔は DT 秒に広がる距離にそろえ、続けて置いたしずくの山を重ねる
const WIDTH = 0.05, TAU = 1.3, SPREAD = 2.5; // 輪の厚み・弱まる速さ(秒)・広がるほど薄まる割合
const FULL = 0.5; // この速さ(高さ / 秒)で動かすと、しずくの強さが 1 になる
const REFRACT = 0.018, SHINE = 0.22, GLINT = 0.5, SPLIT = 0.18; // 画像をずらす量・斜面の明暗・きらめき・色ごとのずれ
const f = (v) => v.toFixed(5); // GLSL の float 定数
const vertexShader = "varying vec2 vUv; void main() { vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }";
const fragmentShader = /* glsl */ `
uniform sampler2D uImage;
uniform vec4 uDrops[${SLOTS}]; // x, y(高さを 1 とした座標、左下原点), 生まれてからの秒, 強さ
uniform vec4 uCover; // object-fit: cover と同じ切り抜き(倍率 xy・ずらし zw)
uniform float uAspect;
varying vec2 vUv;
void main() {
vec2 p = vec2(vUv.x * uAspect, vUv.y), grad = vec2(0.0);
for (int i = 0; i < ${SLOTS}; i++) {
vec4 d = uDrops[i];
vec2 v = p - d.xy;
float r = length(v), x = r - ${f(SPEED)} * d.z; // 0 = 輪の真ん中
if (d.w <= 0.0 || abs(x) > ${f(WIDTH * 3)}) continue;
// 輪の高さ h = 包み × sin(Kx) の、半径の向きの傾き(K で割って -1〜1 にそろえる)
float k = ${f((2 * Math.PI) / WAVE)}, env = exp(-x * x / ${f(WIDTH * WIDTH)}) * exp(-d.z / ${f(TAU)}) * smoothstep(0.0, 0.12, d.z) / (1.0 + r * ${f(SPREAD)});
grad += v / max(r, 1e-4) * env * (cos(k * x) - ${f(2 / (WIDTH * WIDTH))} * x * sin(k * x) / k) * d.w;
}
grad /= 1.0 + 0.35 * length(grad); // 重なりすぎた所で、ずれが大きくなりすぎないようにする
vec2 o = grad * ${f(REFRACT)} / vec2(uAspect, 1.0);
vec3 color = vec3( // 赤と青はずらす量を少し変えて、縁に色のにじみを付ける
texture2D(uImage, uCover.zw + (vUv - o * ${f(1 + SPLIT)}) * uCover.xy).r,
texture2D(uImage, uCover.zw + (vUv - o) * uCover.xy).g,
texture2D(uImage, uCover.zw + (vUv - o * ${f(1 - SPLIT)}) * uCover.xy).b);
float lit = dot(grad, normalize(vec2(-0.6, 0.8))); // 光は左上から。光の来る側の斜面を明るく
gl_FragColor = vec4(color + ${f(SHINE)} * lit + ${f(GLINT)} * pow(max(lit, 0.0), 3.0), 1.0);
}
`;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
document.querySelectorAll(".ripple-hero").forEach(async (hero) => {
const img = hero.querySelector(".ripple-hero__image");
const canvas = hero.querySelector(".ripple-hero__canvas");
if (reduceMotion) return; // 動きを減らす設定では、波紋を描かずに画像だけを見せる
await img.decode().catch(() => {});
if (!img.naturalWidth) return; // 画像が読めなければ、背景色のまま
const renderer = new THREE.WebGLRenderer({ canvas });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
const texture = new THREE.Texture(img);
Object.assign(texture, { minFilter: THREE.LinearFilter, generateMipmaps: false, needsUpdate: true });
texture.wrapS = texture.wrapT = THREE.MirroredRepeatWrapping; // 縁の外を引いたら、鏡に映したように折り返す
const uniforms = { uImage: { value: texture }, uDrops: { value: Array.from({ length: SLOTS }, () => new THREE.Vector4()) }, uCover: { value: new THREE.Vector4(1, 1, 0, 0) }, uAspect: { value: 1 } };
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), new THREE.ShaderMaterial({ uniforms, vertexShader, fragmentShader }));
const scene = new THREE.Scene().add(mesh);
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const drops = []; // { x, y, born, amp }
let pointer = null, prev = null, next = 0, lastMove = 0;
const draw = (now) => {
uniforms.uDrops.value.forEach((slot, i) => (drops[i] ? slot.set(drops[i].x, drops[i].y, now - drops[i].born, drops[i].amp) : slot.set(0, 0, 0, 0)));
renderer.render(scene, camera);
};
const frame = (ms) => {
const now = ms / 1000;
// DT 秒ごとにしずくを置く。強さは、その間にポインタが動いた距離に比例する(止まっていれば置かない)
for (next ||= now; next <= now; next += DT) {
if (pointer && prev) drops.push({ ...pointer, born: next, amp: Math.min(1, Math.hypot(pointer.x - prev.x, pointer.y - prev.y) / DT / FULL) });
prev = pointer;
}
while (drops.length && (now - drops[0].born >= LIFE || drops.length > SLOTS)) drops.shift();
draw(now);
// 波紋が消えて、ポインタも止まっていれば、描くのをやめる(次に動いたときに再開する)
if (!drops.length && now - lastMove > DT * 2) {
renderer.setAnimationLoop(null);
next = 0;
}
};
new ResizeObserver(() => {
const { clientWidth: w, clientHeight: h } = hero;
renderer.setSize(w, h, false);
const s = Math.max(w / img.naturalWidth, h / img.naturalHeight); // object-fit: cover と同じ倍率
const sx = w / (img.naturalWidth * s), sy = h / (img.naturalHeight * s);
uniforms.uCover.value.set(sx, sy, (1 - sx) / 2, (1 - sy) / 2);
uniforms.uAspect.value = w / h;
draw(performance.now() / 1000);
}).observe(hero);
canvas.hidden = false;
hero.addEventListener("pointermove", (e) => {
const r = hero.getBoundingClientRect();
pointer = { x: (e.clientX - r.left) / r.height, y: (r.bottom - e.clientY) / r.height };
lastMove = performance.now() / 1000;
renderer.setAnimationLoop(frame); // 止めていたら再開する(同じ関数なら何度呼んでもよい)
});
hero.addEventListener("pointerleave", () => (pointer = prev = null));
// ページを差し替えるサイト(PJAX)では、ヒーローを外す前に呼んで WebGL のコンテキストを捨てる
hero.destroyRipple = () => {
renderer.setAnimationLoop(null);
[mesh.geometry, mesh.material, texture, renderer].forEach((item) => item.dispose());
renderer.forceContextLoss();
};
});