デモ
デモを読み込めませんでした。
仕様
- 用途
- セクション
- 動作
- DOM+WebGL
- 種類
- シェーダー
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
「次へ」を押すと、うねりながら形を変える波が画像を左から右へ横切り、通り過ぎた所から次の季節の画像に替わります。「前へ」なら右から左へ進むので、押した矢印と波の向きがそろいます。波の先頭のうしろには山がいくつも尾を引いて残り、景色を進む向きへ押し流しながら、遠ざかるほど静まっていきます。
場所ごとに波の先頭からの距離を測り、先頭を過ぎた側では次の画像、まだ届いていない側では前の画像の色を使います。そのうえで先頭に近いほど強くなる重みを作り、その分だけ色を取ってくる位置を進む向きの後ろへずらすと、中身が押し流されたように見えます。先頭の線は、上へ進むうねりと下へ進むうねりを重ねた形で、時間とともに形を変え続けます。形を固めたまま横へ動かすと、硬いガラスが滑っていくようにしか見えません。
波は先頭の前では短く盛り上がり、うしろには長く尾を引いて弱まっていきます。うしろの山は先頭からの距離に時間を足した位相で描いているので、先頭より少しずつ遅れて流れます。山の線も縦に曲げ、高さを場所と時間で変えたので、同じ形の縞が並んで見えることはありません。
始まりでは波を画像の左の外に、終わりでは右の外に置きました。進み具合が0と1のときは波ごと画像の外にあるので、最初と最後の絵は元の画像そのものです。重みも smoothstep で、波の外ではちょうど0になる形にしています。ガウス関数のように裾がどこまでも続く形だと、渡り終えても端がわずかにゆがんだまま残り、canvas を消して img 要素に戻した瞬間に画像が跳んで見えてしまいます。
横切りきるまでは2.6秒の power1.inOut。景色を目で追えるよう、ゆっくりめにしました。切り替えの表情がいちばん変わるのは、押し流す量です。増やすと水が勢いよく流れるように、減らすと薄いガラスを通したような静かな切り替えになります。
コード
<section class="wave-slider" aria-roledescription="カルーセル" aria-label="客室から望む四季の景色">
<div class="wave-slider__head">
<div>
<p class="wave-slider__eyebrow">Scenery</p>
<h2 class="wave-slider__title">客室から望む、四季の景色</h2>
</div>
<p class="wave-slider__lead">ここに説明文が入ります。</p>
</div>
<!-- 画像は WebGL が読むので、同じオリジンに置く(別のドメインなら crossorigin="anonymous" を付け、配信側で CORS を許す) -->
<div class="wave-slider__media">
<img class="wave-slider__image" src="/img/spring.jpg" alt="山桜の咲く丘と湖" data-name="春の山桜">
<img class="wave-slider__image" src="/img/summer.jpg" alt="緑の山並みと湖畔" data-name="夏の湖畔" hidden>
<img class="wave-slider__image" src="/img/autumn.jpg" alt="紅葉した丘と湖" data-name="秋の紅葉" hidden>
<img class="wave-slider__image" src="/img/winter.jpg" alt="朝霧のかかる冬の山と湖" data-name="冬の朝霧" hidden>
<!-- ゆがみの波を描く canvas。切り替えのあいだだけ表示する -->
<canvas class="wave-slider__canvas" aria-hidden="true" hidden></canvas>
</div>
<div class="wave-slider__controls">
<p class="wave-slider__caption" aria-live="polite">
<span class="wave-slider__count">01 / 04</span>
<span class="wave-slider__name">春の山桜</span>
</p>
<button class="wave-slider__arrow" type="button" data-step="-1" aria-label="前の画像">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 12H5M11 6l-6 6 6 6" /></svg>
</button>
<button class="wave-slider__arrow" type="button" data-step="1" aria-label="次の画像">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6" /></svg>
</button>
</div>
</section>
.wave-slider {
--bg: #f2f1ed;
--ink: #262626;
--mute: #6b6b6b;
--line: rgba(38, 38, 38, 0.12);
--gutter: clamp(16px, 7.5vw, 96px);
padding: 72px var(--gutter);
background: var(--bg);
color: var(--ink);
}
.wave-slider__head { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: 16px 48px; }
.wave-slider__eyebrow { margin: 0; color: var(--mute); font-size: 13px; font-weight: 700; letter-spacing: 0.14em; line-height: 1; text-transform: uppercase; }
.wave-slider__title { margin: 12px 0 0; font-size: clamp(26px, 4.4vw, 44px); font-weight: 800; letter-spacing: -0.02em; line-height: 1.1; word-break: auto-phrase; }
.wave-slider__lead { flex: 0 1 360px; margin: 0; color: var(--ink); font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; }
/* 画像と、切り替えのあいだだけ重ねる canvas。どちらも親要素いっぱいに object-fit: cover で表示する */
.wave-slider__media { position: relative; margin-top: 28px; height: clamp(220px, 32.5vw, 416px); border-radius: 4px; overflow: hidden; background: var(--line); }
.wave-slider__media > * { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
.wave-slider__media > [hidden] { display: none; }
.wave-slider__image { object-fit: cover; }
.wave-slider__controls { display: flex; align-items: center; gap: 16px; margin-top: 24px; }
.wave-slider__caption { display: flex; align-items: baseline; gap: 16px; margin: 0 auto 0 0; line-height: 1; white-space: nowrap; }
.wave-slider__count { font-size: 15px; font-weight: 700; letter-spacing: 0.04em; font-variant-numeric: tabular-nums; }
.wave-slider__name { color: var(--mute); font-size: 13px; font-weight: 600; letter-spacing: 0.06em; }
/* 線の丸のボタン。アイコンは 20px で描くので、線は 48 ÷ 20 */
.wave-slider__arrow {
display: grid;
place-items: center;
flex: none;
width: 48px;
height: 48px;
padding: 0;
border: 0;
border-radius: 50%;
background: none;
box-shadow: inset 0 0 0 1px var(--ink);
color: var(--ink);
cursor: pointer;
}
.wave-slider__arrow svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }
.wave-slider__arrow:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
import * as THREE from "three";
import { gsap } from "gsap";
const DURATION = 2.6; // 波が横切りきるまで(秒)
const vertexShader = "varying vec2 vUv; void main() { vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }";
// LEAD と TRAIL は先頭の前とうしろで波が立つ幅、PUSH は押し流す量、RIPPLE は波の山と谷の差、SPACING は山の間隔、WOBBLE は先頭の線のうねり(どれも画像の高さに対する比)
const fragmentShader = /* glsl */ `
precision highp float;
uniform sampler2D uFrom, uTo; uniform vec2 uCoverFrom, uCoverTo, uSize; uniform float uProgress, uDir, uSeed, uTime;
varying vec2 vUv;
const float LEAD = 0.16, TRAIL = 0.64, PUSH = 0.11, RIPPLE = 0.05, SPACING = 0.13, SPEED = 9.0, WOBBLE = 0.07, BLEND = 0.025, PI = 3.14159265;
vec3 read(sampler2D map, vec2 uv, vec2 cover, vec2 off) { // object-fit: cover と同じ切り抜きで、R と B のずれ方を少し変えて読む
return vec3(texture2D(map, (uv - off * 1.08 - 0.5) * cover + 0.5).r, texture2D(map, (uv - off - 0.5) * cover + 0.5).g, texture2D(map, (uv - off * 0.92 - 0.5) * cover + 0.5).b);
}
void main() {
float aspect = uSize.x / uSize.y;
float x = (uDir > 0.0 ? vUv.x : 1.0 - vUv.x) * aspect; // 進む向きに沿った座標。前へは左右を裏返して同じ式で描く
float y = vUv.y, wobble = WOBBLE * (0.3 + 0.7 * sin(PI * uProgress)) * (0.6 * sin(2.0 * PI * (1.1 * y - 0.55 * uTime) + uSeed) + 0.4 * sin(2.0 * PI * (2.3 * y + 0.8 * uTime) + 1.7 * uSeed)); // 先頭の線は上下へ進むうねりで形を変える
float d = x - mix(-LEAD - WOBBLE, aspect + TRAIL + WOBBLE, uProgress) - wobble; // 正: 前の画像 / 負: 波が通って次の画像
float env = d > 0.0 ? 1.0 - smoothstep(0.0, LEAD, d) : 1.0 - smoothstep(0.0, TRAIL, -d); env *= env; // 先頭でいちばん強く、うしろは長く尾を引いて弱まる
float phase = (d + 0.035 * sin(2.0 * PI * (1.6 * y + 0.4 * uTime) + 0.6 * uSeed)) / SPACING * 2.0 * PI + SPEED * uTime, height = 0.75 + 0.25 * sin(2.0 * PI * (0.8 * y - 0.3 * uTime) + uSeed);
float wave = sin(phase) * height, slope = cos(phase) * height; // うしろの波の山は、縦に曲がりながら先頭から遅れて流れる
vec2 off = vec2(uDir * env * (PUSH + RIPPLE * wave) / aspect, 0.006 * env * slope); // 読む位置を後ろへずらす = 進む向きへ押し流す
vec3 from = read(uFrom, vUv, uCoverFrom, off);
vec3 to = read(uTo, (vUv - 0.5) / (1.03 - 0.03 * uProgress) + 0.5, uCoverTo, off); // 次の画像は 1.03 倍から等倍へ
vec3 color = mix(to, from, smoothstep(-BLEND, BLEND, d));
color = mix(color, vec3(1.0), env * 0.2 * max(slope, 0.0)); // 光に向いた波の斜面に照り、反対の斜面に陰
gl_FragColor = vec4(color * (1.0 - env * 0.1 * max(-slope, 0.0)), 1.0);
}
`;
const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
document.querySelectorAll(".wave-slider").forEach((slider) => {
const media = slider.querySelector(".wave-slider__media"), canvas = slider.querySelector(".wave-slider__canvas");
const images = [...slider.querySelectorAll(".wave-slider__image")];
const count = slider.querySelector(".wave-slider__count"), name = slider.querySelector(".wave-slider__name");
const pad = (i) => String(i + 1).padStart(2, "0");
let index = 0, tween = null, gl = null;
const caption = (i) => { count.textContent = `${pad(i)} / ${pad(images.length - 1)}`; name.textContent = images[i].dataset.name; };
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 }, uDir: { value: 1 }, uSeed: { value: 0 }, uTime: { value: 0 } };
for (const key of ["uCoverFrom", "uCoverTo", "uSize"]) 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 は書き換えない
uniforms.uSize.value.set(media.clientWidth, media.clientHeight);
});
sizes.observe(media);
gl = { renderer, scene, camera, textures, uniforms, mesh, sizes };
}
const go = (step) => {
if (tween?.isActive()) return; // 波が抜けきるまでは次の押下を受けない
const next = (index + step + images.length) % 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;
u.uFrom.value = textures[index];
u.uTo.value = textures[next];
cover(from, u.uCoverFrom.value);
cover(to, u.uCoverTo.value);
u.uDir.value = step; // 波は押した矢印の向きへ進む
u.uSeed.value = Math.random() * 6.28; // 先頭の線のうねりを毎回変える
const render = (time) => { u.uTime.value = time; renderer.render(scene, camera); }; // うねりは経過の秒数で動かす
tween = gsap.timeline({ onComplete: () => { show(next); canvas.hidden = true; } }) // 渡し終えたら <img> に戻す
.fromTo(u.uProgress, { value: 0 }, { value: 1, duration: DURATION, ease: "power1.inOut", onUpdate() { render(this.time()); }, onStart() { render(0); canvas.hidden = false; } })
// 番号と名前は始めに消し、波が画像の中ほどを過ぎるころに次のものを表示する
.to([count, name], { opacity: 0, duration: DURATION * 0.15, ease: "none" }, 0)
.call(caption, [next], DURATION * 0.2)
.to([count, name], { opacity: 1, duration: DURATION * 0.2, ease: "none" }, DURATION * 0.3);
};
slider.querySelectorAll(".wave-slider__arrow").forEach((button) => button.addEventListener("click", () => go(Number(button.dataset.step))));
slider.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();
};
});