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

ヒーローの背景で暗い床のドット行列がうねって波打つ

デモ

仕様

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

しくみと調整

128 × 89、1 万個を超えるドットを、円盤(CircleGeometry)の InstancedMesh 1 つで描いています。描き直すたびに、ドットごとの高さを「うねり 2 つと、右奥から広がる波紋」を足した式から計算し、位置・大きさ・色を書き換えます。前のコマの高さに足していかないので、形が崩れていきません。波の時間の係数を整数にしてあるので、6 秒でひと回りしてループがつながります。

高さを谷 0・山 1 の値にそろえ、ドットの大きさを 0.5〜1.4 倍、色を谷の墨のスレートから山の淡いセージへ移します。色相ではなく明るさの差で起伏を見せるので、落ち着いた色でも波が読めます。色は値を 2 乗してから混ぜるので、山の頂だけが明るく光り、谷は暗い床に沈みます。奥は霧(Fog)で地の色へ溶かし、床の果てを見せません。

カメラは動かないので、ドットを 1 つずつカメラへ向ける計算はせず、カメラの回転をそのまま全部のドットの行列に入れて正面を向かせます。行列を毎回書き換えるので、frustumCulled を切っておきます。最初に計算した境界の球が古いまま残ると、見えているドットが描かれないことがあります。1 万個ぶんの計算は JS で 1〜2 ミリ秒ほどで、毎コマ回しても足ります。さらに数を増やすなら、高さの式を頂点シェーダーへ移します。

谷と山の色、地の色は CSS 変数で変えられます。うねりの向きと波長は高さの式の係数で、密度はドットの間隔と数で調整します。横長の画面ではカメラを寄せて、床の左右の端を見せないようにしています。文字の側には地の色の幕をかけて、明るい山の上でも読めるようにしています。動きを減らす設定では 1 枚だけ描いて止め、画面の外にあるあいだは描き直しを止めます。データ・AI・フィンテックの暗いヒーローに向いています。

コード

HTML
<section class="dots-hero">
  <!-- 背景は飾りなので、読み上げからは外す -->
  <canvas class="dots-hero__canvas" aria-hidden="true"></canvas>
  <div class="dots-hero__inner">
    <p class="dots-hero__eyebrow">Analytics</p>
    <h1 class="dots-hero__title">データの変化を、<br>ひと目でつかむ。</h1>
    <p class="dots-hero__lead">ここに説明文が入ります。</p>
    <a class="dots-hero__button" href="/contact/">導入を相談する</a>
  </div>
</section>

CSS
.dots-hero {
  /* 地とドットの色。script.js がこの 3 つを読んでシーンへ渡す */
  --night: #111111;
  --low: #475c6a; /* 谷のドット(墨のスレート) */
  --high: #bed2b7; /* 山のドット(淡いセージ)。谷との明るさの差で起伏を見せる */
  --ink: #eeece6;
  --mute-small: #a8a6a0;
  --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(--night);
}

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

/* 文字の側だけ、地の色の幕で落とす。山のドットは明るいので、これが無いと文字が読めない */
.dots-hero::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(100deg, color-mix(in srgb, var(--night) 90%, transparent), color-mix(in srgb, var(--night) 65%, transparent) 50%, transparent 80%);
  pointer-events: none;
}

.dots-hero__inner {
  position: relative;
  z-index: 1; /* 幕(::after)より前に出す */
  max-width: 32em;
}

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

.dots-hero__title {
  margin: 24px 0 0;
  font-size: clamp(38px, 8vw, 64px);
  line-height: 1.05;
  letter-spacing: -0.02em;
  text-wrap: balance; /* 2 行に折り返すとき、行の長さをそろえる */
}

.dots-hero__lead {
  margin: 24px 0 0;
  font-size: 18px;
  line-height: 1.8;
  word-break: auto-phrase; /* 和文を文節の切れ目で折り返す(対応していないブラウザでは、ふつうに折り返す) */
}

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

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

/* 幅が狭いと、横に流す幕では文字の下が薄くなる。一様にかけて、ドットは透かして見せる */
@media (max-width: 720px) {
  .dots-hero::after { background: color-mix(in srgb, var(--night) 70%, transparent); }
}

JavaScript
import * as THREE from "three";

const DURATION = 6; // 波がひと回りして、同じ形に戻るまで(秒)
const COLS = 128; // 横に並べるドットの数
const ROWS = 89; // 奥へ並べる列の数
const SPACING = 0.25; // ドットの間隔
const NEAR_Z = 4; // いちばん手前の列の位置。ここから奥へ並べる
const DOT = 0.05; // ドットの半径
const AMPLITUDE = 0.72; // 高さの最大。色と大きさを 0〜1 にそろえるのに使う
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

// 床の高さ。うねり 2 つと、右奥から広がる波紋を足す。a は 1 周で 2π 進む位相で、係数 2・1・3 を整数にするとループがつながる
function height(x, z, a) {
  const ripple = Math.hypot(x - 2.5, z + 3);
  return (
    0.32 * Math.sin(x * 0.85 + z * 0.3 - a * 2) +
    0.18 * Math.sin(x * 0.35 - z * 0.9 + a + 1.3) +
    0.22 * Math.sin(ripple * 1.7 - a * 3) * Math.exp(-ripple * 0.15)
  );
}

document.querySelectorAll(".dots-hero").forEach((hero) => {
  const css = (name) => getComputedStyle(hero).getPropertyValue(name).trim();
  const renderer = new THREE.WebGLRenderer({ canvas: hero.querySelector(".dots-hero__canvas"), antialias: true });
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  const scene = new THREE.Scene();
  scene.background = new THREE.Color(css("--night")); // CSS の地とそろえる
  scene.fog = new THREE.Fog(css("--night"), 8, 23); // 奥のドットは地の色へ溶かす
  const camera = new THREE.PerspectiveCamera(40, 1, 0.1, 60); // 縦横比は resize で入れる
  camera.position.set(0, 2.6, 6.5);
  camera.lookAt(0, 0, -2);

  // 1 万個を超えるドットを、1 つの InstancedMesh で描く。ドットは円盤で、全部にカメラの向きを与えて正面を向かせる
  const dots = new THREE.InstancedMesh(new THREE.CircleGeometry(DOT, 12), new THREE.MeshBasicMaterial(), COLS * ROWS);
  const [low, high, color, matrix, size] = [new THREE.Color(css("--low")), new THREE.Color(css("--high")), new THREE.Color(), new THREE.Matrix4(), new THREE.Vector3()];
  dots.setColorAt(0, low); // 1 つ目の色を入れると、ドットごとの色の欄(instanceColor)ができる
  dots.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
  dots.instanceColor.setUsage(THREE.DynamicDrawUsage);
  dots.frustumCulled = false; // 行列を毎回書き換えるので、古い境界球で消えないようにする
  scene.add(dots);
  const facing = new THREE.Matrix4().makeRotationFromQuaternion(camera.quaternion);

  const draw = (t) => {
    const a = ((t % DURATION) / DURATION) * Math.PI * 2;
    for (let row = 0, i = 0; row < ROWS; row++) {
      const z = NEAR_Z - row * SPACING;
      for (let col = 0; col < COLS; col++, i++) {
        const x = (col - (COLS - 1) / 2) * SPACING;
        const y = height(x, z, a);
        const k = Math.min(Math.max((y / AMPLITUDE) * 0.5 + 0.5, 0), 1); // 谷 0 → 山 1
        dots.setMatrixAt(i, matrix.copy(facing).scale(size.setScalar(0.5 + 0.9 * k)).setPosition(x, y, z)); // 山ほど大きく
        dots.setColorAt(i, color.lerpColors(low, high, k * k)); // k を 2 乗して、山だけを明るくする
      }
    }
    dots.instanceMatrix.needsUpdate = true;
    dots.instanceColor.needsUpdate = true;
    renderer.render(scene, camera);
  };
  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 は書き換えない
    if (reduceMotion) draw(1.75); // 動きを減らす設定では、1 枚だけ描いて止める
  });
  const view = new IntersectionObserver(([e]) => renderer.setAnimationLoop(e.isIntersecting && !reduceMotion ? (ms) => draw(ms / 1000) : null)); // 画面の外では回さない
  sizes.observe(hero); view.observe(hero);
  hero.destroy = () => { // PJAX で幕ごと捨てるときに呼ぶ。放っておくと WebGL の文脈が積み上がる
    renderer.setAnimationLoop(null); sizes.disconnect(); view.disconnect();
    dots.geometry.dispose(); dots.material.dispose(); dots.dispose(); renderer.dispose(); renderer.forceContextLoss();
  };
});

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

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