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

ヒーローの背景でつやのある液体の玉がくっついて離れる

デモ

操作:カーソルを動かす。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。

仕様

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

しくみと調整

玉の中心と半径を uniform の配列でシェーダーに渡し、画面の各点で場の値 f = Σ r²/d²(半径の 2 乗 ÷ 中心までの距離の 2 乗を、玉ごとに足したもの)を計算して、f が 1 を越えた所を液体として塗ります。2 つの玉が近づくと、あいだの点で場が足し合わされて 1 を越え、首のようにつながります。玉の位置は周波数を整数にした円運動で動かすので、8 秒でひと回りして元の並びに戻ります。

つやは、液面の内側を高さ h = 1 − 1/f のドームとみなし、場の勾配から法線を作って付けます。勾配は距離の式を微分して解析的に出すので、隣の点の値を引き比べる必要がありません。拡散・鏡面・縁の照り返しを足し、色は近い玉ほど強く効かせた重みで混ぜるので、くっついた所で色がなめらかに移ります。液面の境目は fwidth で画面上の 1 画素ぶんだけぼかし、どの解像度でもぎざぎざを出しません。

ポインタで動かす玉は、配列の最後に 1 つ足しておき、ふだんは半径を 0 にしています。半径 0 の玉は場にも色にも影にも何も足さないので、シェーダーの配列の長さを変えずに出し入れできます。入った所で膨らませ、gsap.quickTo で 0.5 秒遅れて付いてこさせると、液体が引っぱられるように見えます。ポインタの座標は、枠の大きさに対する比から、シェーダーと同じ「高さを 1、中央を 0」の座標に直します。

玉の数・大きさ・周回の中心と半径・色は、配列の値と CSS 変数で変えられます。玉を増やすと、シェーダーの配列の長さも自動で合います。縦長の画面では全体を引いて玉を収め、文字の側には地の色の薄い幕をかけています。動きを減らす設定では 1 枚だけ描いて止め、ポインタの玉も出しません。画面の外にあるあいだは描き直しを止めます。クリエイティブスタジオやアプリの紹介など、遊び心のあるヒーローに向いています。

コード

HTML
<section class="metaball-hero">
  <!-- 背景は飾りなので、読み上げからは外す -->
  <canvas class="metaball-hero__canvas" aria-hidden="true"></canvas>
  <div class="metaball-hero__inner">
    <p class="metaball-hero__eyebrow">MOTION DESIGN</p>
    <h1 class="metaball-hero__title">アイデアを、<br>ひとつの形に。</h1>
    <p class="metaball-hero__lead">ここに説明文が入ります。</p>
    <a class="metaball-hero__button" href="/works/">制作事例を見る</a>
  </div>
</section>

CSS
.metaball-hero {
  /* 地の色と玉の色。script.js がこの 5 つを読んでシェーダーへ渡すので、#rrggbb で書く */
  --bg: #f2f1ed;
  --lavender: #7a66ba; /* ラベンダー(中央の大きな玉) */
  --deep: #365f7a;     /* 墨のスレート(濃い段) */
  --rose: #d25d51;     /* ローズ */
  --pale: #81b5db;     /* 淡いスレート */
  --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(--bg);
}

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

/* 文字の側だけ、地の色の薄い幕をかける。濃い玉が文字の下に来ても読めるように */
.metaball-hero::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(100deg, color-mix(in srgb, var(--bg) 72%, transparent), color-mix(in srgb, var(--bg) 56%, transparent) 40%, transparent 62%);
  pointer-events: none;
}

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

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

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

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

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

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

/* 幅が狭いと、玉が文字の真下に来る。幕を一様にかける */
@media (max-width: 720px) {
  .metaball-hero::after { background: color-mix(in srgb, var(--bg) 58%, transparent); }
}

JavaScript
import * as THREE from "three";
import { gsap } from "gsap";

const DURATION = 8; // 周回する玉がひと回りして、同じ並びに戻るまで(秒)
const X_STRETCH = 1.6; // 横長の画面に合わせて、周回を横に伸ばす
// cx, cy は周回の中心(画面の高さを 1、中央を 0 とした座標)。laps(周数)と pulse(半径が伸び縮みする回数)は整数にするとループがつながる
const BALLS = [
  { cx: 0, cy: 0, orbit: 0.05, laps: 1, pulse: 0, radius: 0.17, phase: 0, color: "--lavender" },
  { cx: 0, cy: 0, orbit: 0.3, laps: 1, pulse: 2, radius: 0.11, phase: 0, color: "--deep" },
  { cx: 0, cy: 0, orbit: 0.34, laps: -1, pulse: 1, radius: 0.095, phase: 2.1, color: "--pale" },
  { cx: 0, cy: 0.02, orbit: 0.26, laps: 2, pulse: 3, radius: 0.075, phase: 4.2, color: "--rose" },
  { cx: -0.52, cy: 0.12, orbit: 0.14, laps: -1, pulse: 2, radius: 0.1, phase: 1, color: "--deep" },
  { cx: 0.54, cy: -0.1, orbit: 0.16, laps: 1, pulse: 2, radius: 0.11, phase: 3, color: "--rose" },
  { cx: 0.5, cy: 0.22, orbit: 0.12, laps: -2, pulse: 1, radius: 0.065, phase: 5, color: "--pale" },
];
const POINTER = { radius: 0.1, color: "--pale", follow: 0.5, grow: 0.6 }; // ポインタの玉。follow は追いつくまで、grow は膨らむまでの秒
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 */ `
  #define N ${BALLS.length + 1}
  uniform vec2 uResolution; uniform float uZoom;
  uniform vec3 uBalls[N], uColors[N], uBg; // uBalls は xy が中心、z が半径。最後の 1 つがポインタの玉
  varying vec2 vUv;
  void main() {
    vec2 p = (vUv - 0.5) * vec2(uResolution.x / uResolution.y, 1.0) / uZoom;
    // 場の値 f = Σ r²/d² とその勾配。f = 1 が液面になる
    float f = 0.0, tintWeight = 0.0, shadow = 0.0;
    vec2 grad = vec2(0.0); vec3 tint = vec3(0.0);
    for (int i = 0; i < N; i++) {
      vec2 d = p - uBalls[i].xy;
      float r2 = uBalls[i].z * uBalls[i].z, dd = dot(d, d) + 1e-5, w = r2 / dd;
      f += w; grad -= 2.0 * r2 * d / (dd * dd);
      tint += uColors[i] * w * w; tintWeight += w * w; // 色は近い玉ほど強く効かせる(くっついた所で色がなめらかに混ざる)
      vec2 ds = d - vec2(0.018, -0.04); shadow += r2 / (dot(ds, ds) + 1e-5); // 影は右下にずらした場で作る
    }
    vec3 bg = uBg * (1.0 - 0.035 * (1.0 - vUv.y)) * (1.0 - smoothstep(0.45, 1.2, shadow) * 0.1); // うっすらした縦グラデーションに、液体の落とす影
    // 液面の内側を高さ h = 1 - 1/f のドームとみなし、その勾配から法線を作る
    vec2 slope = grad / (f * f);
    vec3 n = normalize(vec3(-slope * 0.11, 1.0)), light = normalize(vec3(-0.45, 0.55, 0.7));
    float diffuse = max(dot(n, light), 0.0), rim = pow(1.0 - n.z, 2.0);
    float spec = pow(max(dot(n, normalize(light + vec3(0.0, 0.0, 1.0))), 0.0), 70.0);
    vec3 base = tint / max(tintWeight, 1e-5);
    vec3 liquid = base * (0.5 + 0.6 * diffuse);
    liquid += base * rim * 0.35; liquid += smoothstep(0.2, 0.9, n.y) * 0.08; // 縁の照り返しと、上向きの面に映る空
    liquid += spec * 0.9;
    float e = fwidth(f); // 液面の境目は 1 画素ぶんだけぼかす(fwidth で画面上の幅にそろえる)
    gl_FragColor = vec4(mix(bg, liquid, smoothstep(1.0 - e, 1.0 + e, f)), 1.0);
  }
`;

document.querySelectorAll(".metaball-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 = { uResolution: { value: new THREE.Vector2(1, 1) }, uZoom: { value: 1 }, uBg: { value: rgb("--bg") },
    uBalls: { value: [...BALLS, POINTER].map(() => new THREE.Vector3()) }, uColors: { value: [...BALLS, POINTER].map((b) => rgb(b.color)) } };
  const renderer = new THREE.WebGLRenderer({ canvas: hero.querySelector(".metaball-hero__canvas") });
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  const scene = new THREE.Scene(), 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 hand = { x: 0, y: 0, r: 0 }; // ポインタの玉。半径が 0 のあいだは、場にも色にも影にも効かない
  const draw = (t) => {
    const a = ((t % DURATION) / DURATION) * Math.PI * 2;
    BALLS.forEach((b, i) => {
      const angle = b.phase + b.laps * a, orbit = b.orbit * (1 + 0.5 * Math.sin(b.pulse * a + b.phase));
      uniforms.uBalls.value[i].set(b.cx + Math.cos(angle) * orbit * X_STRETCH, b.cy + Math.sin(angle) * orbit, b.radius);
    });
    uniforms.uBalls.value[BALLS.length].set(hand.x, hand.y, hand.r);
    renderer.render(scene, camera);
  };
  const sizes = new ResizeObserver(() => {
    const { clientWidth: w, clientHeight: h } = hero;
    renderer.setSize(w, h, false); uniforms.uResolution.value.set(w, h); // 大きさは CSS が決めるので、style は書き換えない
    uniforms.uZoom.value = THREE.MathUtils.clamp(w / h / 1.7, 0.6, 1); // 縦長の画面では引いて、玉を収める
    if (reduceMotion) draw(3); // 動きを減らす設定では、1 枚だけ描いて止める(ポインタの玉も出さない)
  });
  const view = new IntersectionObserver(([e]) => renderer.setAnimationLoop(e.isIntersecting && !reduceMotion ? (ms) => draw(ms / 1000) : null)); // 画面の外では回さない
  sizes.observe(hero); view.observe(hero);
  // ポインタの玉は、入った所で膨らみ、少し遅れて付いてきて、出るとしぼむ
  const [toX, toY] = ["x", "y"].map((key) => gsap.quickTo(hand, key, { duration: POINTER.follow, ease: "power3" }));
  const size = (r) => gsap.to(hand, { r, duration: POINTER.grow, ease: r > 0 ? "power3.out" : "power2.inOut", overwrite: "auto" });
  const at = (e) => { // 枠の大きさに対する比から、シェーダーと同じ座標(高さを 1、中央を 0)に直す
    const box = hero.getBoundingClientRect(), zoom = uniforms.uZoom.value;
    return [((e.clientX - box.left) / box.width - 0.5) * (box.width / box.height) / zoom, (0.5 - (e.clientY - box.top) / box.height) / zoom];
  };
  const events = {
    pointerenter: (e) => { const [x, y] = at(e); if (hand.r === 0) { toX(x, x); toY(y, y); } size(POINTER.radius); },
    pointermove: (e) => { const [x, y] = at(e); toX(x); toY(y); },
    pointerleave: () => size(0),
  };
  if (!reduceMotion) for (const [type, fn] of Object.entries(events)) hero.addEventListener(type, fn);
  hero.destroy = () => { // PJAX で幕ごと捨てるときに呼ぶ。放っておくと WebGL の文脈が積み上がる
    renderer.setAnimationLoop(null); sizes.disconnect(); view.disconnect(); gsap.killTweensOf(hand);
    for (const [type, fn] of Object.entries(events)) hero.removeEventListener(type, fn);
    plane.geometry.dispose(); plane.material.dispose(); renderer.dispose(); renderer.forceContextLoss();
  };
});

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

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