デモ
デモを読み込めませんでした。
仕様
- 用途
- ヒーロー
- 動作
- WebGL
- 種類
- シーン
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
9000 個の点を、フィボナッチ格子(高さを等間隔に刻み、黄金角ずつ回す並べ方)で球の面にほぼ均等に置き、Points の頂点シェーダーで 3D のノイズの分だけ外へ押し出します。押し出した量で点の大きさと色(濃いスレート → ラベンダーグレー → くすみローズと、暗い段から明るい段へ)を決め、球の裏側の点は暗くして立体に見せます。
ノイズは細かさの違う 2 つを重ね、大きなうねりに細かい凹凸を足しています。ノイズを引く位置を円周に沿って回し、球の回転も 1 ループ(12 秒)でちょうど 1 周にしています。点ごとのまたたきも周波数を 1〜3 の整数にしているので、ひと回りすると元の形に戻り、継ぎ目が出ません。
点は AdditiveBlending(加算合成)で重ねるので、点を増やしたり大きくしたりすると、重なりが増えて球が白く飛びます。増やすなら点を小さくしてください。点はシェーダーで外へ押し出すので、three.js が測る形の範囲(半径 1 の球)と実際の広がりが合いません。frustumCulled を false にして、カリングで消えないようにします。点の大きさは画面の高さと描画の倍率に合わせ、縦長の画面ではカメラを引いて球を幅に収めます。
背景は画面いっぱいの板に描くシェーダーで、中心がわずかに明るい墨の地に、球の後ろのごく淡いにじみを足しています。なめらかなグラデーションに出やすい縞は、画素ごとの細かい揺らぎで散らしています。点の 3 色と地の 2 色は CSS の変数で変えられます。AI・データ・宇宙を扱うサービスのヒーローに向いています。
コード
<section class="sphere-hero">
<!-- 背景は飾りなので、読み上げからは外す -->
<canvas class="sphere-hero__canvas" aria-hidden="true"></canvas>
<div class="sphere-hero__inner">
<p class="sphere-hero__eyebrow">DATA & AI STUDIO</p>
<h1 class="sphere-hero__title">データを、<br>価値に変える。</h1>
<p class="sphere-hero__lead">ここに説明文が入ります。</p>
<a class="sphere-hero__button" href="/works/">事例を見る</a>
</div>
</section>
.sphere-hero {
/* 点の色(谷・中・山)と地の色。script.js がこの 5 つを読んでシェーダーへ渡すので、#rrggbb で書く */
--low: #688297; /* 谷の点(スレートの濃い段) */
--mid: #b0a5ca; /* ラベンダーグレー */
--high: #d29b97; /* 山の点(くすみローズ) */
--center: #1d2226; /* 地の中心(墨のスレートをごく暗くした色) */
--edge: #08090a;
--ink: #ffffff;
--soft: #eeece6;
--mute: #dedcd6;
--gutter: clamp(24px, 7vw, 96px);
position: relative;
display: grid;
place-items: center;
min-height: min(90vh, 720px);
padding: 0 var(--gutter);
overflow: hidden;
color: var(--ink);
text-align: center;
/* 読み込み中と、WebGL が使えないときの地 */
background-color: var(--edge);
}
.sphere-hero__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.sphere-hero__inner {
position: relative; /* 背景の上に載せる */
max-width: 36em;
/* 球の点の上でも文字の縁が立つよう、地の色の影を敷く */
text-shadow: 0 2px 24px var(--edge);
}
.sphere-hero__eyebrow {
margin: 0;
color: var(--mute);
font-size: 13px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.sphere-hero__title {
margin: 24px 0 0;
font-size: clamp(40px, 8vw, 72px);
line-height: 1.05;
letter-spacing: -0.02em;
}
.sphere-hero__lead {
margin: 24px 0 0;
color: var(--soft);
font-size: 18px;
line-height: 1.8;
word-break: auto-phrase; /* 和文を文節の切れ目で折り返す(対応していないブラウザでは、ふつうに折り返す) */
}
.sphere-hero__button {
display: inline-grid;
place-items: center;
height: 48px;
margin-top: 32px;
padding: 0 32px;
border-radius: 999px;
background-color: var(--ink);
color: var(--edge);
font-size: 15px;
font-weight: 600;
letter-spacing: 0.04em;
text-decoration: none;
text-shadow: none;
}
.sphere-hero__button:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
import * as THREE from "three";
const DURATION = 12; // 球がひと回りし、うねりも同じ形に戻るまで(秒)
const COUNT = 9000; // 点の数。加算合成なので、増やすなら点を小さくする(重なるほど白く飛ぶ)
const RADIUS = 1.7; // 球の半径(カメラからの距離は 8)
const FREQUENCY = 1.5, AMPLITUDE = 0.24; // ノイズの細かさと、外へ押し出す強さ(半径に対する比)
const POINT_SIZE = 3.2; // 点の大きさ(高さ 720px の画面での px。手前の谷の点の目安)
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const pointsVertex = /* glsl */ `
uniform float uPhase; uniform float uFrequency; uniform float uAmplitude; uniform float uRadius; uniform float uSize;
uniform vec3 uLow; uniform vec3 uMid; uniform vec3 uHigh; attribute float aSeed; varying vec3 vColor; varying float vAlpha;
vec3 hash3(vec3 p) {
p = fract(p * vec3(0.1031, 0.1030, 0.0973)); p += dot(p, p.yxz + 33.33);
return fract((p.xxy + p.yxx) * p.zyx) * 2.0 - 1.0;
}
float corner(vec3 i, vec3 f, vec3 o) { return dot(hash3(i + o), f - o); }
float noise3(vec3 p) { // 3 次元のグラディエントノイズ。格子の 8 つの角の勾配をなめらかに混ぜる
vec3 i = floor(p), f = fract(p), u = f * f * (3.0 - 2.0 * f);
return mix(mix(mix(corner(i, f, vec3(0, 0, 0)), corner(i, f, vec3(1, 0, 0)), u.x), mix(corner(i, f, vec3(0, 1, 0)), corner(i, f, vec3(1, 1, 0)), u.x), u.y),
mix(mix(corner(i, f, vec3(0, 0, 1)), corner(i, f, vec3(1, 0, 1)), u.x), mix(corner(i, f, vec3(0, 1, 1)), corner(i, f, vec3(1, 1, 1)), u.x), u.y), u.z);
}
void main() {
vec3 dir = position; // 単位球の上の点。ノイズを引く位置は円周の上を回り、ひと回りで同じ形に戻る
vec3 a = vec3(cos(uPhase), sin(uPhase), 0.0) * 0.9, b = vec3(0.0, cos(uPhase * 2.0 + 0.7), sin(uPhase * 2.0 + 0.7)) * 0.5;
float n = noise3(dir * uFrequency + a) * 0.8 + noise3(dir * uFrequency * 2.3 + b + 3.1) * 0.35;
vec4 mv = modelViewMatrix * vec4(dir * uRadius * (1.0 + n * uAmplitude), 1.0);
gl_Position = projectionMatrix * mv;
float k = clamp(n * 2.4 + 0.5, 0.0, 1.0); // 外へ押し出された点ほど大きく、谷 → 中 → 山の色へ
vColor = k < 0.5 ? mix(uLow, uMid, k * 2.0) : mix(uMid, uHigh, k * 2.0 - 1.0);
float facing = normalize(normalMatrix * dir).z * 0.5 + 0.5; // 球の裏側の点は暗くして、立体に見せる
float twinkle = 0.75 + 0.25 * sin(uPhase * (1.0 + floor(aSeed * 3.0)) + aSeed * 40.0); // またたきの周波数は 1〜3 の整数
vAlpha = mix(0.16, 1.0, facing * facing) * twinkle;
gl_PointSize = uSize * (0.7 + 1.1 * k) * (8.0 / -mv.z);
}
`;
// 丸くぼけた点。加算合成なので、重なった所ほど明るくなる
const pointsFragment = `varying vec3 vColor; varying float vAlpha;
void main() { float d = length(gl_PointCoord - 0.5); gl_FragColor = vec4(vColor, (1.0 - smoothstep(0.12, 0.5, d)) * vAlpha); }`;
const backgroundVertex = `varying vec2 vUv; void main() { vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }`;
// 中心がわずかに明るい地に、球の後ろのごく淡いにじみ。最後の行で 8bit の段差(縞)を細かい揺らぎで散らす
const backgroundFragment = `uniform vec2 uResolution; uniform vec3 uCenter; uniform vec3 uEdge; uniform vec3 uGlow; varying vec2 vUv;
void main() {
float r = length((vUv - 0.5) * vec2(uResolution.x / uResolution.y, 1.0));
vec3 color = mix(uCenter, uEdge, smoothstep(0.0, 0.95, r));
color += uGlow * exp(-r * r / 0.12) * 0.12;
color += (fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453) - 0.5) / 255.0;
gl_FragColor = vec4(color, 1.0);
}`;
document.querySelectorAll(".sphere-hero").forEach((hero) => {
const css = (name) => getComputedStyle(hero).getPropertyValue(name).trim();
// THREE.Color は線形の値に直すので、シェーダーには hex を 255 で割った値をそのまま渡す
const rgb = (name) => { const hex = Number.parseInt(css(name).slice(1), 16); return { value: new THREE.Vector3(hex >> 16, (hex >> 8) & 255, hex & 255).divideScalar(255) }; };
const renderer = new THREE.WebGLRenderer({ canvas: hero.querySelector(".sphere-hero__canvas"), antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
const scene = new THREE.Scene(), camera = new THREE.PerspectiveCamera(35, 1, 0.1, 100).translateZ(8); // 縦横比は resize で入れる
const resolution = { value: new THREE.Vector2(1, 1) };
const background = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), new THREE.ShaderMaterial({ depthTest: false, depthWrite: false,
uniforms: { uResolution: resolution, uCenter: rgb("--center"), uEdge: rgb("--edge"), uGlow: rgb("--mid") }, vertexShader: backgroundVertex, fragmentShader: backgroundFragment }));
Object.assign(background, { frustumCulled: false, renderOrder: -1 }); // 画面いっぱいの板なので、カリングさせず、いつも最初に描く
// フィボナッチ格子: 高さを等間隔に刻み、黄金角ずつ回すと、球の面にほぼ均等に並ぶ
const positions = new Float32Array(COUNT * 3), seeds = new Float32Array(COUNT), golden = Math.PI * (3 - Math.sqrt(5));
for (let i = 0; i < COUNT; i++) {
const y = 1 - (2 * (i + 0.5)) / COUNT, r = Math.sqrt(1 - y * y);
positions.set([Math.cos(golden * i) * r, y, Math.sin(golden * i) * r], i * 3);
seeds[i] = Math.random(); // 点ごとのまたたきのずれ
}
const geometry = new THREE.BufferGeometry().setAttribute("position", new THREE.BufferAttribute(positions, 3)).setAttribute("aSeed", new THREE.BufferAttribute(seeds, 1));
const uniforms = { uPhase: { value: 0 }, uFrequency: { value: FREQUENCY }, uAmplitude: { value: AMPLITUDE }, uRadius: { value: RADIUS },
uSize: { value: POINT_SIZE }, uLow: rgb("--low"), uMid: rgb("--mid"), uHigh: rgb("--high") };
const points = new THREE.Points(geometry, new THREE.ShaderMaterial({ uniforms, vertexShader: pointsVertex, fragmentShader: pointsFragment,
transparent: true, depthWrite: false, blending: THREE.AdditiveBlending }));
points.frustumCulled = false; // 位置はシェーダーが決めるので、カリングで消えないようにする
scene.add(background, points);
const draw = (t) => {
const a = ((t % DURATION) / DURATION) * Math.PI * 2;
uniforms.uPhase.value = a;
points.rotation.set(0.35 + 0.1 * Math.sin(a), a, 0.08 * Math.sin(a * 2)); // 1 ループでちょうど 1 周
renderer.render(scene, camera);
};
const sizes = new ResizeObserver(() => {
const { clientWidth: w, clientHeight: h } = hero;
camera.aspect = w / h;
camera.zoom = Math.min(1, camera.aspect); // 縦長の画面では引いて、球を幅に収める
camera.updateProjectionMatrix();
renderer.setSize(w, h, false); // 大きさは CSS が決めるので、style は書き換えない
renderer.getDrawingBufferSize(resolution.value);
uniforms.uSize.value = (POINT_SIZE * renderer.getPixelRatio() * h * camera.zoom) / 720; // 点の大きさを、球の大きさに合わせる
if (reduceMotion) draw(7); // 動きを減らす設定では、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();
scene.traverse((node) => { node.geometry?.dispose(); node.material?.dispose(); }); renderer.dispose(); renderer.forceContextLoss();
};
});