デモ
デモを読み込めませんでした。
仕様
- 用途
- ヒーロー
- 動作
- DOM+WebGL
- 種類
- シェーダー
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
ヒーローの画像を前と次の 2 枚のテクスチャにし、画像の枠に重ねた WebGL の板のシェーダーで切り替えます。押した点からの距離と、広がっていく波の先頭との差を取り、波の高さを「減衰する包絡線 × cos」で決めます。その傾きのぶんだけ画像を引く位置を中心から外向きにずらすと、水を通して見たような屈折になります。波の先頭より内側(波が通り過ぎた所)が次の画像です。見出しや日時は HTML のままなので、ゆがむのは画像の枠の中だけです。
波の列は画像の高さの 36% の長さで、その中に 3 つの波が減衰しながら続きます。ずらす量は最大で画像の高さの 5% です。傾きと光の向きで斜面に明暗を付け、光の来る側にきらめきを足し、赤と青をずれの向きに 25% ずつずらすと、縁に水らしい色のにじみが出ます。広がりきるまでは 1.8 秒の sine.inOut で、次の画像は押した点を中心に 1.05 倍から等倍へ引きます。
波の届く距離は、押した点からいちばん遠い枠の角までの距離に、波の列の長さを足して決めます。短いと、最後の波が角に残ったまま止まります。波の強さには sin(π × 進み具合) を掛けて始まりと終わりを波の無い画像にしておくと、切り替えの間だけ canvas を出し、終わったら img 要素に戻しても画像が跳びません。画像の比が枠と違うときは、object-fit: cover と同じ切り抜きをシェーダーでもします。
画像の枠そのものを 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 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); // 押した点を中心に、寄った所から引く
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)); };
// 動きを減らす設定では板を作らず、画像をすぐ差し替える
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();
};
});