本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100

ローディング

小さな立方体が集まって組み上がり、崩れてはまた組み直すローダー

デモ

仕様

動作
WebGL
種類
シーン
使うもの
three/addons/geometries/RoundedBoxGeometry.js
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

散らばっていた64個の小さな黒い金属の立方体が、下の段から順に集まって、1つの大きな立方体になります。組み上がると90°向きを変え、今度は上の段から崩れて元の散らばった形へ。これを2.7秒ごとにくり返します。1つの立方体が転がるローダーとは違い、組み上がっていく途中そのものを読み込み中の合図にしました。

64個は Three.js の InstancedMesh 1つにまとめてあり、位置と向きの行列を毎フレーム計算し直しています。集まる動きは0.62秒の power4.out で、段ごとに0.1秒ずつ遅らせました。勢いよく飛んできて、最後にすっと収まる動きにしたかったからです。

意外とつまずくのがループのつなぎ目です。回ってから崩れるので、1周の終わりは始まりより90°向きが変わっています。そこで、散らばり方を乱数で決めるのは右奥の一角の立方体だけにして、残りはそれを Y 軸まわりに90°ずつ回して作りました。散らばった形全体が、回しても元と同じ形になるため、立方体どうしの持ち場が入れ替わるだけで、次の周の始まりにそのままつながります。

黒い金属らしさは、色ではなく映り込みで作っています。metalness を最大にした素材は自分の色をほとんど持たないので、暗い部屋に細長いソフトボックスを置いたスタジオを PMREM でぼかし、環境マップにしました。角は RoundedBoxGeometry で少し丸めています。丸みに照明が細い白い線となって映るので、組み上がったあとも1個ずつの輪郭が見分けられます。

読み込みが終わったら done() を呼んでください。次に組み上がって回り終えたところで止まり、画面ごとフェードアウトしてから WebGL のコンテキストを片付けます。散らばる広がりを小さくすれば、崩れ方が控えめになって全体が落ち着きます。

コード

HTML
<div class="blocks-loader" role="status">
  <div class="blocks-loader__scene">
    <!-- 立方体は飾りなので、スクリーンリーダーからは外す -->
    <canvas class="blocks-loader__canvas" aria-hidden="true"></canvas>
    <p class="blocks-loader__label">LOADING</p>
  </div>
</div>

CSS
.blocks-loader {
  /* 立方体と映り込みの色。script.js がこの 4 つを読んでシーンへ渡す */
  --metal: #b4b7bb; /* 金属の反射の色。映り込みに乗算される */
  --room: #262626; /* 映り込む部屋の色。金属のベースの明るさになる(暗いほど黒い) */
  --floor: #999999; /* 映り込む床の色。下を向いた面の照り返しになる */
  --shadow: #1c1c1c; /* 床の影。#rrggbb で書く */
  /* 背景のグラデーション(無彩色のグレー)。立方体の後ろがいちばん明るい */
  --bg-center: #f2f2f2;
  --bg-middle: #d9d9d9;
  --bg-edge: #bfbfbf;
  --ink: #262626;
  --size: min(92vw, 88vh, 720px); /* 立方体が散らばる範囲の一辺 */
  position: fixed;
  inset: 0;
  z-index: 10;
  display: grid;
  place-items: center;
  background: radial-gradient(ellipse at 50% 42%, var(--bg-center) 0%, var(--bg-middle) 55%, var(--bg-edge) 100%);
  color: var(--ink);
}

.blocks-loader__scene {
  position: relative;
  width: var(--size);
  aspect-ratio: 1;
}

.blocks-loader__canvas {
  display: block;
  width: 100%;
  height: 100%;
}

/* 散らばった立方体のすぐ下に置く */
.blocks-loader__label {
  position: absolute;
  top: 87%;
  left: 0;
  right: 0;
  margin: 0;
  padding-left: 0.14em; /* 最後の字の後ろの字間の分だけ、左にも足して中央にそろえる */
  font-size: 13px;
  font-weight: 700;
  line-height: 1;
  letter-spacing: 0.14em;
  text-align: center;
}

JavaScript
import * as THREE from "three";
import { gsap } from "gsap";
import { RoundedBoxGeometry } from "three/addons/geometries/RoundedBoxGeometry.js";

const DURATION = 2.7; // 集まる → 回る → 崩れる、の 1 周(秒)
const N = 4, SIZE = 0.44, GAP = 0.035, RADIUS = 0.05; // 1 辺の数(4×4×4 = 64 個)・1 個の大きさ・隙間・角の丸み
const GATHER = { start: 0, layer: 0.1, spread: 0.12, duration: 0.62, ease: gsap.parseEase("power4.out") }; // 下の段から 0.1 秒ずつ遅らせて集まる
const TURN = { start: 1.08, duration: 0.65, ease: gsap.parseEase("power3.inOut") }; // 組み上がったら 90° 回る
const BREAK = { start: 1.85, layer: 0.06, spread: 0.1, duration: 0.5, ease: gsap.parseEase("power2.inOut") }; // 上の段から崩れる
const SCATTER = new THREE.Vector3(1.95, 1.5, 1.95), JITTER = 0.36; // 散らばったときの広がり(組み上がった位置からの倍率)とばらつき
const FREEZE = TURN.start + TURN.duration; // 読み込みが終わったら、回り終えたこの時刻で止める
const LIGHTS = [[16, 4, 7, 0, 10, 2], [3, 16, 5, -9, 1, 4], [2.4, 16, 3.2, 9, 1, 3], [1.6, 16, 9, -6, 1, -9], [1.6, 16, 9, 6, 1, -9]]; // 映り込むソフトボックス [幅, 高さ, 明るさ, x, y, z]
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const shadowVertex = `varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`;
// 床の影は、真下の濃く小さい芯と、外へ広く消えていくにじみの 2 層(シャドウマップを使わない)。平面の縁で 0 にする
const shadowFragment = /* glsl */ `uniform vec3 uColor; uniform float uOpacity, uCore; varying vec2 vUv; void main() {
  float d = length(vUv - 0.5) * 2.0, soft = exp(-d * d * 3.5) * uOpacity, core = exp(-d * d * 18.0) * uCore;
  gl_FragColor = vec4(uColor, (1.0 - (1.0 - soft) * (1.0 - core)) * (1.0 - smoothstep(0.7, 1.0, d))); }`;
const clamp01 = (v) => Math.min(Math.max(v, 0), 1), up = new THREE.Vector3(0, 1, 0);

function createLoader(root) {
  const canvas = root.querySelector(".blocks-loader__canvas"), css = (name) => getComputedStyle(root).getPropertyValue(name).trim();
  const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true }); // 背景は CSS のグラデーション。canvas は透明にして重ねる
  renderer.setPixelRatio(2); renderer.toneMapping = THREE.NeutralToneMapping; // 2 倍で描いて縮め、縁に映る細い照明の線のギザギザを消す
  // 映り込み用のスタジオ(画面には描かない)。暗い部屋が黒いベースの色に、ソフトボックスが上の面や縁の明るい線になる
  const studio = new THREE.Scene(), parts = [], pmrem = new THREE.PMREMGenerator(renderer);
  const fall = (v) => THREE.MathUtils.smoothstep(v, 0, 0.3) * THREE.MathUtils.smoothstep(1 - v, 0, 0.3); // ソフトボックスの縁をなめらかに暗くする
  const data = new Uint8Array(32 * 32 * 4).fill(255);
  for (let i = 0; i < 1024; i++) data.fill(Math.round(255 * fall((i % 32 + 0.5) / 32) * fall((Math.floor(i / 32) + 0.5) / 32)), i * 4, i * 4 + 3);
  const box = new THREE.DataTexture(data, 32, 32); box.magFilter = THREE.LinearFilter; box.needsUpdate = true; parts.push(box);
  const add = (geometry, color, [x, y, z], map = box) => {
    const mesh = new THREE.Mesh(geometry, new THREE.MeshBasicMaterial({ color, map, side: THREE.DoubleSide }));
    mesh.position.set(x, y, z); mesh.lookAt(0, 0, 0); studio.add(mesh); parts.push(geometry, mesh.material); };
  add(new THREE.SphereGeometry(20, 32, 16), css("--room"), [0, 0, 0], null);
  add(new THREE.PlaneGeometry(40, 40), css("--floor"), [0, -5, 0]);
  LIGHTS.forEach(([w, h, power, ...at]) => add(new THREE.PlaneGeometry(w, h), new THREE.Color(power, power, power), at));
  const scene = new THREE.Scene(), env = pmrem.fromScene(studio, 0.01); // PMREM でぼかした映り込みのテクスチャ
  scene.environment = env.texture; parts.forEach((part) => part.dispose()); pmrem.dispose();
  const camera = new THREE.PerspectiveCamera(30, 1, 0.1, 100);
  camera.position.set(0, 3.6, 10.4); camera.lookAt(0, -0.15, 0);

  // 右奥の 1/4 の 16 個だけ散らばり方を乱数で決め、残りは Y 軸で 90° ずつ回して作る。散らばった形が 90° 回しても同じになるので、
  // 90° 回ってから崩れても、次の周の始まりとつながる
  const pieces = [], step = SIZE + GAP, half = (N - 1) / 2, turn = new THREE.Quaternion(), rand = () => Math.random() - 0.5;
  for (let ix = N / 2; ix < N; ix++) for (let iz = N / 2; iz < N; iz++) for (let iy = 0; iy < N; iy++) {
    const home = new THREE.Vector3(ix - half, iy - half, iz - half).multiplyScalar(step);
    const away = home.clone().multiply(SCATTER).add(new THREE.Vector3(rand(), rand(), rand()).multiplyScalar(2 * JITTER));
    const spin = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(rand(), rand(), rand()).normalize(), Math.PI * (0.5 + Math.random() * 0.7));
    for (let k = 0; k < 4; k++) {
      turn.setFromAxisAngle(up, (Math.PI / 2) * k);
      pieces.push({ home: home.clone().applyQuaternion(turn), away: away.clone().applyQuaternion(turn), spin: turn.clone().multiply(spin),
        gather: GATHER.start + iy * GATHER.layer + Math.random() * GATHER.spread, // 下の段から集まり、上の段から崩れる
        break: BREAK.start + (N - 1 - iy) * BREAK.layer + Math.random() * BREAK.spread });
    }
  }
  const blocks = new THREE.InstancedMesh(new RoundedBoxGeometry(SIZE, SIZE, SIZE, 3, RADIUS),
    new THREE.MeshPhysicalMaterial({ color: css("--metal"), roughness: 0.26, metalness: 1, clearcoat: 1, clearcoatRoughness: 0.08 }), pieces.length);
  blocks.frustumCulled = false; // 行列を毎回書き換えるので、古い境界球で消えないようにする
  const hex = Number.parseInt(css("--shadow").slice(1), 16); // シェーダーには、hex を 255 で割った値をそのまま渡す
  const shadow = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), new THREE.ShaderMaterial({ vertexShader: shadowVertex, fragmentShader: shadowFragment, transparent: true, depthWrite: false,
    uniforms: { uColor: { value: new THREE.Vector3(hex >> 16, (hex >> 8) & 255, hex & 255).divideScalar(255) }, uOpacity: { value: 0 }, uCore: { value: 0 } } }));
  shadow.rotation.x = -Math.PI / 2; shadow.position.y = -1.55; scene.add(blocks, shadow);
  const m = new THREE.Matrix4(), p = new THREE.Vector3(), q = new THREE.Quaternion(), yaw = new THREE.Quaternion(), one = new THREE.Vector3(1, 1, 1), rest = new THREE.Quaternion();
  const draw = (time) => {
    const t = time % DURATION;
    yaw.setFromAxisAngle(up, Math.PI / 4 + (Math.PI / 2) * TURN.ease(clamp01((t - TURN.start) / TURN.duration)));
    let together = 0; // 組み上がっている割合(影の濃さに使う)
    pieces.forEach((piece, i) => {
      const k = GATHER.ease(clamp01((t - piece.gather) / GATHER.duration)) * (1 - BREAK.ease(clamp01((t - piece.break) / BREAK.duration))); // 0 = 散らばった位置、1 = 組み上がった位置
      together += k / pieces.length;
      p.lerpVectors(piece.away, piece.home, k).applyQuaternion(yaw); p.y += 0.25;
      blocks.setMatrixAt(i, m.compose(p, q.slerpQuaternions(piece.spin, rest, k).premultiply(yaw), one));
    });
    blocks.instanceMatrix.needsUpdate = true;
    const u = shadow.material.uniforms; // 組み上がるほど、影は濃く小さくまとまる
    u.uOpacity.value = 0.12 + 0.16 * together; u.uCore.value = 0.42 * together; shadow.scale.set(5.2 - 1.8 * together, 3.2 - 1.1 * together, 1);
    renderer.render(scene, camera);
  };
  let clock = 0, last = null, stopAt = Infinity;
  const frame = (ms) => { // 時計は止める時刻(stopAt)を越えない。届いたら片付ける
    clock = Math.min(clock + (last === null ? 0 : Math.min(ms - last, 100) / 1000), stopAt); last = ms; draw(clock); if (clock === stopAt) finish(); };
  const finish = () => { // 組み上がったまま止め、画面ごとフェードアウトして片付ける
    renderer.setAnimationLoop(null); sizes.disconnect();
    gsap.to(root, { autoAlpha: 0, duration: reduceMotion ? 0 : 0.4, ease: "power1.out", onComplete: () => {
      [blocks.geometry, blocks.material, shadow.geometry, shadow.material, env, renderer].forEach((item) => item.dispose()); renderer.forceContextLoss(); root.remove(); } });
  };
  const sizes = new ResizeObserver(() => { renderer.setSize(canvas.clientWidth, canvas.clientHeight, false); draw(clock); }); // 大きさは CSS が決める
  sizes.observe(canvas);
  if (reduceMotion) clock = stopAt = FREEZE; // 動きを減らす設定では、組み上がった立方体を止めたまま見せる
  else renderer.setAnimationLoop(frame);
  return {
    // 読み込みが終わったら呼ぶ。次に組み上がって回り終えたところで止まり、フェードアウトする
    done() { if (reduceMotion) finish(); else stopAt = clock - (clock % DURATION) + FREEZE + (clock % DURATION > FREEZE ? DURATION : 0); },
  };
}

// 例: fetch や画像の読み込みを待って loader.done()
export const loader = createLoader(document.querySelector(".blocks-loader"));

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

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