デモ
デモを読み込めませんでした。
仕様
- 用途
- ヒーロー
- 動作
- WebGL
- 種類
- シーン
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
MeshPhysicalMaterial の透過は使わず、自前のシェーダーで屈折を近似します。背景を canvas のテクスチャにしておき、球の各画素で屈折した向きの分だけ、背景を引く位置をずらします。
RGB で屈折率を少しずつずらすと、球の縁に虹色のにじみが出ます。フレネルの項で縁を白ませると、ガラスらしくなります。球の影と、光が集まって明るくなる部分(コースティクス)は、背景のシェーダーに球の画面上の位置を渡して描きます。下のコードは屈折の要点だけに絞った短い版なので、この 2 つは入れていません。
背景に線や模様が無いと、屈折が見えません。グリッドや文字を置いてください。
屈折率・屈折の強さ・色のずれ(分散)・球の並び・背景の絵で調整します。背景は写真にも差し替えられます。
コード
<section class="orb-hero">
<!-- 背景は飾りなので、読み上げからは外す -->
<canvas class="orb-hero__canvas" aria-hidden="true"></canvas>
<div class="orb-hero__inner">
<p class="orb-hero__eyebrow">Skincare — Clean & clear</p>
<h1 class="orb-hero__title">Water, and nothing to hide.</h1>
<p class="orb-hero__lead">成分は 7 つだけ。肌にのせるものを、ぜんぶ見える形にしました。</p>
<a class="orb-hero__button" href="/products/">成分と使い方を見る</a>
</div>
</section>
.orb-hero {
/* 背景の色はここで決める。script.js がこの変数を読んで、canvas に背景を描く */
--tint-a: #f4effd;
--tint-b: #e2edfd;
/* 色だまり。script.js が末尾に 00 を足して透明へ向かわせるので、#rrggbb で書く */
--spot-a: #ffb8d6;
--spot-b: #9cc0ff;
--spot-c: #ffd9a6;
--grid: rgba(40, 36, 80, 0.13);
--ink: #2a2640;
--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;
}
.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; // ひと回りして同じ絵に戻るまで(秒)
const IOR = 1.45; // 屈折率。上げるほど、奥の模様が大きくゆがむ
const STRENGTH = 0.085; // 屈折でずらす量(画面の高さに対する比。半径 1 の球あたり)
const DISPERSION = 0.05; // 色ごとの屈折率の差。球の縁の虹色になる
// x, y, z: 基準の位置 / r: 半径 / ax, ay: 浮遊の振れ幅 / fx, fy: 周波数(整数にするとループがつながる)
const ORBS = [
{ x: -2.7, y: 0.35, z: 0, r: 1.35, ax: 0.25, ay: 0.3, fx: 1, fy: 1, phase: 0 },
{ x: 2, y: 1.15, z: 0.4, r: 0.8, ax: 0.2, ay: 0.25, fx: 1, fy: 2, phase: 1.7 },
{ x: 3.3, y: -1.25, z: -0.6, r: 1.1, ax: 0.18, ay: 0.22, fx: 2, fy: 1, phase: 3.1 },
{ x: 0.1, y: -1.45, z: 0.8, r: 0.55, ax: 0.3, ay: 0.18, fx: 1, fy: 2, phase: 4.4 },
{ x: -0.55, y: 1.7, z: 0.2, r: 0.42, ax: 0.22, ay: 0.2, fx: 2, fy: 1, phase: 5.6 },
];
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 });
const g = canvas.getContext("2d");
const 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, name] of [[0.18, 0.22, "--spot-a"], [0.82, 0.78, "--spot-b"], [0.66, 0.12, "--spot-c"]]) {
const spot = g.createRadialGradient(x * w, y * h, 0, x * w, y * h, 0.55 * 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;
}
const orbVertex = /* glsl */ `
varying vec3 vNormal; varying vec3 vViewPos; varying float vScale;
void main() {
vec4 mv = modelViewMatrix * vec4(position, 1.0);
vViewPos = mv.xyz; vNormal = normalize(normalMatrix * normal);
vScale = length(modelMatrix[0].xyz); // 球の半径。大きい球ほど強く屈折させる
gl_Position = projectionMatrix * mv;
}
`;
const orbFragment = /* glsl */ `
uniform sampler2D uBg; uniform vec2 uResolution; uniform float uIor; uniform float uStrength; uniform float uDispersion;
varying vec3 vNormal; varying vec3 vViewPos; varying float vScale;
void main() {
vec3 n = normalize(vNormal); vec3 v = normalize(-vViewPos);
vec2 uv = gl_FragCoord.xy / uResolution;
vec2 toUv = vec2(uResolution.y / uResolution.x, 1.0) * uStrength * vScale;
// 屈折した向きのぶん、背景を引く位置をずらす。色ごとに屈折率を変えて、縁に虹色を出す
vec3 rr = refract(-v, n, 1.0 / uIor);
vec3 rg = refract(-v, n, 1.0 / (uIor + uDispersion));
vec3 rb = refract(-v, n, 1.0 / (uIor + uDispersion * 2.0));
vec3 color = vec3(texture2D(uBg, uv + rr.xy * toUv).r, texture2D(uBg, uv + rg.xy * toUv).g, texture2D(uBg, uv + rb.xy * toUv).b);
float facing = clamp(dot(n, v), 0.0, 1.0);
color = mix(color, vec3(0.97, 0.96, 1.0), pow(1.0 - facing, 3.0) * 0.6); // 縁ほど空を映す(フレネル)
color += pow(max(dot(reflect(-normalize(vec3(-0.55, 0.65, 0.55)), n), v), 0.0), 80.0) * 0.9; // 左上からの光のハイライト
gl_FragColor = vec4(color, 1.0);
}
`;
document.querySelectorAll(".orb-hero").forEach((hero) => {
const css = (name) => getComputedStyle(hero).getPropertyValue(name).trim();
const renderer = new THREE.WebGLRenderer({ canvas: hero.querySelector(".orb-hero__canvas"), antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.outputColorSpace = THREE.LinearSRGBColorSpace; // 背景も球も、canvas に描いた色をそのまま出す
const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(35, 1, 0.1, 100); camera.position.z = 10;
const uniforms = { uBg: { value: null }, uResolution: { value: new THREE.Vector2(1, 1) },
uIor: { value: IOR }, uStrength: { value: STRENGTH }, uDispersion: { value: DISPERSION } };
const geometry = new THREE.SphereGeometry(1, 64, 48);
const material = new THREE.ShaderMaterial({ uniforms, vertexShader: orbVertex, fragmentShader: orbFragment });
const orbs = ORBS.map((o) => { const mesh = new THREE.Mesh(geometry, material); mesh.scale.setScalar(o.r); scene.add(mesh); return mesh; });
const draw = (t) => {
const a = (t / DURATION) * Math.PI * 2;
ORBS.forEach((o, i) => orbs[i].position.set(o.x + o.ax * Math.sin(o.fx * a + o.phase), o.y + o.ay * Math.cos(o.fy * a + o.phase), o.z));
renderer.render(scene, camera);
};
const sizes = new ResizeObserver(() => {
const { clientWidth: w, clientHeight: h } = hero;
camera.aspect = w / h; camera.zoom = THREE.MathUtils.clamp(camera.aspect / 1.7, 0.6, 1); // 縦長では引いて、球を収める
camera.updateProjectionMatrix();
renderer.setSize(w, h, false); // 大きさは CSS が決めるので、style は書き換えない
const { x, y } = renderer.getDrawingBufferSize(uniforms.uResolution.value); scene.background?.dispose();
const texture = new THREE.CanvasTexture(drawBackground(x, y, css)); // 球のシェーダーも、この同じ絵を画面の座標で引く
texture.minFilter = THREE.LinearFilter; texture.generateMipmaps = false; // 画面と 1:1 なので、縮小用の段は作らない
scene.background = uniforms.uBg.value = texture;
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.dispose(); material.dispose(); scene.background?.dispose();
renderer.dispose(); renderer.forceContextLoss();
};
});