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

ヒーローの背景で細胞のような模様がゆっくりうごめく

デモ

仕様

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

しくみと調整

全画面の板に、ボロノイ(セル)模様のフラグメントシェーダーを 1 枚かけるだけの背景です。画素ごとに、まわりのセルの中心点からいちばん近いものを探し、そのセルの色で塗ります。中心点はセルごとに小さな円を描いて回り、回る速さを 1 周のあいだに ±1 回か ±2 回の整数にしてあるので、15.84 秒でちょうど元の模様に戻り、ループの継ぎ目が出ません。

膜は、いちばん近い中心点と隣の中心点の垂直二等分線までの距離で描きます。3 × 3 のセルで最寄りを探したあと、5 × 5 に広げて境目までの距離を取り、なめらかな最小値(smin)でまとめると、角が丸くなって多角形ではなく細胞らしい形になります。膜の芯の縁は fwidth で 1 画素ぶんだけぼかすので、どの解像度でもぎざぎざになりません。

膜の内側の影に黒を混ぜると、灰色に濁ります。セルの色を 2.2 乗した、同じ色相の濃い色を混ぜると、澄んだまま沈みます。淡い色のなめらかな面は 8bit の段差が縞に見えやすいので、画素の位置から作った細かな揺らぎを足して散らします。1 画素ごとに中心点を 34 個調べる重いシェーダーなので、画面の外では描画を止め、動きを減らす設定では 1 枚だけ描いて止めます。

セルの細かさ(画面の高さに並ぶ数)、中心点が回る円の大きさ、膜のゆがみ、角の丸み、膜の太さとにじみで調整します。3 色(明るいセージ・スレート・ラベンダーグレー。左上ほど明るくして、色相より明るさで移り変わりを見せます)と膜の生成りは、CSS 変数で差し替えられます。見出しを重ねるときは、膜のにじみを弱めて地を静かにし、文字の側に膜の色を薄く重ねると読みやすくなります。等高線やくっつき合う玉の背景と違い、面をすき間なく仕切るセルの模様です。医療・バイオ・化粧品など、やわらかく明るいヒーローに向いています。

コード

HTML
<section class="cell-hero">
  <!-- 背景は飾りなので、読み上げからは外す -->
  <canvas class="cell-hero__canvas" aria-hidden="true"></canvas>
  <div class="cell-hero__inner">
    <p class="cell-hero__eyebrow">BIOTECH</p>
    <h1 class="cell-hero__title">細胞から、<br>未来の医療を。</h1>
    <p class="cell-hero__lead">ここに説明文が入ります。</p>
    <a class="cell-hero__button" href="/research/">研究について見る</a>
  </div>
</section>

CSS
.cell-hero {
  /* 細胞と膜の色はここで決める。script.js がこの変数を読んでシェーダーへ渡す */
  --cell-a: #bfcfba; /* 左上。明るいセージ */
  --cell-b: #a9bdcb; /* 中央。スレート */
  --cell-c: #b0a5ca; /* 右下。ラベンダーグレー(左上ほど明るくして、明るさの段を付ける) */
  --membrane: #f2f1ed; /* 膜。生成り */
  --ink: #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(--ink);
  /* 読み込み中と、WebGL が使えないときの地 */
  background-color: var(--cell-b);
}

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

/* 文字の側だけ、膜の色を薄く重ねる。セルの縁と核は濃いので、そのままだと本文が読みにくい */
.cell-hero::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(100deg, color-mix(in srgb, var(--membrane) 72%, transparent), color-mix(in srgb, var(--membrane) 40%, transparent) 42%, transparent 68%);
}

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

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

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

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

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

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

/* 幅が狭いと、文字が画面いっぱいに広がる。膜の色を一様に重ねる */
@media (max-width: 720px) {
  .cell-hero::after { background: color-mix(in srgb, var(--membrane) 45%, transparent); }
}

JavaScript
import * as THREE from "three";

const DURATION = 15.84; // セルがひと回りして、同じ模様に戻るまで(秒)
const CELLS = 4.6; // 画面の高さに並ぶセルの数。増やすと細かい組織になる
const ORBIT = 0.3; // 中心点が回る円の半径(セルの大きさに対する比。0.34 まで)
const WARP = 0.3; // 膜をゆがめる量。0 でまっすぐな膜になる
const ROUND = 0.14; // セルの角の丸み。0 で多角形
const MEMBRANE_WIDTH = 0.018; // 膜の芯の太さ(セル単位)
const MEMBRANE_GLOW = 0.5; // 膜のまわりのにじみ。見出しを重ねるなら下げて、地を静かにする
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

const vertexShader = /* glsl */ `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, uCells, uOrbit, uWarp, uRound, uMembraneWidth, uMembraneGlow;
  uniform vec3 uColors[3], uMembrane;
  varying vec2 vUv;
  vec4 hash4(vec2 p) { return fract(sin(vec4(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3)), dot(p, vec2(419.2, 371.9)), dot(p, vec2(113.5, 271.9)))) * 43758.5453); }
  vec2 grad2(vec2 p) { return hash4(p).xy * 2.0 - 1.0; }
  // グラディエントノイズ(-1..1)。膜をゆがめるのに使う
  float noise(vec2 p) {
    vec2 i = floor(p), f = fract(p), u = f * f * (3.0 - 2.0 * f);
    return mix(mix(dot(grad2(i), f), dot(grad2(i + vec2(1.0, 0.0)), f - vec2(1.0, 0.0)), u.x),
      mix(dot(grad2(i + vec2(0.0, 1.0)), f - vec2(0.0, 1.0)), dot(grad2(i + vec2(1.0, 1.0)), f - vec2(1.0, 1.0)), u.x), u.y);
  }
  float smin(float a, float b, float k) { float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0); return mix(b, a, h) - k * h * (1.0 - h); }
  // セルの中心点。回る速さは uPhase の ±1 倍か ±2 倍(整数)なので、1 周でちょうど元の位置に戻る
  vec2 site(vec4 h) {
    float speed = h.z < 0.25 ? -2.0 : (h.z < 0.5 ? -1.0 : (h.z < 0.75 ? 1.0 : 2.0));
    float angle = uPhase * speed + h.x * 6.2831853;
    return 0.5 + (fract(h.yx * 13.7) - 0.5) * 0.24 + uOrbit * (0.55 + 0.45 * h.y) * vec2(cos(angle), sin(angle));
  }
  void main() {
    float aspect = uResolution.x / uResolution.y;
    vec2 p = (vUv - 0.5) * vec2(aspect, 1.0) * uCells;
    vec2 loop = vec2(cos(uPhase), sin(uPhase)) * 0.8; // 膜をゆがめるノイズは、引く位置が円周上を 1 周する
    vec2 q = p + vec2(noise(p * 0.45 + loop), noise(p * 0.45 + vec2(5.2, 1.3) - loop.yx)) * uWarp;
    // 1 段目: まわりの 3 × 3 のセルから、いちばん近い中心点を探す
    vec2 n = floor(q), f = fract(q), mg = vec2(0.0), mr = vec2(0.0);
    float md = 8.0, tone = 0.0;
    for (int j = -1; j <= 1; j++) for (int i = -1; i <= 1; i++) {
      vec2 g = vec2(float(i), float(j)); vec4 h = hash4(n + g); vec2 r = g + site(h) - f;
      if (dot(r, r) < md) { md = dot(r, r); mr = r; mg = g; tone = h.w; }
    }
    // 2 段目: 5 × 5 の中心点との境目(垂直二等分線)までの距離を、なめらかな最小値でまとめて角を丸める
    md = 8.0;
    for (int j = -2; j <= 2; j++) for (int i = -2; i <= 2; i++) {
      vec2 g = mg + vec2(float(i), float(j)); vec2 r = g + site(hash4(n + g)) - f; vec2 d = r - mr;
      if (dot(d, d) > 1e-5) md = smin(md, dot(0.5 * (mr + r), normalize(d)), uRound);
    }
    float border = max(md, 0.0);
    // セルの色は、中心点の画面上の位置で 3 色のグラデーションから引く(セルの中は 1 色)
    vec2 at = (q + mr) / (uCells * vec2(aspect, 1.0));
    float g = clamp(dot(at, normalize(vec2(1.0, -0.75))) * 1.1 + 0.5, 0.0, 1.0);
    vec3 base = mix(mix(uColors[0], uColors[1], smoothstep(0.0, 0.55, g)), uColors[2], smoothstep(0.45, 1.0, g)) * (0.95 + 0.08 * tone);
    // 膜の内側の影は、累乗で濃くした同じ色相にする(灰色に濁らない)。中心へ向けて少しふくらませる
    vec3 color = mix(pow(base, vec3(2.2)), base * 0.97, 0.35 + 0.65 * smoothstep(0.0, 0.16, border)) + exp(-dot(mr, mr) / 0.09) * 0.05;
    color = mix(color, pow(base, vec3(3.0)), (1.0 - smoothstep(0.07, 0.095, length(mr))) * 0.45); // 核
    vec2 spark = -mr - vec2(-0.025, 0.03); color += exp(-dot(spark, spark) / 0.0006) * 0.12; // 核の照り
    float aa = fwidth(border), core = 1.0 - smoothstep(uMembraneWidth - aa, uMembraneWidth + aa, border); // 芯の縁は 1 画素ぶんだけぼかす
    color = mix(color, uMembrane, clamp(core + exp(-border * 16.0) * uMembraneGlow, 0.0, 1.0));
    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(".cell-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 new THREE.Vector3((hex >> 16) / 255, ((hex >> 8) & 255) / 255, (hex & 255) / 255); };
  const uniforms = {
    uPhase: { value: 0 }, uResolution: { value: new THREE.Vector2(1, 1) }, uCells: { value: CELLS }, uOrbit: { value: ORBIT }, uWarp: { value: WARP },
    uRound: { value: ROUND }, uMembraneWidth: { value: MEMBRANE_WIDTH }, uMembraneGlow: { value: MEMBRANE_GLOW },
    uColors: { value: ["--cell-a", "--cell-b", "--cell-c"].map(rgb) }, uMembrane: { value: rgb("--membrane") },
  };
  const renderer = new THREE.WebGLRenderer({ canvas: hero.querySelector(".cell-hero__canvas") });
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  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 }));
  const scene = new THREE.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);
    if (reduceMotion) draw(10.56); // 動きを減らす設定では、1 枚だけ描いて止める
  });
  // 1 画素ごとに中心点を 34 個調べる重いシェーダーなので、画面の外では回さない
  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 サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。