本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100

ヒーロー

ヒーローの画像が立方体に割れて崩れ落ち、奥から次の画像が現れる

デモ

操作:クリック。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。

仕様

用途
ヒーロー
動作
DOM+WebGL
種類
シーン
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

ヒーローの「次へ」を押すと、画像が小さな立方体に割れ、左上から順に手前へ倒れながら落ちていきます。抜けた所には奥から次の画像が少し大きく暗い状態で現れ、落ちきるころには等倍の元の明るさに戻ります。「前へ」なら右上から崩れます。見出しやボタンは HTML のまま上に置いてあるので、崩れるのは画像だけです。

立方体は1つの InstancedMesh で、縦18段、横は表示する領域の比から決めた数を一度に描いています。立方体ごとに持っている情報は「何列目・何段目か」だけで、表の面には画像のうちその位置が受け持つ範囲を uv で貼りました。倒れる角度や落ちる量、手前へ来る量は、全体の進み具合と立方体ごとの順番から頂点シェーダーが計算するので、JavaScript で1つずつ動かす処理はありません。順番は左上からの距離に少しのばらつきを足したもので、倒れ始める時刻が全体の6割の範囲に散らばります。

止まっている立方体の横の面は、その向きの隣が動き出すまで描かないことにしました。表の面がすき間なく並んでいる間に横の面まで描くと、継ぎ目に細い暗い線が縦に並んで見えることがあります。隣が動き出したかどうかは、頂点シェーダーの中でわかります。ばらつきを列と段から計算する式にしておいたので、隣の立方体の進みも同じ式で求められるのです。

崩れ方の重さを決めるのは段の数です。18段だと粒のそろった崩れ方に見え、減らすと大きな塊が倒れる重たい崩れ方に、増やすと砂のように細かく崩れます。落ちきるまでは2秒。始めと終わりの絵は元の画像そのものなので、canvas を表示するのは切り替えのあいだだけにして、終わったら img 要素に戻しています。

コード

HTML
<section class="block-hero" aria-roledescription="カルーセル" aria-label="海辺の風景">
  <!-- 画像は WebGL が読むので、同じオリジンに置く(別のドメインなら crossorigin="anonymous" を付け、配信側で CORS を許す) -->
  <div class="block-hero__media">
    <img class="block-hero__image" src="/img/dawn.jpg" alt="朝焼けに染まる静かな海" data-name="朝焼けの海">
    <img class="block-hero__image" src="/img/cove.jpg" alt="晴れた昼下がりの入り江" data-name="昼下がりの入り江" hidden>
    <img class="block-hero__image" src="/img/dusk.jpg" alt="夕暮れの岬と水平線" data-name="夕暮れの岬" hidden>
    <!-- 立方体に割れて崩れる切り替えを描く canvas。切り替えのあいだだけ表示する -->
    <canvas class="block-hero__canvas" aria-hidden="true" hidden></canvas>
  </div>

  <div class="block-hero__inner">
    <p class="block-hero__eyebrow">SEASIDE HOTEL</p>
    <h1 class="block-hero__title">波の音で、<br>目を覚ます。</h1>
    <p class="block-hero__lead">ここに説明文が入ります。</p>
    <a class="block-hero__button" href="/rooms/">空室を探す</a>
  </div>

  <div class="block-hero__controls">
    <p class="block-hero__caption" aria-live="polite">
      <span class="block-hero__count">01 / 03</span>
      <span class="block-hero__name">朝焼けの海</span>
    </p>
    <button class="block-hero__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="block-hero__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>

CSS
.block-hero {
  --ink: #ffffff;
  --soft: #eeece6;
  --mute: #dedcd6;
  --on-ink: #111111;
  --gutter: clamp(16px, 7.5vw, 96px);
  position: relative;
  display: grid;
  grid-template-rows: 1fr auto;
  gap: 48px;
  box-sizing: border-box; /* 高さに上下の余白を含める */
  min-height: min(90vh, 720px);
  padding: 96px var(--gutter) 56px;
  overflow: clip;
  color: var(--ink);
  background: #111111; /* 画像が届くまでの背景色 */
}

/* 画像と、切り替えのあいだだけ重ねる canvas。どちらも親要素いっぱいに object-fit: cover で表示する */
.block-hero__media { position: absolute; inset: 0; }
.block-hero__media > * { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
.block-hero__media > [hidden] { display: none; }
.block-hero__image { object-fit: cover; }

/* 文字の側を濃くした黒のオーバーレイ。canvas の上にも重なるので、崩れている間も文字が読める */
.block-hero__media::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(90deg, rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0.3));
}

.block-hero__inner { position: relative; align-self: center; max-width: 34em; }

.block-hero__eyebrow {
  margin: 0;
  color: var(--mute);
  font-size: 13px;
  font-weight: 700;
  letter-spacing: 0.14em;
  text-transform: uppercase;
}

.block-hero__title { margin: 24px 0 0; font-size: clamp(40px, 8vw, 72px); font-weight: 800; line-height: 1.05; letter-spacing: -0.02em; }
.block-hero__lead { margin: 24px 0 0; color: var(--soft); font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; word-break: auto-phrase; }

.block-hero__button {
  display: inline-grid;
  place-items: center;
  height: 48px;
  margin-top: 32px;
  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;
}

.block-hero__controls { position: relative; display: flex; align-items: center; justify-content: flex-end; gap: 16px; }
.block-hero__caption { display: flex; align-items: baseline; gap: 16px; margin: 0 8px 0 0; line-height: 1; white-space: nowrap; }
.block-hero__count { color: var(--mute); font-size: 13px; font-weight: 600; letter-spacing: 0.04em; font-variant-numeric: tabular-nums; }
.block-hero__name { font-size: 15px; font-weight: 600; letter-spacing: 0.04em; }

/* 線の丸のボタン。アイコンは 20px で描くので、線は 48 ÷ 20 */
.block-hero__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;
}

.block-hero__arrow svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }
.block-hero a:focus-visible, .block-hero__arrow:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }

@media (max-width: 600px) {
  .block-hero__caption { flex-direction: column; gap: 8px; margin-right: auto; }
}

JavaScript
import * as THREE from "three";
import { gsap } from "gsap";

const DURATION = 2; // 崩れ落ちきるまで(秒)
const ROWS = 18; // 立方体の行の数。列の数は表示する領域の比から決める
const MAX = 1440; // 用意しておく立方体の数(列 80 まで)
const FOV = 35;
// 立方体は画面の高さを 2 とした座標で、表の面が z = 0 に並ぶ。p はその立方体の進み(0..1)。
// SPREAD は倒れ始める時刻の広がりで、残りの 0.4 で 1 つの立方体が落ちきる。TILT は倒れる角度、DROP は落ちる量、NEAR は手前へ来る量
const blockVertex = /* glsl */ `
  attribute vec2 aCell; uniform vec2 uGrid; uniform float uAspect, uProgress, uDir; varying vec2 vUv; varying float vShade;
  const float SPREAD = 0.6, TILT = 1.9, DROP = 2.8, NEAR = 1.1;
  float progressOf(vec2 cell) { // ばらつきは列と行のハッシュなので、隣の立方体の進みも同じ式で求められる
    float col = uDir > 0.0 ? cell.x : uGrid.x - 1.0 - cell.x; // 次へは左上から、前へは右上から
    float order = (0.55 * col / max(uGrid.x - 1.0, 1.0) + 0.45 * cell.y / (uGrid.y - 1.0)) * 0.85 + fract(sin(dot(cell, vec2(12.9898, 78.233))) * 43758.5453) * 0.15;
    return clamp((uProgress - order * SPREAD) / (1.0 - SPREAD), 0.0, 1.0);
  }
  void main() {
    float p = progressOf(aCell);
    // 止まっている立方体の横の面は、その向きの隣が動き出すまで描かない(描くと、表の面の継ぎ目に細い線になって写る)
    if (abs(normal.z) < 0.5 && p <= 0.0 && progressOf(aCell + vec2(normal.x, -normal.y)) <= 0.0) { gl_Position = vec4(2.0, 2.0, 2.0, 1.0); return; }
    vec2 size = vec2(2.0 * uAspect, 2.0) / uGrid; vUv = (vec2(aCell.x, uGrid.y - 1.0 - aCell.y) + uv) / uGrid; // 画像のうち、この立方体が受け持つ範囲
    float a = TILT * p * p, c = cos(a), s = sin(a); // 下の手前の辺を軸に、上の辺が手前へ来る向きに倒す
    vec3 q = position * vec3(size, size.y) * 1.003 - vec3(0.0, -0.5 * size.y, 0.5 * size.y); // 1.003 倍で、並んだ面の継ぎ目をふさぐ
    q = vec3(q.x, q.y * c - q.z * s, q.y * s + q.z * c); vec3 n = vec3(normal.x, normal.y * c - normal.z * s, normal.y * s + normal.z * c);
    vec2 center = vec2(-uAspect + (aCell.x + 0.5) * size.x, 1.0 - (aCell.y + 0.5) * size.y);
    vec3 light = normalize(vec3(-0.3, 0.6, 1.0)); vShade = clamp(0.3 + 0.7 * dot(n, light) / light.z, 0.15, 1.1) * (1.0 - 0.3 * p); // 正面を向いた表の面が元の明るさになるように割る
    gl_Position = projectionMatrix * viewMatrix * vec4(q + vec3(center.x, center.y - 0.5 * size.y - DROP * p * p, NEAR * p), 1.0);
  }
`;
// uCover は object-fit: cover と同じ切り抜き。奥の平面(次の画像)は 1.06 倍・少し暗い状態から、等倍・元の明るさへ戻す
const blockFragment = "uniform sampler2D uMap; uniform vec2 uCover; varying vec2 vUv; varying float vShade; void main() { gl_FragColor = vec4(texture2D(uMap, (vUv - 0.5) * uCover + 0.5).rgb * vShade, 1.0); }";
const backVertex = "varying vec2 vUv; void main() { vUv = uv; gl_Position = vec4(position.xy, 0.9999, 1.0); }";
const backFragment = "uniform sampler2D uMap; uniform vec2 uCover; uniform float uZoom, uBright; varying vec2 vUv; void main() { gl_FragColor = vec4(texture2D(uMap, (vUv - 0.5) * uCover / uZoom + 0.5).rgb * uBright, 1.0); }";
const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches, backEase = gsap.parseEase("power2.out");
document.querySelectorAll(".block-hero").forEach((hero) => {
  const media = hero.querySelector(".block-hero__media"), canvas = hero.querySelector(".block-hero__canvas");
  const images = [...hero.querySelectorAll(".block-hero__image")];
  const count = hero.querySelector(".block-hero__count"), name = hero.querySelector(".block-hero__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, antialias: true });
    renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
    const scene = new THREE.Scene(), camera = new THREE.PerspectiveCamera(FOV, 1, 1, 6);
    camera.position.z = 1 / Math.tan(THREE.MathUtils.degToRad(FOV / 2)); // z = 0 の面が、ちょうど領域いっぱいに映る距離
    // 先に GPU へ送り、押した瞬間に詰まらせない
    const textures = images.map((img) => { const t = new THREE.Texture(img); img.decode().then(() => { t.needsUpdate = true; renderer.initTexture(t); }, () => {}); return t; });
    const back = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), new THREE.ShaderMaterial({ uniforms: { uMap: { value: null }, uCover: { value: new THREE.Vector2() }, uZoom: { value: 1 }, uBright: { value: 1 } }, vertexShader: backVertex, fragmentShader: backFragment, depthWrite: false, depthTest: false }));
    back.renderOrder = -1;
    const geometry = new THREE.BoxGeometry(1, 1, 1), cells = new THREE.InstancedBufferAttribute(new Float32Array(MAX * 2), 2);
    geometry.setAttribute("aCell", cells);
    const uniforms = { uMap: { value: null }, uCover: { value: new THREE.Vector2() }, uGrid: { value: new THREE.Vector2() }, uAspect: { value: 1 }, uProgress: { value: 0 }, uDir: { value: 1 } };
    const blocks = new THREE.InstancedMesh(geometry, new THREE.ShaderMaterial({ uniforms, vertexShader: blockVertex, fragmentShader: blockFragment }), MAX);
    back.frustumCulled = blocks.frustumCulled = false; // 位置は頂点シェーダーで決めるので、three の範囲の計算は当てにならない
    scene.add(back, blocks);
    // 領域の比に合わせて列の数を決め、立方体を並べ直す
    const sizes = new ResizeObserver(() => {
      const w = media.clientWidth, h = media.clientHeight, cols = Math.min(Math.max(1, Math.round((ROWS * w) / h)), MAX / ROWS);
      renderer.setSize(w, h, false); // 大きさは CSS が決めるので、style は書き換えない
      camera.aspect = uniforms.uAspect.value = w / h;
      camera.updateProjectionMatrix();
      for (let i = 0; i < cols * ROWS; i++) cells.setXY(i, i % cols, Math.floor(i / cols));
      cells.needsUpdate = true;
      blocks.count = cols * ROWS;
      uniforms.uGrid.value.set(cols, ROWS);
    });
    sizes.observe(media);
    gl = { renderer, scene, camera, textures, back, blocks, 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, back, blocks } = gl;
    const b = blocks.material.uniforms, k = back.material.uniforms, state = { s: 0 };
    b.uMap.value = textures[index]; k.uMap.value = textures[next]; b.uDir.value = step; // 立方体は今の画像、奥の平面は次の画像
    cover(from, b.uCover.value); cover(to, k.uCover.value);
    const render = () => { b.uProgress.value = state.s; k.uZoom.value = 1.06 - 0.06 * backEase(state.s); k.uBright.value = 0.7 + 0.3 * backEase(state.s); renderer.render(scene, camera); };
    tween = gsap.timeline({ onComplete: () => { show(next); canvas.hidden = true; } }) // 落ちきったら <img> に戻す
      .fromTo(state, { s: 0 }, { s: 1, duration: DURATION, ease: "none", onUpdate: render, onStart: () => { render(); 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.25);
  };
  hero.querySelectorAll(".block-hero__arrow").forEach((button) => button.addEventListener("click", () => go(Number(button.dataset.step))));
  hero.destroy = () => { // PJAX で差し替えるときに呼ぶ。放っておくと WebGL のコンテキストが積み上がる
    tween?.kill(); if (!gl) return;
    gl.sizes.disconnect(); gl.back.geometry.dispose(); gl.back.material.dispose(); gl.blocks.geometry.dispose(); gl.blocks.material.dispose(); gl.blocks.dispose();
    gl.textures.forEach((texture) => texture.dispose()); gl.renderer.dispose(); gl.renderer.forceContextLoss();
  };
});

インタラクションデザインについて、
お気軽にご相談ください。

ここに並べているのは、Web サイトの中で動く UI のサンプルです。新しく作るサイトに組み込むことも、いま公開中のサイトに、ボタンやヒーロー、スクロールの演出だけを足すこともできます。設計から実装までの進め方はWeb サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。