デモ
デモを読み込めませんでした。
仕様
- 用途
- ヒーロー
- 動作
- WebGL
- 種類
- シーン
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
MeshPhysicalMaterial の透過は使わず、自前のシェーダーで水の雫を描きます。背景を canvas のテクスチャにしておき、雫の各画素で、視線を表面で屈折させて中を通し、裏側でもう一度屈折させます。抜けた視線が奥の背景の面に当たる所を画面の座標に直して、背景を引きます。水の玉はレンズなので、奥の模様は小さく逆さに映り、これがガラスの球との見分けどころになります。屈折率は水の 1.33、背景の面は雫の中心から半径の 6 倍奥です。色ごとの屈折率の差は 0.006 とごく小さくし、これより上げると、中のグリッドの線が赤と緑に割れて見えます。
雫の休みの形は、表面張力と重さで下半分の高さを 0.8 倍につぶした形です(上は球、下は楕円体で、赤道でなめらかにつながります)。そこへ、左右に 5% 伸び縮みする揺れと、下がふくらんで上がすぼむ揺れ(いちばん大きいときで半径の 6%)を重ね、表面張力で戻ろうとするように、ぷるんと形を変え続けます。揺れの周期は 1 ループの整数分の 1 にし、雫ごとに周期と位相を変えて、同じ動きに見えないようにしています。屈折・縁の明暗・内側の光・背景の影と集光も、その時刻の形のまま解きます。上半分は伸び縮みしても半球のまま保ち、横に縮んだ分は下半分が深くなって受け止めます。上を縦長にすると、上の縁で視線が全反射して暗い帯になるためです。
写真らしさは、ピントと縁と光で足します。画面の背景には 2.5px ぼかした版を、雫の中にはくっきりした版を使うと、雫にピントが合った写真に見えます。縁ほど周りを強く映すフレネルの項で、上は明るい空・下は暗い床を映し、縁の上に明るい線、下に暗い輪郭を出します。照明の反対側の内側には光が集まった三日月と点、左上には照明の窓の映り込みを載せ、背景には右下へ伸びる影と、影の中に集まった細長い光を描きます。雫の縁は、描く板を形より 6% 大きくし、内と外を形の式で決めて、fwidth で測った画面の 1.5px の幅でなめらかに地へつなぎます(窓の映り込みの縁も同じです)。板の縁で切ると、拡大したときに縁が階段状にギザギザします。
雫の中は景色が縮むので、細い線が点線のように欠けます。くっきりした版だけ縮小用の段(generateMipmaps)を作ります。雫が画面の外を引くと端の色が筋のように伸びるので、テクスチャの繰り返しを鏡映しにします。影の位置は雫の中心を画面に写して決めますが、最初の描画の前はカメラの行列がまだ計算されていないので、先に updateMatrixWorld を呼びます。呼ばないと、1 枚だけ描く動きを減らす設定で背景が真っ黒になります。
背景に線や模様が無いと、屈折が見えません。グリッドや文字を置いてください。屈折率・背景までの奥行き・下のつぶれ具合・揺れの大きさと周期・ぼかしの量・雫の並び・背景の画像で調整します。動きを減らす設定では揺らさず、休みの形で 1 枚だけ描きます。背景は写真にも差し替えられ、化粧水や飲料など、みずみずしさを見せたいヒーローに向いています。
コード
<section class="orb-hero">
<!-- 背景は飾りなので、読み上げからは外す -->
<canvas class="orb-hero__canvas" aria-hidden="true"></canvas>
<div class="orb-hero__inner">
<p class="orb-hero__eyebrow">Skincare</p>
<h1 class="orb-hero__title">成分は、<br>たった7つ。</h1>
<p class="orb-hero__lead">ここに説明文が入ります。</p>
<a class="orb-hero__button" href="/products/">成分と使い方を見る</a>
</div>
</section>
.orb-hero {
/* 背景の色はここで決める。script.js がこの変数を読んで、canvas に背景を描く */
--tint-a: #f2f1ed; /* 生成り */
--tint-b: #dfe8ee; /* スレートの淡い色み */
/* 色だまり。script.js が末尾に 00 を足して透明へ向かわせるので、#rrggbb で書く */
--spot-a: #e4aea7; /* くすみローズ(淡) */
--spot-b: #aac5da; /* スレート(淡) */
--spot-c: #e4cbaa; /* グレージュ(淡) */
--spot-d: #b6d8b0; /* セージ(淡) */
--grid: rgba(38, 38, 38, 0.2); /* 雫の中で逆さに映る線。淡すぎると、屈折が見えない */
--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(--tint-a);
}
.orb-hero__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.orb-hero__inner {
position: relative; /* 背景の上に載せる */
max-width: 32em;
}
.orb-hero__eyebrow {
margin: 0;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.orb-hero__title {
margin: 24px 0 0;
font-size: clamp(40px, 8vw, 72px);
line-height: 1.05;
letter-spacing: -0.02em;
}
.orb-hero__lead {
margin: 24px 0 0;
font-size: 18px;
line-height: 1.8;
word-break: auto-phrase; /* 和文を文節の切れ目で折り返す(対応していないブラウザでは、ふつうに折り返す) */
}
.orb-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;
}
.orb-hero__button:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
import * as THREE from "three";
const DURATION = 10, IOR = 1.33, DISPERSION = 0.006; // 1 周の秒数・屈折率(水)・色ごとの屈折率の差(上げると、雫の中の線が色に割れる)
const DEPTH = 6, FLAT = 0.8, BLUR = 2.5; // 背景の面までの奥行き(半径の何倍か。遠いほど中の景色が大きく逆さに映る)・下半分の高さの比(1 で球)・背景のぼかし(px)
const STRETCH = 0.05, TAPER = 0.06, PINCH = 0.4; // 雫の揺れ: 左右の伸び縮み・下がふくらむ量(半径に対する比)・それに対して上がすぼむ量の比(上を細らせすぎると、上の縁が全反射で暗い帯になる)
// 雫ごとに [x, y, z, 半径, 浮遊の振れ幅(横・縦), 周波数(横・縦), 位相, 揺れの周波数(伸び縮み・すぼみ), 揺れの位相]。周波数は整数にするとループがつながる
const ORBS = [[-2.7, 0.35, 0, 1.35, 0.25, 0.3, 1, 1, 0, 3, 2, 0.4], [2, 1.15, 0.4, 0.8, 0.2, 0.25, 1, 2, 1.7, 4, 3, 2.1], [3.3, -1.25, -0.6, 1.1, 0.18, 0.22, 2, 1, 3.1, 3, 4, 3.6],
[0.1, -1.45, 0.8, 0.55, 0.3, 0.18, 1, 2, 4.4, 5, 3, 5], [-0.55, 1.7, 0.2, 0.42, 0.22, 0.2, 2, 1, 5.6, 4, 5, 1.2]]; const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// 背景。屈折を見せるには線や模様が要る(無地だと、ゆがんでも動きが見えない)。写真に差し替えてもよい
function drawBackground(w, h, css) {
const canvas = Object.assign(document.createElement("canvas"), { width: w, height: h }), g = canvas.getContext("2d"), base = g.createLinearGradient(0, 0, w, h);
base.addColorStop(0, css("--tint-a")); base.addColorStop(1, css("--tint-b")); g.fillStyle = base; g.fillRect(0, 0, w, h);
for (const [x, y, r, name] of [[0.18, 0.22, 0.55, "--spot-a"], [0.82, 0.78, 0.6, "--spot-b"], [0.66, 0.12, 0.4, "--spot-c"], [0.32, 0.92, 0.45, "--spot-d"]]) {
const spot = g.createRadialGradient(x * w, y * h, 0, x * w, y * h, r * h); spot.addColorStop(0, css(name)); spot.addColorStop(1, `${css(name)}00`); g.fillStyle = spot; g.fillRect(0, 0, w, h);
}
const cell = h / 12; g.strokeStyle = css("--grid"); g.lineWidth = Math.max(1, Math.round(h / 720)); g.beginPath(); // 細いグリッド。逆さに映ると水とわかる
for (let x = (w % cell) / 2; x <= w; x += cell) { g.moveTo(Math.round(x) + 0.5, 0); g.lineTo(Math.round(x) + 0.5, h); }
for (let y = (h % cell) / 2; y <= h; y += cell) { g.moveTo(0, Math.round(y) + 0.5); g.lineTo(w, Math.round(y) + 0.5); } g.stroke(); return canvas;
}
// 画面の背景(ぼかした版)に、雫の落とす影と、雫を抜けて影の中に集まった細長い光を足す。どちらも雫の伸び縮み(uStretch)に合わせる
const backFragment = /* glsl */ `uniform sampler2D uBg; uniform float uAspect; uniform vec3 uOrbs[${ORBS.length}]; uniform vec2 uStretch[${ORBS.length}]; varying vec2 vUv; void main() {
vec3 color = texture2D(uBg, vUv).rgb; vec2 light = vec2(0.6, -0.8); for (int i = 0; i < ${ORBS.length}; i++) { // light: 影の伸びる向き(照明の反対側)
vec2 d = (vUv - uOrbs[i].xy) * vec2(uAspect, 1.0) / uStretch[i], s = vec2(dot(d, light), dot(d, vec2(-light.y, light.x))) / uOrbs[i].z;
vec2 shade = (s - vec2(0.5, 0.0)) * vec2(0.85, 1.05), c = (s - vec2(1.02, 0.0)) * vec2(1.6, 0.9);
color = color * (1.0 - exp(-dot(shade, shade) / 0.7) * 0.36) + vec3(1.0, 0.98, 0.93) * exp(-dot(c, c) / 0.03) * 0.42; } gl_FragColor = vec4(color, 1.0); }`;
const shapeGlsl = /* 雫の形 uShape: 横の半径 x・上半分の高さ y・下半分の高さ z・すぼみ w。横の半径は高さの 2 乗で、下ほど太り上ほど細る(赤道で折れ目を出さない) */ `uniform vec4 uShape; float pinch(float y) { return y >= 0.0 ? ${PINCH.toFixed(2)} : -1.0; } float radius(float y) { return uShape.x * (1.0 - uShape.w * y * y * pinch(y)); }`;
// 球をその時刻の雫の形に変え、1.06 倍に広げて描く(縁の外の画素でも、縁をぼかして地へつなげるように)
const orbVertex = /* glsl */ `${shapeGlsl} varying vec3 vViewPos, vCenter; varying float vScale; void main() {
vec3 p = position; p.y *= p.y >= 0.0 ? uShape.y : uShape.z; p.xz *= radius(p.y); vec4 mv = modelViewMatrix * vec4(p * 1.06, 1.0); vViewPos = mv.xyz;
vCenter = (modelViewMatrix * vec4(0.0, 0.0, 0.0, 1.0)).xyz; vScale = length(modelMatrix[0].xyz); gl_Position = projectionMatrix * mv; }`;
// 水の雫として描く。視線を表面で屈折させて中を通し、裏側でもう一度屈折させて抜けた先の背景を引く(レンズなので景色は逆さに映る)。座標は雫の中心から半径で割ったもの
const orbFragment = /* glsl */ `uniform mat4 projectionMatrix; uniform sampler2D uBg; uniform float uIor, uDispersion, uDepth; ${shapeGlsl} varying vec3 vViewPos, vCenter; varying float vScale;
float ellipsoid(vec3 o, vec3 t, vec3 r, float sgn, float lo, out float g) { // 視線と楕円体の交わり。g は視線が通る深さ(縁で 0、外で負)。lo より浅ければ lo の所を通ったとみなす
vec3 o2 = o / r, t2 = t / r; float a = dot(t2, t2), b = dot(o2, t2); g = b * b / a - dot(o2, o2) + 1.0; return (-b + sgn * sqrt(max(g, lo) * a)) / a; }
float trace(vec3 o, vec3 t, float sgn, float lo, out float g) { // 雫の形との交わり(sgn: -1 で入る所、1 で出る所)。横の半径は高さで変わるので、求めた高さで 3 回解き直す
float s = 0.0, y = 0.0, gt, gb; for (int i = 0; i < 3; i++) { float h = radius(y), st = ellipsoid(o, t, vec3(h, uShape.y, h), sgn, lo, gt), sb = ellipsoid(o, t, vec3(h, uShape.z, h), sgn, lo, gb);
bool top = o.y + t.y * st >= 0.0; s = top ? st : sb; g = top ? gt : gb; y = o.y + t.y * s; } return s; } // 上の楕円体の交わりが上半分にあればそれ、なければ下
vec3 normalAt(vec3 p) { float h = radius(p.y), b = p.y >= 0.0 ? uShape.y : uShape.z; // 表面の向き(形の式の勾配)
return normalize(vec3(p.x / (h * h), p.y / (b * b) + dot(p.xz, p.xz) * 2.0 * uShape.x * uShape.w * pinch(p.y) * p.y / (h * h * h), p.z / (h * h))); }
vec2 through(vec3 o, vec3 n, vec3 d, float ior, out float len, out float escape) { // 抜けた視線が uDepth 奥の背景に当たる所(画面の uv)
float g; vec3 t1 = refract(d, n, 1.0 / ior), q = o + t1 * trace(o, t1, 1.0, 0.0, g), n2 = normalAt(q), t2 = refract(t1, -n2, ior), qv = vCenter + q * vScale;
if (dot(t2, t2) < 0.001) t2 = reflect(t1, -n2); len = length(q - o); float k = 1.0 - ior * ior * (1.0 - dot(t1, n2) * dot(t1, n2)); // 全反射なら跳ね返す
escape = smoothstep(0.0, -0.35, t2.z) * smoothstep(0.0, 0.08, k); // 全反射の手前でなめらかに減らす(急に切り替えると、縁に階段状の暗い帯が出る)
vec4 clip = projectionMatrix * vec4(qv + t2 * ((vCenter.z - uDepth * vScale - qv.z) / min(t2.z, -0.05)), 1.0); return clip.xy / clip.w * 0.5 + 0.5; }
float band(float v, float e, float soft) { float w = max(soft, fwidth(v) * 1.25); return smoothstep(e + w * 0.5, e - w * 0.5, v); } // 画面の 1.25px 以上の幅で切り替える
void main() { vec3 d = normalize(vViewPos), eye = -vCenter / vScale; float g, len, escape; trace(eye, d, -1.0, 0.0, g); // eye: カメラの位置(雫の座標)
float px = max(fwidth(g), 1e-6), cover = smoothstep(-0.75, 0.75, g / px); // 形の内と外を、縁の前後 1.5px でなめらかにつなぐ(px は 1px あたりの g)
vec3 o = eye + d * trace(eye, d, -1.0, px, g), n = normalAt(o); // 縁ぎりぎりの画素は 1px 内側の色で塗る(かすめた視線で、上の明るい線が灰色に沈まないように)
vec3 color = vec3(texture2D(uBg, through(o, n, d, uIor + uDispersion * 2.0, len, escape)).r, // 色ごとに屈折率をごくわずかに変える
texture2D(uBg, through(o, n, d, uIor + uDispersion, len, escape)).g, texture2D(uBg, through(o, n, d, uIor, len, escape)).b);
color = mix(vec3(0.16, 0.17, 0.18), color, escape) * exp(-len * vec3(0.06, 0.035, 0.03)); // 手前へ折り返した視線は暗い周りを映す。水は赤から少し沈む
vec3 key = normalize(vec3(-0.5, 0.62, 0.6)); vec2 away = normalize(-key.xy), f = o.xy - away * 0.6; // key: 左上の照明。side: 照明の反対側(右下)ほど 1
float edge = 1.0 - clamp(dot(n, -d), 0.0, 1.0), rr = length(o.xy), side = dot(normalize(o.xy + 1e-5), away), focus = dot(f, f);
color *= 1.0 - smoothstep(0.42, 0.9, edge) * (0.5 + 0.3 * smoothstep(-0.4, 0.9, side)); // 暗い輪郭(視線が大きく曲がって暗い周りから来る)
float crescent = smoothstep(0.36, 0.62, rr) * (1.0 - smoothstep(0.7, 0.86, rr)) * pow(max(side, 0.0), 4.0); // 内側に集まる光の三日月と点
color += vec3(1.0, 0.99, 0.95) * (crescent * 0.28 + exp(-focus / 0.0025) * 0.7 + exp(-focus / 0.02) * 0.16);
vec3 r = reflect(d, n); // 周りの映り込み(フレネル)。上は明るい空、下は暗い床。縁の上は明るい線、下は暗い輪郭になる
color = mix(color, mix(vec3(0.12), vec3(1.0), smoothstep(-0.3, 0.5, r.y)), 0.02 + 0.98 * pow(edge, 5.0));
vec3 kx = normalize(cross(vec3(0.0, 1.0, 0.0), key)); vec2 w = vec2(dot(r, kx) / 0.3, dot(r, cross(key, kx)) / 0.13); // 左上の横長の窓と、右上の点の映り込み
float box = band(length(max(abs(w) - vec2(0.55, 0.2), 0.0)) + 0.6, 0.9, 0.2) * band(-dot(r, key), 0.0, 0.001);
gl_FragColor = vec4(color + box * 1.1 + pow(max(dot(r, normalize(vec3(0.55, 0.5, 0.67))), 0.0), 1400.0) * 1.2, cover); }`;
// t 秒の雫の形。上半分は半球のまま(縦長にすると上の縁が全反射で暗くなる)、横に縮んだ分は下半分が深くなる。動きを減らす設定では揺らさない
const shapeAt = (sk, tk, wp, a, stretch = 1 + (reduceMotion ? 0 : STRETCH * Math.sin(sk * a + wp))) => [stretch, stretch, FLAT / stretch ** 2, reduceMotion ? 0 : TAPER * (0.5 - 0.5 * Math.cos(tk * a + wp * 1.7))];
document.querySelectorAll(".orb-hero").forEach((hero) => {
const css = (name) => getComputedStyle(hero).getPropertyValue(name).trim(), renderer = new THREE.WebGLRenderer({ canvas: hero.querySelector(".orb-hero__canvas"), antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.outputColorSpace = THREE.LinearSRGBColorSpace; // 背景も雫も、描いた色をそのまま出す
const scene = new THREE.Scene(), camera = new THREE.PerspectiveCamera(35, 1, 0.1, 100); camera.position.z = 10; camera.updateMatrixWorld(); // 影の位置を最初の 1 枚から写せるように
const uniforms = { uBg: { value: null }, uIor: { value: IOR }, uDispersion: { value: DISPERSION }, uDepth: { value: DEPTH } }; // 雫どうしで共有する(シェーダーは 1 つにまとまる)
const back = { uBg: { value: null }, uAspect: { value: 1 }, uOrbs: { value: ORBS.map(() => new THREE.Vector3()) }, uStretch: { value: ORBS.map(() => new THREE.Vector2(1, 1)) } };
const backdrop = Object.assign(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), new THREE.ShaderMaterial({ uniforms: back, fragmentShader: backFragment, depthTest: false,
depthWrite: false, vertexShader: "varying vec2 vUv; void main() { vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }" })), { frustumCulled: false, renderOrder: -1 }); // 画面全体を覆う板
const geometry = new THREE.SphereGeometry(1, 96, 64), orbs = ORBS.map(([, , , r]) => { // 形は雫ごとに違うので、材質は雫ごとに作る。縁のぼかしで地と混ぜるので transparent
const mesh = new THREE.Mesh(geometry, new THREE.ShaderMaterial({ uniforms: { ...uniforms, uShape: { value: new THREE.Vector4() } }, vertexShader: orbVertex, fragmentShader: orbFragment, transparent: true }));
mesh.scale.setScalar(r); scene.add(mesh); return mesh; }); scene.add(backdrop); const center = new THREE.Vector3(), top = new THREE.Vector3(), textures = [];
const draw = (t, a = (t / DURATION) * Math.PI * 2) => { // 雫ごとに、基準の位置のまわりを小さな楕円を描いて漂い、ぷるんと形を変える。影の位置と大きさは、中心と上端を画面に写して決める
ORBS.forEach(([x, y, z, r, ax, ay, fx, fy, phase, sk, tk, wp], i) => {
const [width, high, low, taper] = shapeAt(sk, tk, wp, a); orbs[i].material.uniforms.uShape.value.set(width, high, low, taper); back.uStretch.value[i].set(width, (high + low) / (1 + FLAT));
center.copy(orbs[i].position.set(x + ax * Math.sin(fx * a + phase), y + ay * Math.cos(fy * a + phase), z)).project(camera);
top.copy(orbs[i].position).setY(orbs[i].position.y + r).project(camera); back.uOrbs.value[i].set((center.x + 1) / 2, (center.y + 1) / 2, (top.y - center.y) / 2);
}); renderer.render(scene, camera);
};
const sizes = new ResizeObserver(() => {
const { clientWidth: w, clientHeight: h } = hero; back.uAspect.value = camera.aspect = w / h;
camera.zoom = THREE.MathUtils.clamp(camera.aspect / 1.7, 0.6, 1); camera.updateProjectionMatrix(); renderer.setSize(w, h, false); // 縦長では引いて雫を収める。大きさは CSS が決める
const { x, y } = renderer.getDrawingBufferSize(new THREE.Vector2()), sharp = drawBackground(x, y, css);
const soft = Object.assign(document.createElement("canvas"), { width: x, height: y }), g = soft.getContext("2d"); // ぼかした版。縁が透けないよう、くっきりした版に重ねる
g.drawImage(sharp, 0, 0); g.filter = `blur(${BLUR * renderer.getPixelRatio()}px)`; g.drawImage(sharp, 0, 0); textures.splice(0).forEach((texture) => texture.dispose());
[sharp, soft].forEach((canvas, i) => { // 画面の外を引いても縁が伸びないよう鏡映しで繰り返す。雫の中は景色が縮むので、くっきりした版だけ縮小用の段を作る
const texture = new THREE.CanvasTexture(canvas); texture.wrapS = texture.wrapT = THREE.MirroredRepeatWrapping;
texture.generateMipmaps = i === 0; texture.minFilter = i === 0 ? THREE.LinearMipmapLinearFilter : THREE.LinearFilter; textures.push(texture);
});
[uniforms.uBg.value, back.uBg.value] = textures; // 雫の中はくっきりした版、画面の背景はぼかした版(雫にピントが合った写真に見える)
if (reduceMotion) draw(2.5); // 動きを減らす設定では、休みの形で 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, ...orbs.map((mesh) => mesh.material), backdrop.geometry, backdrop.material, ...textures, renderer].forEach((item) => item.dispose()); renderer.forceContextLoss();
};
});