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

ヒーローの背景でポインタが通ったタイルが浮き上がる

デモ

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

仕様

用途
ヒーロー
動作
DOM+WebGL
種類
シーン
使うもの
three/addons/geometries/RoundedBoxGeometry.js
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

角の丸い箱(RoundedBoxGeometry)を 22 × 13 枚並べ、1 つの InstancedMesh にまとめています。ポインタが動くたびに Raycaster で画面の位置から線を飛ばし、タイルの上面の高さに置いた見えない板と交わる点を、時刻と一緒に記録します。

タイルの高さは、記録した点ごとに「タイルからの近さ(半径 1.45 のガウス)× 通ってからの時間の重み」を出し、その最大で決めます。重みはすぐ上がってゆっくり沈む形にし、2.4 秒たった点は記録から外します。前のコマの高さに足していかないので、速く動かしても値が暴れず、止まれば必ず沈みきります。高さに合わせて色も墨のスレートから生成りへ移し、浮いたタイルは床に影を落とします。

行列を毎回書き換えるので、frustumCulled を切っておきます。最初に計算した境界の球が古いまま残ると、見えているタイルが描かれないことがあります。当たり判定は、タイルそのものではなく平らな板で取ります。浮いたタイルに当てると、当たる高さが変わるたびに交わる点が手前へずれ、浮き上がる位置が揺れるためです(タイルの番号が要るときは、タイルに当てて instanceId を読みます)。

浮き上がる範囲・沈みきるまでの時間・浮く高さと、タイルと床の色で調整します。タッチ端末と、動きを減らす設定のときは、つながずに平らなタイルのまま見せます。画面の外にあるときと、沈みきって動くものが無いあいだは、描き直しを止めます。SaaS やテック系のサービスのヒーローの背景に向いています。

コード

HTML
<section class="pad-hero">
  <!-- 背景は飾りなので、読み上げからは外す -->
  <canvas class="pad-hero__canvas" aria-hidden="true"></canvas>
  <div class="pad-hero__inner">
    <p class="pad-hero__eyebrow">NOW IN BETA</p>
    <h1 class="pad-hero__title">すべての操作を、<br>リアルタイムに。</h1>
    <p class="pad-hero__lead">ここに説明文が入ります。</p>
    <a class="pad-hero__button" href="/signup/">無料で試す</a>
  </div>
</section>

CSS
.pad-hero {
  /* タイルと床と光の色はここで決める。script.js がこの 5 つを読んでシーンへ渡す */
  --tile: #4a5b66;    /* 沈んでいるタイル(墨のスレート) */
  --lifted: #eeece6;  /* 浮き上がったタイル(生成り) */
  --floor: #161b1f;   /* すき間から見える床 */
  --sky: #e6e8e9;     /* 上から回り込む光 */
  --ground: #232b30;  /* 床からの照り返し */
  --ink: #ffffff;
  --soft: #eeece6;
  --gutter: clamp(24px, 7vw, 96px);
  position: relative;
  display: grid;
  align-items: center;
  min-height: min(90vh, 720px);
  padding: 0 var(--gutter);
  overflow: hidden;
  color: var(--ink);
  /* 読み込み中と、WebGL が使えないときの地 */
  background-color: var(--tile);
}

.pad-hero__canvas {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
}

/* 文字の側だけ、床の色の幕で落とす。浮き上がったタイルは明るいので、これが無いと白い文字が読めない */
.pad-hero::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(90deg, color-mix(in srgb, var(--floor) 88%, transparent), color-mix(in srgb, var(--floor) 60%, transparent) 45%, transparent 75%);
  pointer-events: none;
}

.pad-hero__inner {
  position: relative;
  z-index: 1; /* 幕(::after)より前に出す */
  max-width: 32em;
  text-shadow: 0 2px 24px var(--floor);
}

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

.pad-hero__title {
  margin: 24px 0 0;
  font-size: clamp(36px, 8vw, 64px);
  line-height: 1.05;
  letter-spacing: -0.02em;
}

.pad-hero__lead {
  margin: 24px 0 0;
  color: var(--soft);
  font-size: 18px;
  line-height: 1.8;
  word-break: auto-phrase; /* 和文を文節の切れ目で折り返す(「Web/アプリ」のように語の途中で割らない) */
}

.pad-hero__button {
  display: inline-grid;
  place-items: center;
  height: 48px;
  margin-top: 32px;
  padding: 0 32px;
  border-radius: 999px;
  background-color: var(--ink);
  color: #111111;
  font-size: 15px;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-decoration: none;
  text-shadow: none;
}

.pad-hero__button:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }

/* 幅が狭いと、横に流す幕では画面ぜんぶが暗くなる。一様に落として、タイルは透かして見せる */
@media (max-width: 720px) {
  .pad-hero::after { background: color-mix(in srgb, var(--floor) 70%, transparent); }
}

JavaScript
import * as THREE from "three";
import { RoundedBoxGeometry } from "three/addons/geometries/RoundedBoxGeometry.js";

const COLS = 22; // タイルの列の数
const ROWS = 13; // タイルの行の数
const TILE = 0.9; // タイルの幅
const PITCH = TILE + 0.08; // タイルの間隔(幅 + すき間)
const THICK = 0.3; // タイルの厚み
const LIFT = 1.1; // 浮き上がる高さ
const RADIUS = 1.45; // ポインタのまわりで浮き上がる範囲
const TRAIL = 2.4; // 何秒前までの軌跡を見るか(沈みきるまでの時間)
const canHover = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

// 通ってから s 秒たった点が、タイルをどれだけ持ち上げるか。すぐ上がって、ゆっくり沈む
const envelope = (s) => Math.exp(-s * 1.35) * (1 - 0.45 * Math.exp(-s * 25));

document.querySelectorAll(".pad-hero").forEach((hero) => {
  const css = (name) => getComputedStyle(hero).getPropertyValue(name).trim();
  const renderer = new THREE.WebGLRenderer({ canvas: hero.querySelector(".pad-hero__canvas"), antialias: true });
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  renderer.toneMapping = THREE.NeutralToneMapping;
  Object.assign(renderer.shadowMap, { enabled: true, type: THREE.PCFShadowMap });
  const scene = new THREE.Scene(); scene.background = new THREE.Color(css("--floor")); // CSS の床の色とそろえる
  const camera = new THREE.PerspectiveCamera(35, 1, 0.1, 100); // 縦横比は resize で入れる
  camera.position.set(0, 11.5, 8.5); camera.lookAt(0, 0, 0.6); // 斜め上から見下ろす

  // 左上からの光で、タイルの影を床に落とす
  const sun = new THREE.DirectionalLight("#ffffff", 2.4);
  sun.position.set(-6, 12, 7); sun.castShadow = true;
  sun.shadow.mapSize.set(2048, 2048);
  Object.assign(sun.shadow.camera, { left: -14, right: 14, top: 14, bottom: -14, near: 1, far: 40 });
  Object.assign(sun.shadow, { bias: -0.0005, normalBias: 0.02 });
  const floor = new THREE.Mesh(new THREE.PlaneGeometry(40, 30), new THREE.MeshStandardMaterial({ color: css("--floor"), roughness: 0.9 }));
  floor.rotation.x = -Math.PI / 2; floor.receiveShadow = true;
  scene.add(sun, new THREE.HemisphereLight(css("--sky"), css("--ground"), 1.1), floor);

  // タイルは 1 つの InstancedMesh にまとめ、行列と色を 1 枚ずつ書き換える
  const tiles = new THREE.InstancedMesh(new RoundedBoxGeometry(TILE, THICK, TILE, 2, 0.08), new THREE.MeshStandardMaterial({ roughness: 0.42 }), COLS * ROWS);
  tiles.castShadow = tiles.receiveShadow = true;
  tiles.frustumCulled = false; // 行列を書き換えるので、古い境界球で消えないようにする
  scene.add(tiles);
  const centers = Array.from({ length: COLS * ROWS }, (_, i) => new THREE.Vector2(((i % COLS) - (COLS - 1) / 2) * PITCH, (Math.floor(i / COLS) - (ROWS - 1) / 2) * PITCH));
  const [base, lifted, color, matrix] = [new THREE.Color(css("--tile")), new THREE.Color(css("--lifted")), new THREE.Color(), new THREE.Matrix4()];

  // 通ったあと(ポインタが床に当たった点と時刻)。タイルの高さは「点からの近さ × 経過時間の重み」の最大で決め、値を積み上げない
  const trail = [];
  const lift = (now) => {
    while (trail.length > 0 && now - trail[0].time > TRAIL) trail.shift();
    const weights = trail.map((sample) => envelope(now - sample.time));
    centers.forEach((c, i) => {
      let value = 0;
      trail.forEach(({ point }, k) => { value = Math.max(value, Math.exp(-((c.x - point.x) ** 2 + (c.y - point.z) ** 2) / RADIUS ** 2) * weights[k]); });
      tiles.setMatrixAt(i, matrix.makeTranslation(c.x, THICK / 2 + value * LIFT, c.y));
      tiles.setColorAt(i, color.lerpColors(base, lifted, Math.min(1, value * 1.6)));
    });
    tiles.instanceMatrix.needsUpdate = tiles.instanceColor.needsUpdate = true;
  };
  lift(0);

  // ポインタは、タイルの上面の高さに置いた見えない板に当てる(シーンには入れない)
  const hitPlane = new THREE.Mesh(new THREE.PlaneGeometry(200, 200), new THREE.MeshBasicMaterial());
  hitPlane.rotation.x = -Math.PI / 2; hitPlane.position.y = THICK; hitPlane.updateMatrixWorld();
  const raycaster = new THREE.Raycaster();
  const follow = (event) => {
    const rect = hero.getBoundingClientRect();
    raycaster.setFromCamera(new THREE.Vector2(((event.clientX - rect.left) / rect.width) * 2 - 1, -((event.clientY - rect.top) / rect.height) * 2 + 1), camera);
    const hit = raycaster.intersectObject(hitPlane)[0];
    if (hit) trail.push({ point: hit.point, time: event.timeStamp / 1000 });
  };
  // タッチ端末ではポインタが無く、動きを減らす設定の人には負担になるので、どちらでもつながない
  const live = canHover && !reduceMotion;
  if (live) hero.addEventListener("pointermove", follow);

  let settled = false; // 沈みきったら、次に動かすまで描き直さない
  const frame = (ms) => {
    if (trail.length === 0 && settled) return;
    lift(ms / 1000);
    renderer.render(scene, camera);
    settled = trail.length === 0;
  };
  const sizes = new ResizeObserver(() => {
    const { clientWidth: w, clientHeight: h } = hero;
    camera.aspect = w / h;
    camera.zoom = Math.max(1, camera.aspect / (16 / 9)); // 横長の画面では寄って、床の端を見せない
    camera.updateProjectionMatrix();
    renderer.setSize(w, h, false); // 大きさは CSS が決めるので、style は書き換えない
    renderer.render(scene, camera);
  });
  const view = new IntersectionObserver(([entry]) => renderer.setAnimationLoop(entry.isIntersecting && live ? frame : null)); // 画面の外では回さない
  sizes.observe(hero); view.observe(hero);
  hero.destroy = () => { // PJAX で幕ごと捨てるときに呼ぶ。放っておくと WebGL の文脈が積み上がる
    renderer.setAnimationLoop(null); sizes.disconnect(); view.disconnect();
    for (const mesh of [tiles, floor, hitPlane]) { mesh.geometry.dispose(); mesh.material.dispose(); }
    tiles.dispose(); renderer.dispose(); renderer.forceContextLoss();
  };
});

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

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