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

ヒーローの背景で星が光の尾を引き、ワープするように手前へ流れる

デモ

仕様

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

しくみと調整

星の尾 1 本を四角形 1 枚にして、インスタンス描画(InstancedBufferGeometry)で 1600 本をまとめて描きます。星ごとの乱数(角度・筒の中の半径・奥行きの始まり・速さ)は最初に 1 度だけ決め、あとは頂点シェーダーが時間の式だけで位置を出します。

奥行きは「始まりの位置 + 時間 × 速さ」を範囲で割った余り(mod)です。速さを「範囲 × 整数 ÷ 周期」にしてあるので、6 秒のひと巡りでどの星も元の奥行きに戻り、ループの継ぎ目が出ません。星によって 1 周か 2 周進むので、速さにむらが出ます。生まれる奥と折り返す手前ではフェードさせ、星が急に現れたり消えたりするのを隠します。

WebGL の線は 1px より太くできないので、頭と尾をそれぞれ画面に投影し、画面の上で尾に直交する向きへ太さを付けています。太さを画素で決めているため、そのままでは高解像度の画面や背の高い画面で尾が細く見えます。描画の倍率と画面の高さから倍率を出し、高さ 720px の画面と同じ見え方にそろえます。

星の数・尾の長さ・筒の半径と、3 色の星と宇宙の地の色(CSS の変数)で調整します。星は淡いスレート・ラベンダーグレー・グレージュで、色相より明るさの差で見分けられるようにし、地は青みを抜いた墨色にしています。カメラをわずかに漂わせると、消失点がゆっくり動いて奥行きが増します。見出しを重ねる側には宇宙の色の幕を敷き、尾が文字をまたいでも読めるようにしています。宇宙・テック・新しいサービスの告知のヒーローに向いています。

コード

HTML
<section class="warp-hero">
  <!-- 背景は飾りなので、読み上げからは外す -->
  <canvas class="warp-hero__canvas" aria-hidden="true"></canvas>
  <div class="warp-hero__inner">
    <p class="warp-hero__eyebrow">VERSION 2.0</p>
    <h1 class="warp-hero__title">もっと速く、<br>もっと遠くへ。</h1>
    <p class="warp-hero__lead">ここに説明文が入ります。</p>
    <a class="warp-hero__button" href="/signup/">無料ではじめる</a>
  </div>
</section>

CSS
.warp-hero {
  /* 宇宙の地と星の色。script.js がこの変数を読んでシェーダーへ渡すので、#rrggbb で書く */
  --space-center: #15191d; /* 消失点のまわり(青みを抜いた墨に、スレートをわずかに差す) */
  --space-edge: #030405; /* 画面の端 */
  --space-glow: #2c3640; /* 消失点のにじみ */
  --star-a: #d3dde5; /* 7 割の星。淡いスレート(色相より明るさで 3 色を分ける) */
  --star-b: #b0a5ca; /* 2 割の星。ラベンダーグレー */
  --star-c: #d2b999; /* 残りの星。グレージュ */
  --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: radial-gradient(ellipse at center, var(--space-center), var(--space-edge) 70%);
}

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

/* 文字の側だけ、宇宙の色の幕で星を沈める。尾が文字をまたぐと、本文が読みにくい */
.warp-hero::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(90deg, color-mix(in srgb, var(--space-edge) 82%, transparent), color-mix(in srgb, var(--space-edge) 55%, transparent) 45%, transparent 72%);
  pointer-events: none;
}

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

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

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

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

.warp-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(--space-edge);
  font-size: 15px;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-decoration: none;
}

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

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

JavaScript
import * as THREE from "three";

const DURATION = 6; // 星がひと巡りして、同じ並びに戻るまで(秒)
const COUNT = 1600; // 星(尾)の数
const FAR = -60, NEAR = -1.2; // 星が生まれる奥行きと、奥へ戻す手前の位置
const TRAIL = 0.12, SPREAD = 9; // 尾の長さ(何秒ぶんの移動を尾に引くか)と、星を並べる筒の半径
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

const streakVertex = /* glsl */ `
  uniform float uTime, uDuration, uFar, uRange, uTrail, uSpread, uScale;
  uniform vec2 uResolution; uniform vec3 uStarA, uStarB, uStarC;
  attribute vec4 aSeed;
  varying float vAlong, vSide, vAlpha; varying vec3 vColor;
  void main() {
    // 1 周期で範囲の 1 倍か 2 倍進む。整数倍なので、周期の終わりで元の奥行きに戻る
    float laps = 1.0 + floor(aSeed.w * 2.0), speed = uRange * laps / uDuration;
    float travel = mod(aSeed.z * uRange + uTime * speed, uRange), nearness = travel / uRange;
    float angle = aSeed.x * 6.2831853, radius = mix(0.5, uSpread, sqrt(aSeed.y));
    vec3 head = vec3(cos(angle) * radius, sin(angle) * radius, uFar + travel), tail = head - vec3(0.0, 0.0, speed * uTrail);
    // 頭と尾をそれぞれ投影し、画面上で尾に直交する向きへ太さを付ける(WebGL の線は 1px より太くできない)
    vec4 h = projectionMatrix * modelViewMatrix * vec4(head, 1.0);
    vec4 tl = projectionMatrix * modelViewMatrix * vec4(tail, 1.0);
    vec2 dir = (h.xy / h.w - tl.xy / tl.w) * uResolution;
    float len = length(dir); dir = len > 0.001 ? dir / len : vec2(0.0, 1.0);
    vec2 normal = vec2(-dir.y, dir.x);
    vec4 pos = mix(h, tl, position.x); // position.x: 0 = 頭, 1 = 尾 / position.y: -1..1 = 太さの向き
    float halfWidth = mix(0.9, 2.2, nearness * nearness) * uScale; // 手前ほど太く(高さ 720px の画面での px)
    pos.xy += normal * position.y * halfWidth * 2.0 / uResolution * pos.w; gl_Position = pos;
    vAlong = position.x; vSide = position.y;
    // 奥で生まれるときはフェードイン、折り返す直前にフェードアウト
    vAlpha = smoothstep(0.0, 0.3, nearness) * (1.0 - smoothstep(0.9, 1.0, nearness)) * (0.45 + 0.55 * aSeed.w);
    float tone = fract(aSeed.x * 17.0 + aSeed.y * 31.0);
    vColor = tone < 0.7 ? uStarA : (tone < 0.88 ? uStarB : uStarC);
  }
`;
const streakFragment = /* glsl */ `
  varying float vAlong, vSide, vAlpha; varying vec3 vColor;
  void main() {
    float side = 1.0 - abs(vSide), fade = pow(1.0 - vAlong, 1.5); // 頭が明るく、尾へ向かって消える
    gl_FragColor = vec4(vColor, side * fade * vAlpha);
  }
`;
const backgroundVertex = `varying vec2 vUv; void main() { vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }`;
const backgroundFragment = /* glsl */ `
  uniform vec2 uResolution; uniform vec3 uCenter, uEdge, uGlow; varying vec2 vUv;
  void main() { // 奥(消失点)がわずかに明るい、墨色の宇宙
    float r = length((vUv - 0.5) * vec2(uResolution.x / uResolution.y, 1.0));
    vec3 color = mix(uCenter, uEdge, smoothstep(0.0, 0.8, r));
    color += uGlow * exp(-r * r / 0.02) * 0.5;
    color += (fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453) - 0.5) / 255.0; // 8bit の段差(バンディング)を散らす
    gl_FragColor = vec4(color, 1.0);
  }
`;

document.querySelectorAll(".warp-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 resolution = { value: new THREE.Vector2(1, 1) }; // 2 つのシェーダーで共有する(実際の画素の数)
  const renderer = new THREE.WebGLRenderer({ canvas: hero.querySelector(".warp-hero__canvas"), antialias: true });
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  const scene = new THREE.Scene(), camera = new THREE.PerspectiveCamera(60, 1, 0.1, 100); // 縦横比は resize で入れる
  const background = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), new THREE.ShaderMaterial({ vertexShader: backgroundVertex, fragmentShader: backgroundFragment,
    uniforms: { uResolution: resolution, uCenter: rgb("--space-center"), uEdge: rgb("--space-edge"), uGlow: rgb("--space-glow") }, depthTest: false, depthWrite: false }));
  background.frustumCulled = false; background.renderOrder = -1; // カメラに関係なく、画面いっぱいに先に描く
  // 尾 1 本 = 四角形 1 枚を、インスタンスで COUNT 本。星ごとの乱数は最初に決め、あとは時間の式だけで動かす
  const geometry = new THREE.InstancedBufferGeometry().setIndex([0, 2, 1, 1, 2, 3]);
  geometry.setAttribute("position", new THREE.BufferAttribute(new Float32Array([0, -1, 0, 0, 1, 0, 1, -1, 0, 1, 1, 0]), 3));
  geometry.setAttribute("aSeed", new THREE.InstancedBufferAttribute(Float32Array.from({ length: COUNT * 4 }, () => Math.random()), 4));
  geometry.instanceCount = COUNT;
  const uniforms = { uTime: { value: 0 }, uDuration: { value: DURATION }, uFar: { value: FAR }, uRange: { value: NEAR - FAR }, uTrail: { value: TRAIL }, uSpread: { value: SPREAD },
    uScale: { value: 1 }, uResolution: resolution, uStarA: rgb("--star-a"), uStarB: rgb("--star-b"), uStarC: rgb("--star-c") };
  const streaks = new THREE.Mesh(geometry, new THREE.ShaderMaterial({ uniforms, vertexShader: streakVertex, fragmentShader: streakFragment,
    transparent: true, depthWrite: false, side: THREE.DoubleSide, blending: THREE.AdditiveBlending }));
  streaks.frustumCulled = false; // 位置はシェーダーで決めるので、視錐台カリングで消えないようにする
  scene.add(background, streaks);

  const draw = (t) => {
    const a = (t / DURATION) * Math.PI * 2;
    uniforms.uTime.value = t % DURATION;
    camera.position.set(0.35 * Math.sin(a), 0.2 * Math.sin(a * 2), 0); // 消失点をゆっくり漂わせる(周波数は整数)
    renderer.render(scene, camera);
  };
  const sizes = new ResizeObserver(() => {
    const { clientWidth: w, clientHeight: h } = hero;
    camera.aspect = w / h; camera.updateProjectionMatrix();
    renderer.setSize(w, h, false); // 大きさは CSS が決めるので、style は書き換えない
    uniforms.uScale.value = renderer.getDrawingBufferSize(resolution.value).y / 720; // 尾の太さを、高さ 720px の画面と同じ見え方にそろえる
    if (reduceMotion) draw(5.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();
    [geometry, streaks.material, background.geometry, background.material, renderer].forEach((item) => item.dispose()); renderer.forceContextLoss();
  };
});

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

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