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

ヒーローの背景で地形図のような等高線が生成りの紙の上でうごめく

デモ

仕様

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

しくみと調整

全画面の板にフラグメントシェーダーを 1 枚かけて描く、地形図のような背景です。ノイズでゆがめた座標でもう一度ノイズを引いて(ドメインワープ)高さの場を作り、高さに線の本数を掛けた値が整数になる所に線を引きます。ゆがみを引く位置を円周に沿って回すので、地形は平行に流れるのではなく形そのものがうごめき、12 秒のひと回りで元の形に戻ります。

線の太さは、いちばん近い整数までの距離を fwidth で割って、画素の単位にそろえます。こうすると、斜面の急な所でも緩い所でも同じ太さの線になります。5 本おきに太く濃い計曲線を入れ、低い所はセージ、高い所はグレージュへ紙をごく薄く色づけると(段彩)、地図らしい奥行きが出ます。

画素の単位にそろえた線は、そのままだと高解像度の画面で細く薄くなります。描画の倍率と画面の高さから倍率を出し、高さ 720px の画面と同じ見え方にそろえています。見出しや本文を重ねる側は、紙の色の幕を重ねて線を薄めると、線が文字をまたいでも読めます。

線の密度(高さ 1 あたりの本数)・計曲線の間隔・段彩の濃さと、紙・線・段彩の 4 色で調整します。色は CSS の変数で変えられ、紙と線を入れ替えると暗い地の地形図になります。アウトドア・建築・地図やデータを扱うサービスのヒーローに向いています。

コード

HTML
<section class="topo-hero">
  <!-- 背景は飾りなので、読み上げからは外す -->
  <canvas class="topo-hero__canvas" aria-hidden="true"></canvas>
  <div class="topo-hero__inner">
    <p class="topo-hero__eyebrow">SINCE 2012</p>
    <h1 class="topo-hero__title">歩いた道を、<br>地図に残す。</h1>
    <p class="topo-hero__lead">ここに説明文が入ります。</p>
    <a class="topo-hero__button" href="/courses/">コースを探す</a>
  </div>
</section>

CSS
.topo-hero {
  /* 紙・線・段彩の色。script.js がこの 4 つを読んでシェーダーへ渡すので、#rrggbb で書く。紙と線を入れ替えると暗い地の地形図になる */
  --paper: #f2f1ed; /* 生成り */
  --ink: #4a5b66; /* 等高線。墨のスレート */
  --low: #739374; /* 低い所の段彩。セージ */
  --high: #a28767; /* 高い所の段彩。グレージュ */
  --text: #262626;
  --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(--text);
  /* 読み込み中と、WebGL が使えないときの地 */
  background-color: var(--paper);
}

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

/* 文字の側だけ、紙の色を重ねて線を薄める。線が文字をまたぐと、本文が読みにくい */
.topo-hero::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(90deg, color-mix(in srgb, var(--paper) 86%, transparent), color-mix(in srgb, var(--paper) 72%, transparent) 42%, transparent 70%);
  pointer-events: none;
}

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

.topo-hero__eyebrow {
  margin: 0;
  font-size: 13px;
  font-weight: 700;
  letter-spacing: 0.14em;
  text-transform: uppercase;
}

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

.topo-hero__lead {
  margin: 24px 0 0;
  font-size: 18px;
  line-height: 1.8;
  word-break: auto-phrase; /* 和文を文節の切れ目で折り返す(対応していないブラウザでは、ふつうに折り返す) */
  line-break: strict; /* 小さい「ョ」や「ー」から行を始めない */
}

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

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

/* 幅が狭いと、横に流す幕では文字の下に線が濃く残る。一様にかける */
@media (max-width: 720px) {
  .topo-hero::after { background: color-mix(in srgb, var(--paper) 78%, transparent); }
}

JavaScript
import * as THREE from "three";

const DURATION = 12; // ゆがみがひと回りして、同じ地形に戻るまで(秒)
const LEVELS = 16; // 高さ 1 あたりの等高線の本数。増やすと線が密になる
const MAJOR_EVERY = 5; // 何本おきに太い計曲線を入れるか
const TINT = 0.14; // 段彩の濃さ(紙に混ぜる割合)
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

const vertexShader = `varying vec2 vUv; void main() { vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }`;

const fragmentShader = /* glsl */ `
  precision highp float;
  uniform vec2 uResolution;
  uniform float uPhase, uLevels, uMajorEvery, uScale, uTint;
  uniform vec3 uPaper, uInk, uLow, uHigh;
  varying vec2 vUv;

  vec2 hash2(vec2 p) {
    p = vec2(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3)));
    return -1.0 + 2.0 * fract(sin(p) * 43758.5453123);
  }

  // グラディエントノイズ(-1..1)
  float noise(vec2 p) {
    vec2 i = floor(p);
    vec2 f = fract(p);
    vec2 u = f * f * (3.0 - 2.0 * f);
    return mix(
      mix(dot(hash2(i), f), dot(hash2(i + vec2(1.0, 0.0)), f - vec2(1.0, 0.0)), u.x),
      mix(dot(hash2(i + vec2(0.0, 1.0)), f - vec2(0.0, 1.0)), dot(hash2(i + vec2(1.0, 1.0)), f - vec2(1.0, 1.0)), u.x),
      u.y
    );
  }

  float fbm(vec2 p) {
    float sum = 0.0;
    float amp = 0.55;
    for (int i = 0; i < 4; i++) {
      sum += amp * noise(p);
      p = mat2(1.6, 1.2, -1.2, 1.6) * p + 11.7;
      amp *= 0.5;
    }
    return sum;
  }

  // 等高線 1 本の濃さ。v の整数の位置に、halfWidth 画素の線を引く(画素は高さ 720px の画面での大きさ)
  float contour(float v, float halfWidth) {
    float dist = abs(fract(v + 0.5) - 0.5) / (max(fwidth(v), 1e-4) * uScale);
    return 1.0 - smoothstep(halfWidth - 0.6, halfWidth + 0.6, dist);
  }

  void main() {
    vec2 p = (vUv - 0.5) * vec2(uResolution.x / uResolution.y, 1.0) * 2.4;
    // 地形そのものを「ゆがめた座標で引いたノイズ」にする。ゆがみの引き位置を円周上で回すと、形がうごめく
    vec2 loopA = vec2(cos(uPhase), sin(uPhase));
    vec2 loopB = vec2(cos(uPhase * 2.0 + 1.3), sin(uPhase * 2.0 + 1.3));
    vec2 q = vec2(fbm(p + loopA * 0.45), fbm(p + vec2(5.2, 1.3) - loopA.yx * 0.45));
    float h = fbm(p * 0.85 + q * 1.3 + loopB * 0.18);
    float v = h * uLevels;
    // 何本目の線か。uMajorEvery 本おきに太く濃い計曲線にする
    float index = floor(v + 0.5);
    float major = 1.0 - step(0.5, mod(index, uMajorEvery));
    float line = contour(v, mix(0.55, 1.1, major)) * mix(0.5, 0.9, major);
    // 段彩: 低い所から高い所へ、紙をごく薄く 2 色の間で色づける
    vec3 color = mix(uPaper, mix(uLow, uHigh, smoothstep(-0.3, 0.6, h)), uTint);
    gl_FragColor = vec4(mix(color, uInk, line), 1.0);
  }
`;

document.querySelectorAll(".topo-hero").forEach((hero) => {
  const css = (name) => getComputedStyle(hero).getPropertyValue(name).trim();
  // 色は CSS 変数から読む。THREE.Color は線形に直すので、hex を 255 で割った値をそのまま渡す
  const rgb = (name) => { const hex = Number.parseInt(css(name).slice(1), 16); return { value: new THREE.Vector3(hex >> 16, (hex >> 8) & 255, hex & 255).divideScalar(255) }; };
  const uniforms = { uPhase: { value: 0 }, uResolution: { value: new THREE.Vector2(1, 1) }, uScale: { value: 1 },
    uLevels: { value: LEVELS }, uMajorEvery: { value: MAJOR_EVERY }, uTint: { value: TINT },
    uPaper: rgb("--paper"), uInk: rgb("--ink"), uLow: rgb("--low"), uHigh: rgb("--high") };
  const renderer = new THREE.WebGLRenderer({ canvas: hero.querySelector(".topo-hero__canvas") });
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  const scene = new THREE.Scene();
  const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); // 板は画面ぴったりの 2 × 2。頂点シェーダーがそのまま座標に使う
  const plane = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), new THREE.ShaderMaterial({ uniforms, vertexShader, fragmentShader }));
  scene.add(plane);
  const draw = (t) => { uniforms.uPhase.value = ((t % DURATION) / DURATION) * Math.PI * 2; renderer.render(scene, camera); };
  const sizes = new ResizeObserver(() => {
    const { clientWidth: w, clientHeight: h } = hero;
    renderer.setSize(w, h, false); // 大きさは CSS が決めるので、style は書き換えない
    uniforms.uResolution.value.set(w, h);
    uniforms.uScale.value = (renderer.getPixelRatio() * h) / 720; // 線の太さを、地形と一緒に画面の高さに合わせる
    if (reduceMotion) draw(9); // 動きを減らす設定では、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();
    plane.geometry.dispose(); plane.material.dispose(); renderer.dispose(); renderer.forceContextLoss();
  };
});

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

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