デモ
デモを読み込めませんでした。
仕様
- 用途
- ヒーロー
- 動作
- DOM+WebGL
- 種類
- シェーダー
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
ヒーローの画像を押すと、その点を中心に水面のような波紋が広がり、波が通り過ぎた内側から次の画像に変わっていきます。次の画像は1.05倍に寄った状態から、押した点を中心に等倍へ引いていきます。見出しや日時は WebGL に描かず HTML のまま残し、ゆがむのは画像の範囲だけにしました。
前と次の画像をテクスチャとして読み込み、画像の上に重ねた平面をシェーダーで描いています。波の高さは、押した点からの距離と、広がっていく波の先頭との差から「減衰する包絡線 × cos」で求め、その傾きの分だけ、画像の色を読み取る位置を外向きにずらします。傾きの大きい所ほど大きくずれるので、水を通して見たような屈折になります。波の列は画像の高さの36%ぶんの長さで、そこに3つの波が弱まりながら続きます。
斜面には光の向きから明暗を付け、光の来る側にはきらめきを足しています。赤と青だけはずれる量を少し増減させてあり、色ごとのずれ方の違いが、波の縁に水らしいにじみを作ります。広がりきるまでは1.8秒の sine.inOut です。
波が届く距離は、いちばん遠い角までの距離に波の列の長さを足して決めています。角までで止めると、最後の波が角に残ったまま終わってしまいます。波の強さには sin(π × 進み具合) を乗算して、始まりと終わりでは波が無い状態にしました。canvas を表示するのは切り替えのあいだだけで、前後は img 要素に戻すので、波が残っていると入れ替わる瞬間に画像が跳んで見えます。
画像を表示している要素そのものが button なので、Enter や Space でも切り替えられ、そのときは画像の中央から波を広げます。画像は WebGL がテクスチャとして読むので、同じオリジンに置いてください。PJAX でページを差し替えるサイトでは destroy を呼んでコンテキストを破棄しないと、移るたびに WebGL のコンテキストが残っていきます。
コード
<section class="ripple-hero" aria-roledescription="カルーセル" aria-label="プログラム">
<div class="ripple-hero__copy">
<p class="ripple-hero__eyebrow"><span>Festival</span><span>7月13日〜19日</span></p>
<h1 class="ripple-hero__title">水辺で、<br>夏を遊ぶ。</h1>
<p class="ripple-hero__lead">ここに説明文が入ります。</p>
<p class="ripple-hero__caption" aria-live="polite">
<span class="ripple-hero__count">01 / 03</span>
<span class="ripple-hero__name">夜明けの水泳</span>
<span class="ripple-hero__date">7月13日(月)6:00</span>
</p>
<p class="ripple-hero__hint">画像を押すと、次のプログラムへ進みます</p>
</div>
<!-- 画像は WebGL が読むので、同じオリジンに置く(別のドメインなら crossorigin="anonymous" を付け、配信側で CORS を許す)。
名前と日時は data-* に書き、切り替えたときに左の列へ移す -->
<button class="ripple-hero__media" type="button" aria-label="次のプログラムを見る">
<img class="ripple-hero__image" src="/img/dawn-swim.jpg" alt="" data-name="夜明けの水泳" data-date="7月13日(月)6:00">
<img class="ripple-hero__image" src="/img/pier-sessions.jpg" alt="" data-name="桟橋のライブ" data-date="7月15日(水)19:30" hidden>
<img class="ripple-hero__image" src="/img/film-on-the-water.jpg" alt="" data-name="水上の映画会" data-date="7月18日(土)21:00" hidden>
<!-- 波紋の切り替えを描く canvas。切り替えのあいだだけ表示する -->
<canvas class="ripple-hero__canvas" aria-hidden="true" hidden></canvas>
</button>
</section>
.ripple-hero {
--ground: #111111;
--ink: #eeece6;
--mute: #a8a6a0;
--line: rgba(238, 236, 230, 0.14);
--gutter: clamp(16px, 7.5vw, 96px);
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 512px);
align-items: center;
gap: 48px 64px;
padding: 96px var(--gutter);
background: var(--ground);
color: var(--ink);
}
.ripple-hero__eyebrow { display: flex; flex-wrap: wrap; gap: 8px 16px; margin: 0; color: var(--mute); font-size: 13px; line-height: 1; }
.ripple-hero__eyebrow span:first-child { font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; }
.ripple-hero__eyebrow span:last-child { font-weight: 600; letter-spacing: 0.06em; }
.ripple-hero__title { margin: 24px 0 0; font-size: clamp(40px, 8vw, 72px); font-weight: 800; line-height: 1.05; letter-spacing: -0.02em; }
.ripple-hero__lead { margin: 24px 0 0; font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; word-break: auto-phrase; }
.ripple-hero__lead span { display: inline-block; } /* 狭い画面でも「映画を観る。」を 1 語のまま次の行へ送る */
/* いまの画像の番号・名前・日時。切り替えると script.js が書き換え、読み上げにも届く */
.ripple-hero__caption {
display: grid;
justify-items: start;
gap: 8px;
margin: 32px 0 0;
padding-top: 24px;
box-shadow: inset 0 1px 0 var(--line);
line-height: 1;
}
.ripple-hero__count { color: var(--mute); font-size: 13px; font-weight: 600; letter-spacing: 0.04em; font-variant-numeric: tabular-nums; }
.ripple-hero__name { margin-top: 8px; font-size: 22px; font-weight: 700; line-height: 1.3; letter-spacing: -0.01em; }
.ripple-hero__date { color: var(--mute); font-size: 14px; letter-spacing: 0.04em; font-variant-numeric: tabular-nums; }
.ripple-hero__hint { margin: 24px 0 0; color: var(--mute); font-size: 13px; line-height: 1.6; letter-spacing: 0.06em; }
/* 画像のコンテナそのものがボタン。画像と、切り替えのあいだだけ重ねる canvas をコンテナいっぱいに広げる */
.ripple-hero__media {
position: relative;
display: block;
width: 100%;
aspect-ratio: 16 / 15;
padding: 0;
border: 0;
border-radius: 4px;
overflow: hidden;
background: var(--line);
cursor: pointer;
}
.ripple-hero__media > * { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
.ripple-hero__media > [hidden] { display: none; }
.ripple-hero__image { object-fit: cover; }
.ripple-hero__media:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
@media (max-width: 860px) {
.ripple-hero { grid-template-columns: minmax(0, 1fr); }
}
import * as THREE from "three";
import { gsap } from "gsap";
const DURATION = 1.8; // 波の列が画像の端を抜けきるまで(秒)
const WIDTH = 0.36, CRESTS = 3; // 波の列の長さ(画像の高さに対する比)と、その中の波の数
const vertexShader = "varying vec2 vUv; void main() { vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }";
// 長さはどれも画像の高さを 1 とした比。AMP は画像をずらす最大量、SPLIT は R と B のずれ、ZOOM は次の画像の寄り
const fragmentShader = /* glsl */ `
precision highp float;
uniform sampler2D uFrom, uTo; uniform vec2 uCoverFrom, uCoverTo, uCenter; uniform float uProgress, uReach, uAspect, uPx;
varying vec2 vUv;
const float PI = 3.14159265, WIDTH = ${WIDTH.toFixed(2)}, K = ${((CRESTS * 2 * Math.PI) / WIDTH).toFixed(4)};
const float AHEAD = 0.03, AMP = 0.05, SHINE = 0.3, GLINT = 0.5, SPLIT = 0.25, ZOOM = 1.05;
const vec2 LIGHT = vec2(-0.6, 0.8); // 光の来る向き(左上)
// o だけずらした位置の色を読み取る。R と B はずらす量を変えて、屈折の縁に色のにじみを出す。cover は object-fit: cover と同じ切り抜き
vec3 refracted(sampler2D tex, vec2 uv, vec2 cover, vec2 o) {
uv = (uv - 0.5) * cover + 0.5; o *= cover;
return vec3(texture2D(tex, uv + o * (1.0 + SPLIT)).r, texture2D(tex, uv + o).g, texture2D(tex, uv + o * (1.0 - SPLIT)).b);
}
void main() {
vec2 aspect = vec2(uAspect, 1.0), d = vUv * aspect - uCenter, dir = d / max(length(d), 1e-4);
float x = length(d) - uProgress * uReach; // 0 = 波の先頭。負 = 波が通り過ぎた後(次の画像)
float env = x > 0.0 ? exp(-x * x / (AHEAD * AHEAD)) : exp(x / (WIDTH * 0.42)); // 先頭の前はすぐ消え、後ろは減衰しながら続く
float slope = -env * sin(K * x) * sin(PI * uProgress); // 波の高さ env · cos(Kx) の傾き。始まりと終わりには波が無い
vec2 o = dir * slope * AMP / aspect;
vec3 from = refracted(uFrom, vUv, uCoverFrom, o);
vec3 to = refracted(uTo, (uCenter + d / mix(ZOOM, 1.0, uProgress)) / aspect, uCoverTo, o); // 押した点を中心に、1.05 倍に拡大した状態から等倍へ戻す
float lit = slope * dot(dir, normalize(LIGHT)); // 光の来る側の斜面は明るく、反対側は暗く
gl_FragColor = vec4(mix(from, to, smoothstep(uPx, -uPx, x)) + SHINE * lit + GLINT * pow(max(lit, 0.0), 4.0), 1.0);
}
`;
const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
document.querySelectorAll(".ripple-hero").forEach((hero) => {
const media = hero.querySelector(".ripple-hero__media"), canvas = hero.querySelector(".ripple-hero__canvas");
const images = [...hero.querySelectorAll(".ripple-hero__image")];
const [count, name, date] = ["count", "name", "date"].map((key) => hero.querySelector(`.ripple-hero__${key}`));
const pad = (i) => String(i + 1).padStart(2, "0");
let index = 0, tween = null, gl = null;
const caption = (i) => ([count.textContent, name.textContent, date.textContent] = [`${pad(i)} / ${pad(images.length - 1)}`, images[i].dataset.name, images[i].dataset.date]);
const show = (i) => { images.forEach((img, k) => (img.hidden = k !== i)); index = i; };
// <img> の object-fit: cover と同じ所を切り抜く(画像の比率がコンテナと違っても、切り替えの前後で絵が跳ばない)
const cover = (img, out) => { const k = media.clientWidth / media.clientHeight / (img.naturalWidth / img.naturalHeight); out.set(Math.min(1, k), Math.min(1, 1 / k)); };
// 動きを減らす設定ではWebGL の描画を用意せず、画像をすぐ差し替える
if (!reduceMotion) {
const renderer = new THREE.WebGLRenderer({ canvas });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
const scene = new THREE.Scene(), camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const textures = images.map((img) => {
const texture = new THREE.Texture(img);
img.decode().then(() => { texture.needsUpdate = true; renderer.initTexture(texture); }, () => {}); // 先に GPU へ送り、押した瞬間に詰まらせない
return texture;
});
const uniforms = { uFrom: { value: null }, uTo: { value: null }, uProgress: { value: 0 }, uReach: { value: 1 }, uAspect: { value: 1 }, uPx: { value: 0 } };
for (const key of ["uCoverFrom", "uCoverTo", "uCenter"]) uniforms[key] = { value: new THREE.Vector2() };
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), new THREE.ShaderMaterial({ uniforms, vertexShader, fragmentShader }));
scene.add(mesh);
const sizes = new ResizeObserver(() => renderer.setSize(media.clientWidth, media.clientHeight, false)); // 大きさは CSS が決めるので、style は書き換えない
sizes.observe(media);
gl = { renderer, scene, camera, textures, uniforms, mesh, sizes };
}
// x, y は押した点(コンテナの左下を原点、コンテナの高さを 1 とした座標)
const go = (x, y) => {
if (tween?.isActive()) return; // 波が抜けきるまでは次の押下を受けない
const next = (index + 1) % images.length, from = images[index], to = images[next];
if (!gl || !from.naturalWidth || !to.naturalWidth) { show(next); caption(next); return; } // 画像が読めていなければ、そのまま差し替える
const { renderer, scene, camera, textures, uniforms: u } = gl;
const aspect = media.clientWidth / media.clientHeight;
u.uFrom.value = textures[index]; u.uTo.value = textures[next];
cover(from, u.uCoverFrom.value);
cover(to, u.uCoverTo.value);
u.uCenter.value.set(x, y); u.uAspect.value = aspect; u.uPx.value = 1 / media.clientHeight;
// いちばん遠い角まで、波の列ごと抜ける距離。短いと最後の波が角に残ったまま止まる
u.uReach.value = Math.max(Math.hypot(x, y), Math.hypot(aspect - x, y), Math.hypot(x, 1 - y), Math.hypot(aspect - x, 1 - y)) + WIDTH + 0.05;
const render = () => renderer.render(scene, camera);
tween = gsap.timeline({ onComplete: () => { show(next); canvas.hidden = true; } }) // 描き終えたら <img> に戻す
.fromTo(u.uProgress, { value: 0 }, { value: 1, duration: DURATION, ease: "sine.inOut", onUpdate: render, onStart: () => { render(); canvas.hidden = false; } })
// 番号・名前・日時は始めに消し、波が広がる途中で次のものを表示する
.to([count, name, date], { opacity: 0, duration: DURATION * 0.15, ease: "none" }, 0)
.call(caption, [next], DURATION * 0.2)
.to([count, name, date], { opacity: 1, duration: DURATION * 0.2, ease: "none" }, DURATION * 0.25);
};
media.addEventListener("click", (e) => {
const r = media.getBoundingClientRect();
if (e.detail === 0) go(r.width / r.height / 2, 0.5); // Enter・Space で押したときは、コンテナの中央から広げる
else go((e.clientX - r.left) / r.height, (r.bottom - e.clientY) / r.height);
});
hero.destroy = () => { // PJAX で差し替えるときに呼ぶ。放っておくと WebGL のコンテキストが積み上がる
tween?.kill();
if (!gl) return;
gl.sizes.disconnect(); gl.mesh.geometry.dispose(); gl.mesh.material.dispose();
gl.textures.forEach((texture) => texture.dispose()); gl.renderer.dispose(); gl.renderer.forceContextLoss();
};
});