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

ヒーローの背景の暗がりに光の粒が立ちのぼる

デモ

仕様

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

しくみと調整

900 個の光の粒を Points 1 つで描き、位置はすべて頂点シェーダーで時刻から計算します。粒ごとに持たせるのは 4 つの乱数(横の位置・高さ・奥行き・大きさと速さ)だけで、CPU で毎フレーム座標を書き換えることはしません。粒は中心ほど明るいぼやけた丸にし、加算合成で重ねて光らせます。

上昇の速さは、1 ループ(12 秒)で縦の範囲の 1 倍か 2 倍を進む値にしています。高さを mod で折り返すので、ひと回りでちょうど元の高さに戻り、継ぎ目が出ません。上下の端に近づくと薄くしてから折り返すので、粒は上でふっと消え、下から現れます。奥の粒ほど小さく薄くして、奥行きを出しています。

頂点の位置は 0 のまま渡してシェーダーで動かすので、three.js が測る形の範囲(境界球)は原点の 1 点になり、実際の粒の広がりと合いません。カメラを動かして原点が画面から外れると、粒がまとめて消えるので、frustumCulled を false にしておきます。また、加算合成は重なるほど明るくなるので、粒を多く・大きくしすぎると画面が白く飛びます。

粒の大きさは画面の高さと描画の倍率に合わせて掛けているので、どの画面でも高さ 720px のときと同じ見え方になります。横長の画面ではカメラを寄せて、左右の端まで粒を行き渡らせます。地と粒の色は CSS の変数で変えられ、粒の数や縦の範囲を変えれば、雪や蛍、舞い上がる火の粉のようにも見せられます。宿泊・リラクゼーション・夜のイベントなど、落ち着いた暗いヒーローに向いています。

コード

HTML
<section class="drift-hero">
  <!-- 背景は飾りなので、読み上げからは外す -->
  <canvas class="drift-hero__canvas" aria-hidden="true"></canvas>
  <div class="drift-hero__inner">
    <p class="drift-hero__eyebrow">NIGHT RETREAT</p>
    <h1 class="drift-hero__title">灯りのそばで、<br>静かな夜を。</h1>
    <p class="drift-hero__lead">ここに説明文が入ります。</p>
    <a class="drift-hero__button" href="/reserve/">空き状況を見る</a>
  </div>
</section>

CSS
.drift-hero {
  /* 地と粒の色はここで決める。script.js がこの 2 つを読んで WebGL へ渡すので、#rrggbb で書く */
  --night: #111111;
  --glow: #e0d3c0; /* 粒の光。グレージュを文字の色と半々に混ぜた、彩度の低い暖かい色 */
  --ink: #eeece6;
  --mute: #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);
}

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

.drift-hero__inner {
  position: relative; /* 背景の上に載せる */
  max-width: 32em;
}

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

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

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

.drift-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;
}

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

JavaScript
import * as THREE from "three";

const DURATION = 12; // 粒がひと回りして、同じ高さに戻るまで(秒)
const COUNT = 900; // 粒の数。加算合成なので、多く・大きくしすぎると重なって白く飛ぶ
const RANGE = 7; // 縦にループする範囲(カメラからの距離は 5)
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

const vertexShader = /* glsl */ `
  uniform float uTime;
  uniform float uDuration;
  uniform float uRange;
  uniform float uScale;
  attribute vec4 aSeed;
  varying float vAlpha;

  void main() {
    // 上昇の速さは、1 ループで範囲の 1 倍か 2 倍。ひと回りでちょうど元の高さに戻る
    float laps = 1.0 + floor(aSeed.w * 2.0);
    float y = mod(aSeed.y * uRange + uTime / uDuration * uRange * laps, uRange) - uRange * 0.5;
    float sway = sin(uTime / uDuration * 6.2831853 * laps + aSeed.x * 20.0) * 0.15;
    vec3 pos = vec3((aSeed.x - 0.5) * 9.0 + sway, y, -3.0 + aSeed.z * 5.0);

    vec4 mv = modelViewMatrix * vec4(pos, 1.0);
    // 奥の粒ほど小さく(遠近)。uScale は画面の大きさに合わせる倍率
    gl_PointSize = (5.0 + 14.0 * aSeed.w) * uScale * (4.0 / -mv.z);
    gl_Position = projectionMatrix * mv;
    // 上下の端で消えてから折り返す
    vAlpha = (0.3 + 0.7 * aSeed.z) * smoothstep(0.0, 0.8, uRange * 0.5 - abs(y));
  }
`;

// 中心ほど明るい、ぼやけた丸
const fragmentShader = /* glsl */ `
  uniform vec3 uColor;
  varying float vAlpha;
  void main() {
    float d = length(gl_PointCoord - 0.5);
    float glow = smoothstep(0.5, 0.0, d);
    gl_FragColor = vec4(uColor * glow, glow * vAlpha);
  }
`;

document.querySelectorAll(".drift-hero").forEach((hero) => {
  const css = (name) => getComputedStyle(hero).getPropertyValue(name).trim();
  const renderer = new THREE.WebGLRenderer({ canvas: hero.querySelector(".drift-hero__canvas"), antialias: true });
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  renderer.setClearColor(css("--night")); // CSS の地とそろえる
  const scene = new THREE.Scene();
  const camera = new THREE.PerspectiveCamera(50, 1, 0.1, 50); // 縦横比は resize で入れる
  camera.position.z = 5;

  // 粒ごとに 4 つの乱数(横の位置と揺れ・高さ・奥行きと濃さ・大きさと速さ)
  const seeds = new Float32Array(COUNT * 4);
  for (let i = 0; i < seeds.length; i++) seeds[i] = Math.random();
  const geometry = new THREE.BufferGeometry();
  geometry.setAttribute("position", new THREE.BufferAttribute(new Float32Array(COUNT * 3), 3)); // 位置はシェーダーが決める
  geometry.setAttribute("aSeed", new THREE.BufferAttribute(seeds, 4));
  // THREE.Color は線形の値に直すので、シェーダーには hex を 255 で割った値をそのまま渡す
  const hex = Number.parseInt(css("--glow").slice(1), 16);
  const uniforms = { uTime: { value: 0 }, uDuration: { value: DURATION }, uRange: { value: RANGE }, uScale: { value: 1 },
    uColor: { value: new THREE.Vector3(hex >> 16, (hex >> 8) & 255, hex & 255).divideScalar(255) } };
  const material = new THREE.ShaderMaterial({
    uniforms, vertexShader, fragmentShader, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending,
  });
  const points = new THREE.Points(geometry, material);
  points.frustumCulled = false; // 頂点の位置が 0 のままなので、カリングで消えないようにする
  scene.add(points);

  const draw = (t) => {
    uniforms.uTime.value = t % DURATION;
    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 は書き換えない
    uniforms.uScale.value = (renderer.getPixelRatio() * h * camera.zoom) / 720; // 高さ 720px の画面で 1
    if (reduceMotion) draw(2.5); // 動きを減らす設定では、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();
    geometry.dispose(); material.dispose(); renderer.dispose(); renderer.forceContextLoss();
  };
});

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

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