本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
ローディング

ローディング中に光の粒が輪になって回る

デモ

仕様

動作
DOM+WebGL
種類
シーン
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

光の輪は、輪の形の板(RingGeometry)にフラグメントシェーダーで描きます。画素ごとに、輪の中心線からの距離で芯とにじみの 2 段にぼかし、角度から「頭からどれだけ後ろか」を求めて、頭に近いほど明るく白く、後ろほど暗く、スレートからラベンダーグレーへ移します。粒(Points)も同じ式で明るさを決め、頭の近くほど大きく、外へ散らします。

頭は 2 つで、1 ループ(4 秒)に 2 周します。粒は同じ間に 1 周だけ流れるので、頭をくぐるたびに光ります。頭・粒の流れ・またたきは、どれも 1 ループで整数の周になるので、継ぎ目が出ません。頭から遠い所にも 22% の明るさを残し、尾が消えたあとも輪の形が見えるようにしています。

頭の前を明るさ 0 のままにすると、頭の直前と直後で値が飛び、なめらかなにじみが頭の位置でまっすぐ切れて見えます。前はごく短いガウスで立ち上げ、後ろへ引く指数と比べて max を取ります。また、粒の点だけで輪を作ると細く暗く見えるので、形の光はシェーダーで描き、点はきらめきに回しています。加算合成なので、粒を多く・大きくしすぎると、輪全体が白く飛びます。

輪と粒の色(後ろ・途中・頭)と地の色は、CSS の変数で変えられます。読み込みの進み具合を set に 0〜1 で渡すと数字が追いかけ、終わったら done で 100% まで進めて画面ごと消し、WebGL の文脈も捨てます。数字は読み上げ向けに、進み具合を表す要素の値にも書きます。3D や WebGL を使うサイトの、最初の全画面ローディングに向いています。

コード

HTML
<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>

CSS
.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;
}

JavaScript
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"));

インタラクションデザインについて、
お気軽にご相談ください。

ここに並べているのは、Web サイトの中で動く UI のサンプルです。新しく作るサイトに組み込むことも、いま公開中のサイトに、ボタンやヒーロー、スクロールの演出だけを足すこともできます。設計から実装までの進め方はWeb サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。