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

ヒーローの背景で夕焼けの中をワイヤーフレームの山並みが手前へ流れる

デモ

仕様

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

しくみと調整

平らな板(PlaneGeometry)の頂点を、頂点シェーダーで尾根のとがったノイズ(ridged fbm)の高さまで持ち上げて、山並みを作ります。進んだ距離のぶんだけ奥の地形の座標を引くので、カメラを前へ動かさなくても、山が手前へ流れてきます。中央は平らな谷にして、奥に沈む太陽を見せます。空と太陽は別の板のシェーダーで描き、地平線の画面上の高さを JS で求めて渡します。

ループの継ぎ目を消すため、ノイズの格子の番号を奥行きの向きだけ mod で折り返し、奥行き 64 ごとに同じ地形が繰り返すようにしています。14 秒でちょうど 1 周期ぶん進むので、ループがつながります。オクターブを重ねるときは、座標と一緒に周期も 2 倍にし、ずらすのは横の向きだけにします。どちらかが欠けると周期が崩れ、ループの継ぎ目で山が飛びます。

格子の線は、素材の wireframe では描きません。三角形の斜めの辺まで線になり、面も塗られないので、手前の山の向こうの線が透けて見えます。ここでは、フラグメントシェーダーで地形の座標の整数の位置に線を引き、fwidth で割って画面上の太さをそろえています。手前も奥も同じ細さの線になり、手前の山が奥の線を隠します。奥で線が画素より細かく詰まるところは薄めて、ちらつきを抑えます。

霧の色を地平線の色にそろえると、板の奥の端が空に溶けて見えなくなります。高さをシェーダーで付けるので、three.js が持つ境界の球は平らな板のままです。frustumCulled を切っておかないと、見えているはずの山が消えることがあります。線の太さは描画の画素で決まり、高解像度の画面では細く見えるので、そろえたいときは太さの値に描画の倍率を掛けます。画面の外では描画を止め、動きを減らす設定では 1 枚だけ描いて止めます。

山の高さ、谷の幅、格子 1 マスの大きさ、ノイズの細かさ、霧の距離で調整でき、空・太陽・地面・線の色は CSS 変数で変えられます。いまは、くすみローズとグレージュを中心にした落ち着いた夕暮れの色で、太陽・線・空の順に明るさを離しています。文字は、太陽にかからない左側に置き、空の色の幕をかけて読みやすくしています。縦長の画面ではカメラを引いて、山並みを収めます。平らなグリッドが流れる背景と違い、起伏のある 3D の地形と夕焼けの空があります。音楽・ゲーム・イベント告知のヒーローに向いています。

コード

HTML
<section class="terrain-hero">
  <!-- 背景は飾りなので、読み上げからは外す -->
  <canvas class="terrain-hero__canvas" aria-hidden="true"></canvas>
  <div class="terrain-hero__inner">
    <p class="terrain-hero__eyebrow">VOL.12</p>
    <h1 class="terrain-hero__title">夕焼けの、<br>その先へ。</h1>
    <p class="terrain-hero__lead">ここに説明文が入ります。</p>
    <a class="terrain-hero__button" href="/tickets/">チケットを見る</a>
  </div>
</section>

CSS
.terrain-hero {
  /* 空・太陽・地形の色はここで決める。script.js がこの変数を読んでシェーダーへ渡す */
  --sky-top: #141318; /* 墨にラベンダーグレーをわずかに差す */
  --sky-mid: #4a4560; /* 暗いラベンダーグレー */
  --sky-horizon: #b27771; /* 地平線と、奥の霧の色。くすみローズ */
  --sun-top: #e3d3b8; /* 淡いグレージュ */
  --sun-bottom: #d29b97; /* くすみローズ */
  --ground: #141318;
  --line: #d29b97; /* 手前の格子の線。くすみローズ */
  --line-far: #d2b999; /* 奥の格子の線。グレージュ(霧より明るくして、奥でも見せる) */
  --paper: #ffffff;
  --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(--paper);
  /* 読み込み中と、WebGL が使えないときの地 */
  background-color: var(--sky-top);
}

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

/* 文字の側だけ地を空の色で暗く落とす。格子の線が明るいので、これが無いと読めない。中央の太陽にはかけない */
.terrain-hero::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(100deg, color-mix(in srgb, var(--sky-top) 90%, transparent), color-mix(in srgb, var(--sky-top) 55%, transparent) 30%, transparent 46%);
}

.terrain-hero__inner {
  position: relative;
  z-index: 1; /* 幕(::after)より前に出す */
  max-width: 26em; /* 画面の中央に沈む太陽にかからない幅 */
  text-shadow: 0 2px 24px var(--sky-top);
}

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

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

.terrain-hero__lead {
  margin: 24px 0 0;
  font-size: 18px;
  line-height: 1.8;
  word-break: auto-phrase; /* 和文を文節の切れ目で折り返す */
}

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

.terrain-hero__button:focus-visible { outline: 2px solid var(--sun-top); outline-offset: 4px; }

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

JavaScript
import * as THREE from "three";

const DURATION = 14; // 地形 1 周期ぶん進んで、元の絵に戻るまで(秒)。速さは PERIOD / DURATION
const PERIOD = 64; // 地形が繰り返す奥行き(= 1 周で進む距離)。霧の奥行きより長くすると繰り返しに気づかない
const CELL = 0.8, NOISE_CELLS = 8; // 格子 1 マスの大きさ(PERIOD を割り切れる値)と、PERIOD の中のノイズの格子の数(整数。増やすと山が細かい)
const MOUNTAIN = 3.6, VALLEY = 1.3; // 山の高さと、中央の平らな谷の半幅
const LINE_WIDTH = 1.1, SUN_RADIUS = 0.17; // 線の太さ(描画の画素)と、太陽の半径(画面の高さに対する比)
const FOG = [12, 52]; // 霧が始まる距離と、地平線の色に溶けきる距離
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

// 空は画面ぴったりの板に描く。地平線のくすみローズ → ラベンダーグレー → 墨と、地平線の少し上に、上が淡いグレージュ・下がくすみローズの太陽
const skyVertex = /* glsl */ `varying vec2 vUv; void main() { vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }`;
const skyFragment = /* glsl */ `
  uniform vec2 uResolution; varying vec2 vUv;
  uniform float uHorizon, uSunRadius; // uHorizon は地平線の画面上の高さ(0..1)
  uniform vec3 uSkyTop, uSkyMid, uSkyHorizon, uSunTop, uSunBottom;
  void main() {
    vec2 s = (vUv - vec2(0.5, uHorizon + 0.06)) * vec2(uResolution.x / uResolution.y, 1.0);
    float y = vUv.y - uHorizon, r = length(s), px = 1.0 / uResolution.y; // 太陽の縁は 1 画素ぶんだけぼかす
    vec3 color = mix(mix(uSkyHorizon, uSkyMid, smoothstep(0.0, 0.3, y)), uSkyTop, smoothstep(0.24, 0.66, y)) + uSunBottom * exp(-r * r / 0.1) * 0.3;
    color = mix(color, mix(uSunBottom, uSunTop, smoothstep(-uSunRadius * 0.7, uSunRadius, s.y)), 1.0 - smoothstep(uSunRadius - px, uSunRadius + px, r));
    gl_FragColor = vec4(color + (fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453) - 0.5) / 255.0, 1.0); // 8bit の段差を散らす
  }
`;
const terrainVertex = /* glsl */ `
  uniform float uTravel, uFrequency, uPeriod, uMountain, uValley, uCell;
  varying vec2 vGrid; varying float vDepth, vShade;
  vec2 hash2(vec2 p) { vec3 p3 = fract(vec3(p.xyx) * vec3(0.1031, 0.1030, 0.0973)); p3 += dot(p3, p3.yzx + 33.33); return fract((p3.xx + p3.yz) * p3.zy) * 2.0 - 1.0; }
  // 奥行き(y)の向きだけ period ごとに繰り返すグラディエントノイズ(-1..1)。格子の番号を mod で折り返す
  float periodicNoise(vec2 p, float period) {
    vec2 i = floor(p), f = fract(p), u = f * f * (3.0 - 2.0 * f);
    float y0 = mod(i.y, period), y1 = mod(i.y + 1.0, period);
    return mix(mix(dot(hash2(vec2(i.x, y0)), f), dot(hash2(vec2(i.x + 1.0, y0)), f - vec2(1.0, 0.0)), u.x),
      mix(dot(hash2(vec2(i.x, y1)), f - vec2(0.0, 1.0)), dot(hash2(vec2(i.x + 1.0, y1)), f - vec2(1.0, 1.0)), u.x), u.y);
  }
  // 尾根のとがった山(ridged fbm)。オクターブごとに座標と周期を両方 2 倍にし、ずらすのは x だけにして奥行きの周期を保つ
  float ridges(vec2 p, float period) {
    float sum = 0.0, amp = 0.6;
    for (int i = 0; i < 3; i++) { float n = 1.0 - abs(periodicNoise(p, period)); sum += amp * n * n; p = p * 2.0 + vec2(17.3, 0.0); period *= 2.0; amp *= 0.42; }
    return sum;
  }
  void main() {
    vec3 pos = position; float w = pos.z - uTravel; // 進んだぶん奥の地形を引くので、山が手前へ流れてくる
    float side = smoothstep(uValley, uValley + 5.5, abs(pos.x)), r = ridges(vec2(pos.x, w) * uFrequency, uPeriod);
    pos.y = r * (uMountain * side * side + 0.3); vGrid = vec2(pos.x, w) / uCell; vShade = r;
    vec4 mv = modelViewMatrix * vec4(pos, 1.0); vDepth = -mv.z; gl_Position = projectionMatrix * mv;
  }
`;
const terrainFragment = /* glsl */ `
  uniform vec3 uGround, uLine, uLineFar, uFog; uniform float uFogNear, uFogFar, uLineWidth;
  varying vec2 vGrid; varying float vDepth, vShade;
  void main() { // 格子の線は、地形の座標の整数の位置に、画面上で uLineWidth 画素の太さで引く。手前の山が奥の線を隠す
    vec2 fw = max(fwidth(vGrid), vec2(1e-4)), d = abs(fract(vGrid - 0.5) - 0.5) / fw;
    float dist = min(d.x, d.y), line = 1.0 - smoothstep(uLineWidth - 0.6, uLineWidth + 0.6, dist), glow = exp(-dist * 0.35) * 0.3;
    float dense = 1.0 - smoothstep(0.25, 0.6, max(fw.x, fw.y)); // 奥で線が画素より細かく詰まったら薄める(ちらつき防止)
    float fog = smoothstep(uFogNear, uFogFar, vDepth); // 霧を地平線の色にそろえると、板の奥の端が消える
    vec3 color = mix(uGround * (0.7 + 0.8 * vShade), mix(uLine, uLineFar, smoothstep(0.1, 0.8, fog)), clamp(line + glow, 0.0, 1.0) * dense);
    gl_FragColor = vec4(mix(color, uFog, fog), 1.0);
  }
`;

document.querySelectorAll(".terrain-hero").forEach((hero) => {
  const css = (name) => getComputedStyle(hero).getPropertyValue(name).trim(); // 色は CSS 変数から読み、THREE.Color を通さず 255 で割って渡す(線形に直させない)
  const rgb = (name) => { const hex = Number.parseInt(css(name).slice(1), 16); return { value: new THREE.Vector3((hex >> 16) / 255, ((hex >> 8) & 255) / 255, (hex & 255) / 255) }; };
  const renderer = new THREE.WebGLRenderer({ canvas: hero.querySelector(".terrain-hero__canvas"), antialias: true });
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  const camera = new THREE.PerspectiveCamera(55, 1, 0.1, 200); // 縦横比は resize で入れる
  const sky = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), new THREE.ShaderMaterial({ vertexShader: skyVertex, fragmentShader: skyFragment, depthTest: false, depthWrite: false,
    uniforms: { uResolution: { value: new THREE.Vector2(1, 1) }, uHorizon: { value: 0.5 }, uSunRadius: { value: SUN_RADIUS },
      uSkyTop: rgb("--sky-top"), uSkyMid: rgb("--sky-mid"), uSkyHorizon: rgb("--sky-horizon"), uSunTop: rgb("--sun-top"), uSunBottom: rgb("--sun-bottom") } }));
  sky.frustumCulled = false; sky.renderOrder = -1; // 最初に描いて、地形の後ろに敷く
  const terrain = new THREE.Mesh(new THREE.PlaneGeometry(64, 72, 200, 240).rotateX(-Math.PI / 2).translate(0, 0, -32), new THREE.ShaderMaterial({
    vertexShader: terrainVertex, fragmentShader: terrainFragment, // 地形の板(z: +4 〜 -68)の頂点は動かさず、高さと線をシェーダーで決める
    uniforms: { uTravel: { value: 0 }, uFrequency: { value: NOISE_CELLS / PERIOD }, uPeriod: { value: NOISE_CELLS }, uMountain: { value: MOUNTAIN }, uValley: { value: VALLEY },
      uCell: { value: CELL }, uLineWidth: { value: LINE_WIDTH }, uFogNear: { value: FOG[0] }, uFogFar: { value: FOG[1] },
      uGround: rgb("--ground"), uLine: rgb("--line"), uLineFar: rgb("--line-far"), uFog: rgb("--sky-horizon") } }));
  terrain.frustumCulled = false; // 高さをシェーダーで付けるので、境界球が実際と合わない。視錐台カリングで消えないようにする
  const scene = new THREE.Scene().add(sky, terrain), ahead = new THREE.Vector3();
  const draw = (t) => {
    const p = (t % DURATION) / DURATION, a = p * Math.PI * 2;
    terrain.material.uniforms.uTravel.value = p * PERIOD;
    camera.position.set(0.35 * Math.sin(a), 1.45 + 0.1 * Math.sin(a * 2), 3); // 谷の上をゆっくり揺れながら進む(揺れの周波数は整数)
    camera.lookAt(0.6 * Math.sin(a + 0.8), 1.05, -20); camera.updateMatrixWorld();
    ahead.set(camera.position.x, camera.position.y, camera.position.z - 1000).project(camera); // 目の高さのはるか前方を画面に写す
    sky.material.uniforms.uHorizon.value = (ahead.y + 1) / 2; renderer.render(scene, camera); // 地平線の高さを空へ渡してから描く
  };
  const sizes = new ResizeObserver(() => {
    camera.aspect = hero.clientWidth / hero.clientHeight; camera.zoom = THREE.MathUtils.clamp(camera.aspect / 1.7, 0.6, 1); // 縦長では引いて、山並みを収める
    camera.updateProjectionMatrix();
    renderer.setSize(hero.clientWidth, hero.clientHeight, false); // 大きさは CSS が決めるので、style は書き換えない
    renderer.getDrawingBufferSize(sky.material.uniforms.uResolution.value); if (reduceMotion) draw(9.3); // 動きを減らす設定では、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();
    for (const mesh of [sky, terrain]) { mesh.geometry.dispose(); mesh.material.dispose(); } renderer.dispose(); renderer.forceContextLoss();
  };
});

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

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