デモ
デモを読み込めませんでした。
仕様
- 用途
- ローディング
- 動作
- DOM+WebGL
- 種類
- シーン
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
光の輪の上を2つの明るい先頭が尾を引いて回り、輪に沿って流れる細かな粒が、先頭に追い越されるたびに光ります。先頭は4秒のループで輪を2周しますが、粒は同じ時間に1周しか流れないので、何度も追い越されることになります。
輪の光は、ドーナツ形の平面(RingGeometry)にフラグメントシェーダーで描いています。ピクセルごとに中心線からの距離を測って、明るい芯と淡いにじみに分けてぼかし、角度からは先頭からどれだけ後ろにあるかを求めます。先頭に近いほど白に近く、離れるほど暗く、色はスレートカラーからラベンダーへ移ります。粒(Points)も同じ式で明るさを決め、先頭に近いものほど大きく、外側へ散らしています。
輪の形まで粒の点で作らなかったのは、点だけだと輪が細く暗く見えるからです。形の光はシェーダーに任せ、粒はきらめきの役に回しました。粒は加算合成なので、数や大きさを増やしすぎると、重なった光で輪全体が白く飛びます。
先頭の前側をただ真っ暗にしておくと、直前と直後で明るさの値が飛び、後ろへなめらかに伸びるにじみが、先頭の位置でまっすぐ断ち切られて見えます。前側はごく短いガウス関数で立ち上げ、尾を引く指数関数と比べて max で大きいほうを取っています。先頭から遠いところにも22%の明るさを残し、尾が消えても輪の形がうっすら見えるようにしました。
先頭の回転、粒の流れ、粒のまたたきは、どれもループのあいだにちょうど整数回の周期が収まるので、継ぎ目が出ません。読み込みの進み具合は set で受け取り、done では数字を最後まで進めてから画面ごと消して、WebGL のコンテキストも破棄します。数字は進み具合を表す要素の値にも書き込むので、スクリーンリーダーでも読めます。
コード
<div class="particle-loader">
<!-- 光の輪は飾りなので、スクリーンリーダーからは外す -->
<canvas class="particle-loader__canvas" aria-hidden="true"></canvas>
<div class="particle-loader__panel" role="progressbar" aria-label="読み込みの進み具合"
aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
<p class="particle-loader__number" aria-hidden="true"><span class="particle-loader__digits">0</span><span class="particle-loader__unit">%</span></p>
</div>
<p class="particle-loader__note">3D空間を読み込んでいます…</p>
</div>
.particle-loader {
--ground: #111111; /* 背景色。script.js が読んで WebGL の背景色にする */
--ink: #eeece6; /* 数字の色 */
--mute: #898989; /* 輪の下の文言の色 */
/* 輪と粒の光。頭から遠い所 → 頭の近く → 頭の順に移る。script.js が読んでシェーダーへ渡す */
--tail: #b0a5ca;
--mid: #98b1c4;
--head: #f6f1e8;
--glow: #877fa1; /* 奥のぼんやりした光 */
/* 輪の大きさの目安。縦長の画面では、script.js がカメラを引いて輪を幅に収めるので、幅で決まる */
--ring: min(100dvh, 133.33vw);
position: fixed;
inset: 0;
z-index: 10;
display: grid;
place-items: center;
background: var(--ground);
color: var(--ink);
}
.particle-loader__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.particle-loader__panel {
position: relative; /* 輪の上に載せる */
}
.particle-loader__number {
margin: 0;
font-size: calc(var(--ring) * 0.089); /* 高さ 720px の画面で 64px */
font-weight: 800;
line-height: 1;
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
}
.particle-loader__unit {
margin-left: 0.16em;
font-size: 0.4em;
font-weight: 700;
opacity: 0.55;
}
.particle-loader__note {
position: absolute;
top: calc(50% + var(--ring) / 3); /* 輪の少し下 */
left: 16px;
right: 16px;
margin: 0;
color: var(--mute);
font-size: 16px;
line-height: 1;
letter-spacing: 0.04em;
text-align: center;
}
import { gsap } from "gsap";
import * as THREE from "three";
const DURATION = 4; // 頭が 2 周、粒が 1 周して、同じ絵に戻るまで(秒)
const COUNT = 6000; // 粒の数。加算合成なので、多く・大きくしすぎると輪が白く飛ぶ
const RADIUS = 1.3; // 輪の半径(カメラからの距離は 6)
const TILT = 0.38; // 輪の傾き(ラジアン)。0 で正面
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SPEED = reduceMotion ? 12 : 1; // 動きを減らす設定では、数字をほぼ一瞬で進める
// 輪と粒で共通の式。角度から「頭からどれだけ後ろか」を求め、頭で 1、後ろほど指数で暗くして、色を移す。
// 頭の前もごく短いガウスで立ち上げる(前を 0 にすると、にじみが頭の位置でまっすぐ切れる)
const common = /* glsl */ `
const float TAU = 6.28318530718;
uniform float uPhase; uniform float uRadius; uniform float uScale; uniform vec3 uTail; uniform vec3 uMid; uniform vec3 uHead; uniform vec3 uGlow;
float heatAt(float angle) { // 頭は 2 つで、1 ループに 2 周する
float behind = mod(uPhase * TAU * 2.0 - angle, TAU / 2.0);
return max(exp(-behind * 1.5), exp(-pow(TAU / 2.0 - behind, 2.0) / 0.01)); }
vec3 heatColor(float heat) { return mix(mix(uTail, uMid, smoothstep(0.08, 0.5, heat)), uHead, smoothstep(0.6, 1.0, heat)); }
`;
const planeVertex = `varying vec2 vPos; void main() { vPos = position.xy; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`;
const ringFragment = `${common} varying vec2 vPos;
void main() { // 輪の中心線からの距離で、芯とにじみの 2 段にぼかす
float heat = heatAt(atan(vPos.y, vPos.x)), d = length(vPos) - uRadius;
gl_FragColor = vec4(heatColor(heat), exp(-d * d / 0.0036) * (0.22 + 0.78 * heat) + exp(-d * d / 0.0432) * (0.04 + 0.3 * heat));
}`;
const glowFragment = `${common} varying vec2 vPos; // 奥のぼんやりした光(半径 4)
void main() { gl_FragColor = vec4(uGlow, 0.12 * pow(1.0 - smoothstep(0.0, 4.0, length(vPos)), 2.0)); }`;
const dustVertex = `${common} attribute vec4 aSeed; varying float vHeat; varying float vAlpha;
void main() {
float angle = (aSeed.x + uPhase) * TAU; // 粒は 1 ループに 1 周。頭と速さが違うので、頭をくぐるたびに光る
float heat = heatAt(angle), r = uRadius + aSeed.y * (0.06 + 0.14 * heat); // 頭の近くほど外へ散らす
vec4 mv = modelViewMatrix * vec4(cos(angle) * r, sin(angle) * r, aSeed.z * (0.04 + 0.12 * heat), 1.0);
gl_PointSize = 3.2 * (0.5 + aSeed.w) * (0.8 + 1.2 * heat) * uScale * (6.0 / -mv.z);
gl_Position = projectionMatrix * mv; vHeat = heat;
vAlpha = (0.22 + 0.78 * heat) * (0.7 + 0.3 * sin(uPhase * TAU * 8.0 + aSeed.x * 431.0)); // またたき
}`;
const dustFragment = `${common} varying float vHeat; varying float vAlpha;
void main() { vec2 p = gl_PointCoord - 0.5; gl_FragColor = vec4(heatColor(vHeat), exp(-dot(p, p) * 18.0) * vAlpha); }`;
function createLoader(root) {
const css = (name) => getComputedStyle(root).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 uniforms = { uPhase: { value: 0 }, uRadius: { value: RADIUS }, uScale: { value: 1 }, uTail: rgb("--tail"), uMid: rgb("--mid"), uHead: rgb("--head"), uGlow: rgb("--glow") };
const material = (vertexShader, fragmentShader) =>
new THREE.ShaderMaterial({ uniforms, vertexShader, fragmentShader, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending });
const renderer = new THREE.WebGLRenderer({ canvas: root.querySelector(".particle-loader__canvas"), antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setClearColor(css("--ground"));
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(40, 1, 0.1, 50).translateZ(6); // 縦横比は resize で入れる
const seeds = new Float32Array(COUNT * 4);
// 粒ごとに、輪の上の位置・半径のずれ・厚みのずれ・大きさ。ずれは乱数の和にして、輪の中心線に寄せる
for (let i = 0; i < seeds.length; i += 4) seeds.set([Math.random(), (Math.random() + Math.random() + Math.random()) / 1.5 - 1, Math.random() + Math.random() - 1, Math.random()], i);
const geometry = new THREE.BufferGeometry().setAttribute("aSeed", new THREE.BufferAttribute(seeds, 4));
geometry.setAttribute("position", new THREE.BufferAttribute(new Float32Array(COUNT * 3), 3)); // 位置はシェーダーが決める
const dust = new THREE.Points(geometry, material(dustVertex, dustFragment));
dust.frustumCulled = false; // 頂点の位置が 0 のままなので、カリングで消えないようにする
const ring = new THREE.Mesh(new THREE.RingGeometry(RADIUS - 0.45, RADIUS + 0.45, 256, 1), material(planeVertex, ringFragment)); // 芯とにじみが収まる幅の平面
const glow = new THREE.Mesh(new THREE.PlaneGeometry(8, 8), material(planeVertex, glowFragment)).translateZ(-1);
scene.add(new THREE.Group().add(ring, dust).rotateX(TILT), glow);
const draw = (t) => { uniforms.uPhase.value = (t % DURATION) / DURATION; renderer.render(scene, camera); };
const sizes = new ResizeObserver(() => {
const { clientWidth: w, clientHeight: h } = root;
camera.aspect = w / h;
camera.zoom = Math.min(1, camera.aspect / 0.75); // 縦長の画面では引いて、輪を幅に収める(CSS の --ring と同じ割合)
camera.updateProjectionMatrix();
renderer.setSize(w, h, false); // 大きさは CSS が決めるので、style は書き換えない
uniforms.uScale.value = (renderer.getPixelRatio() * h * camera.zoom) / 720; // 粒の大きさを、輪の大きさに合わせる
if (reduceMotion) draw(0.5); // 動きを減らす設定では、1 枚だけ描いて止める
});
sizes.observe(root);
if (!reduceMotion) renderer.setAnimationLoop((ms) => draw(ms / 1000));
const [panel, digits] = [".particle-loader__panel", ".particle-loader__digits"].map((selector) => root.querySelector(selector));
const state = { value: 0 };
const show = () => { const value = String(Math.round(state.value)); digits.textContent = value; panel.setAttribute("aria-valuenow", value); };
const destroy = () => { // 閉じたら WebGL のコンテキストごと破棄する。残すと、ページを移るたびに積み上がる
renderer.setAnimationLoop(null); sizes.disconnect();
scene.traverse((node) => { node.geometry?.dispose(); node.material?.dispose(); });
renderer.dispose(); renderer.forceContextLoss(); root.remove();
};
return {
set(progress) { // 進み具合を 0〜1 で渡す。飛び飛びで届く値でも、トゥイーンを挟めば滑らかに進む
gsap.to(state, { value: gsap.utils.clamp(0, 1, progress) * 100, duration: 0.8 / SPEED, ease: "power2.out", overwrite: true, onUpdate: show });
},
done() { // 読み込みが終わったら呼ぶ。100% まで進め、少し置いてから画面ごと消える
return gsap.timeline({ onComplete: destroy })
.to(state, { value: 100, duration: 0.6, ease: "power2.inOut", overwrite: true, onUpdate: show })
.to(root, { autoAlpha: 0, duration: 0.4, ease: "power1.in" }, "+=0.35")
.timeScale(SPEED);
},
};
}
// 例: 受け取った量を loader.set(loaded / total) で渡し、読み終わったら loader.done()
export const loader = createLoader(document.querySelector(".particle-loader"));