デモ
デモを読み込めませんでした。
仕様
- 用途
- ヒーロー
- 動作
- 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 枚だけ描いて止め、ポインタの玉も出しません。画面の外にあるあいだは描き直しを止めます。クリエイティブスタジオやアプリの紹介など、遊び心のあるヒーローに向いています。
コード
<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>
.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); }
}
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();
};
});