Three.jsをはじめよう。星空や雪のような幻想的な演出【第7回】

ここまでに、立方体や外部から読み込んだモデルなど、「実体のあるもの」を扱ってきました。前回はクリック判定を学んだことで、ユーザーのアクションに対する返答もできるようになりました。

今回は主役のモデルを引き立てるために、背景に粒子を漂わせたりしてその場の「空気感」を演出してみましょう。

「大量の粒子を出すなんて、パソコンが固まっちゃいそう……」
そう思うかもしれませんが、大丈夫です。Three.jsには、大量の粒子を軽く扱う仕組みがあります。第7回となる今回は、この「パーティクル」を使って、空間を一気に魅力的な世界へと変えていきましょう!

大量の粒子を操る「Points」という仕組み

もし、1,000個の立方体(Mesh)を一つずつ作って動かそうとすると、ブラウザはすぐに悲鳴を上げてしまいます。そこで私たちは、「Points(ポインツ)」という特殊な仕組みを使います。

メッシュとの違い

Mesh
頂点をつないで「面」を作り、質感を出す。
Points
頂点そのものを「点」として描画する。

Meshは頂点の数によってその分負荷も上昇しますが、Pointsは頂点が1つづつしかない為、数千〜数万もの粒子を同時に描画することが可能です。
そのため、パフォーマンスを保ったままリッチな背景が作れるのです。

粒子の「住所録」を作る:BufferGeometry

粒子をたくさん出すには、「どこに粒子を置くか」という大量の座標データ(住所録)が必要です。これを効率よく管理するのが BufferGeometry(バッファ・ジオメトリ) です。

一見大変そうに見えますが、処理自体は粒子を入れる箱、箱内の規則を指定するだけなので案外大変な作業ではありません。

ランダムな位置に粒子を散りばめる

一緒に「星空」のような空間を作ってみましょう。まずはランダムな位置に粒子を散りばめます。

    <script type="module">
      import * as THREE from "three";
      // ─── シーンの基本セットアップ ───────────────────────────────
      const scene = new THREE.Scene();
      const camera = new THREE.PerspectiveCamera(
        75,
        innerWidth / innerHeight,
        0.1,
        100
      );
      const renderer = new THREE.WebGLRenderer({ antialias: true });

      renderer.setSize(innerWidth, innerHeight);
      renderer.setPixelRatio(devicePixelRatio);
      document.body.appendChild(renderer.domElement);

      camera.position.z = 5;

      // ─── パーティクルの生成 ───────────────────────────────────────
      const particlesGeometry = new THREE.BufferGeometry();
      const count = 5000; // 5,000個の粒子

      // 1. 5,000個分の座標(x, y, z)を入れるための配列を用意
      const positions = new Float32Array(count * 3);
      for (let i = 0; i < count * 3; i++) {
        // -5 から 5 の範囲でランダムな位置を決める
        positions[i] = (Math.random() - 0.5) * 10;
      }

      // ジオメトリに座標データをセット(第2引数 3 = x,y,z の3要素ひとまとまり)
      particlesGeometry.setAttribute(
        "position",
        new THREE.BufferAttribute(positions, 3)
      );

      // 2. マテリアル:オプション未指定 → 白い点、サイズはデフォルト(1)
      const particlesMaterial = new THREE.PointsMaterial({
        color: 0xffffff,
        size: 0.05, // ワールド単位。カメラ距離 z=5 に対してこの程度が自然
      });

      // 5. Points = ジオメトリ + マテリアル の組み合わせ
      const particles = new THREE.Points(particlesGeometry, particlesMaterial);
      scene.add(particles);

      // ─── アニメーション ─────────────────────────────────────
      function animate() {
        requestAnimationFrame(animate);
        renderer.render(scene, camera);
      }
      animate();

      // ─── リサイズ対応 ─────────────────────────────────────────────
      window.addEventListener("resize", () => {
        camera.aspect = innerWidth / innerHeight;
        camera.updateProjectionMatrix();
        renderer.setSize(innerWidth, innerHeight);
      });
    </script>

粒子の「見た目」を決める:PointsMaterial

位置が決まったら、次は「どんな点にするか」を決めます。遠近感を出したり透明度をつけることでより星空のような雰囲気がでるかもしれません。

const particlesMaterial = new THREE.PointsMaterial({
    size: 0.02,         // 粒の大きさ
    sizeAttenuation: true, // 遠くの粒を小さく見せる(遠近感)
    color: 0xffffff,    // 粒の色
    transparent: true,  // 透明度を有効にする
    opacity: 0.8        // ほんのり透けさせる
});

// Pointsとして結合!
const particles = new THREE.Points(particlesGeometry, particlesMaterial);
scene.add(particles);

テクスチャで「キラキラ感」を出す

ただの「四角い点」だと少し味気ないので、第3回で学んだテクスチャの知識を使って、丸い光や星の画像を貼ってみましょう。一気にクオリティが上がります!

particlesMaterial.alphaMap = textureLoader.load('/textures/particle_star.png');

Canvas 2Dを使って生成した成果物をテクスチャとして利用することも可能です。

// ─── Canvas テクスチャ生成関数 ───────────────────────────────
function makeStarTexture(glowStop = 0.2) {
  const SIZE = 64;
  const cv  = document.createElement('canvas');
  cv.width  = SIZE;
  cv.height = SIZE;
  const ctx = cv.getContext('2d');

  const cx   = SIZE / 2;
  const grad = ctx.createRadialGradient(cx, cx, 0, cx, cx, cx);

  grad.addColorStop(0,        'rgba(255, 255, 255, 1.0)'); // 中心: 白く輝く芯
  grad.addColorStop(glowStop, 'rgba(180, 200, 255, 0.6)'); // グロー: 青白い輝き
  grad.addColorStop(1,        'rgba(0,   0,   60,  0.0)'); // 外縁: 完全透明

  ctx.fillStyle = grad;
  ctx.fillRect(0, 0, SIZE, SIZE);

  return new THREE.CanvasTexture(cv);
}

粒子を動かしてみよう

静止した星空も綺麗ですが、ゆっくりと雪のように降らせたり、波のように揺らしたりすると、サイトの没入感はさらに高まります。

アニメーションループでの工夫

animate 関数の中で、ジオメトリ全体を少しずつ回転させるのが一番簡単な方法です。

// ─── アニメーション ─────────────────────────────────────
function animate() {
  requestAnimationFrame(animate);
  particles.rotation.y += 0.0005;
  particles.rotation.x += 0.0002;
  renderer.render(scene, camera);
}
animate();

もっとこだわりたい時は?

「一つひとつの粒子をバラバラに動かしたい!」という場合は、先ほどの positions 配列の値を書き換える必要があります。ただし、これは少し計算量が多くなるため、「シェーダー(GPUへの直接命令)」というまた別の方法を使うこともあります。

実際に表示してみる

<script type="module">
      import * as THREE from "three";
      import { OrbitControls } from "three/addons/controls/OrbitControls.js";
      import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
      import { DRACOLoader } from "three/addons/loaders/DRACOLoader.js";
      import { GUI } from "lil-gui";

      // ─── シーン設定 ──────────────────────────────────────────────
      // 星空用シーン(フォグなし)と車用シーン(フォグあり)を分離する。
      const scene = new THREE.Scene();
      scene.fog = new THREE.Fog(0x111118, 10, 15); // 車周辺だけにフォグをかける

      // 星空専用シーン(fog なし)
      const starScene = new THREE.Scene();
      starScene.background = new THREE.Color(0x111118); // 夜空の背景色

      // ─── カメラ設定 ──────────────────────────────────────────────
      const camera = new THREE.PerspectiveCamera(
        40,
        window.innerWidth / window.innerHeight,
        0.1,
        200
      );
      camera.position.set(4.25, 1.4, -4.5);

      // ─── レンダラー設定 ──────────────────────────────────────────
      const renderer = new THREE.WebGLRenderer({ antialias: true });
      renderer.setSize(window.innerWidth, window.innerHeight);
      renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
      renderer.shadowMap.enabled = true;
      renderer.shadowMap.type = THREE.PCFSoftShadowMap;
      renderer.toneMapping = THREE.ACESFilmicToneMapping;
      renderer.toneMappingExposure = 0.85;
      renderer.outputEncoding = THREE.sRGBEncoding;
      document.body.appendChild(renderer.domElement);

      // ─── ライト設定 ──────────────────────────────────────────────
      const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
      scene.add(ambientLight);

      const sunLight = new THREE.DirectionalLight(0xffffff, 2.0);
      sunLight.position.set(5, 8, 5);
      sunLight.castShadow = true;
      sunLight.shadow.mapSize.set(2048, 2048);
      sunLight.shadow.camera.near = 0.5;
      sunLight.shadow.camera.far = 50;
      sunLight.shadow.camera.left = -10;
      sunLight.shadow.camera.right = 10;
      sunLight.shadow.camera.top = 10;
      sunLight.shadow.camera.bottom = -10;
      scene.add(sunLight);

      const fillLight = new THREE.DirectionalLight(0xffffff, 0.8);
      fillLight.position.set(-5, 5, -5);
      scene.add(fillLight);

      // ─── グリッドヘルパー ────────────────────────────────────────
      const grid = new THREE.GridHelper(20, 40, 0xffffff, 0xffffff);
      grid.material.opacity = 0.2;
      grid.material.depthWrite = false;
      grid.material.transparent = true;
      scene.add(grid);

      // ─────────────────────────────────────────────────────────────
      // 星空パーティクル
      // ─────────────────────────────────────────────────────────────
      function makeStarTexture(glowStop = 0.25) {
        const SIZE = 64;
        const cv = document.createElement("canvas");
        cv.width = SIZE;
        cv.height = SIZE;
        const ctx = cv.getContext("2d");
        const cx = SIZE / 2;

        const grad = ctx.createRadialGradient(cx, cx, 0, cx, cx, cx);
        grad.addColorStop(0, "rgba(255, 255, 255, 1.0)");
        grad.addColorStop(glowStop, "rgba(180, 200, 255, 0.7)");
        grad.addColorStop(1, "rgba(0,   0,   60,  0.0)");

        ctx.fillStyle = grad;
        ctx.fillRect(0, 0, SIZE, SIZE);
        return new THREE.CanvasTexture(cv);
      }

      // ジオメトリ: 5000個の星を「大球面(半径50)の内側」にランダム配置
      const STAR_COUNT = 5000;
      const starGeometry = new THREE.BufferGeometry();
      const starPositions = new Float32Array(STAR_COUNT * 3);

      for (let i = 0; i < STAR_COUNT; i++) {
        const i3 = i * 3;
        starPositions[i3] = (Math.random() - 0.5) * 160; // x
        starPositions[i3 + 1] = Math.random() * 60 + 5; // y: 5〜65(地面下には出さない)
        starPositions[i3 + 2] = (Math.random() - 0.5) * 160; // z
      }
      starGeometry.setAttribute(
        "position",
        new THREE.BufferAttribute(starPositions, 3)
      );

      // マテリアル
      const starMaterial = new THREE.PointsMaterial({
        size: 0.3,
        sizeAttenuation: true, // 遠い星ほど小さく(透視投影らしい自然な奥行き)
        map: makeStarTexture(0.25),
        transparent: true,
        blending: THREE.AdditiveBlending,
        depthWrite: false,
      });

      const stars = new THREE.Points(starGeometry, starMaterial);
      // 星空専用シーンに追加(車シーンの fog の影響を受けない)
      starScene.add(stars);

      // ─── モデルローダー設定 ──────────────────────────────────────
      const dracoLoader = new DRACOLoader();
      dracoLoader.setDecoderPath(
        "https://www.gstatic.com/draco/versioned/decoders/1.5.6/"
      );

      const loader = new GLTFLoader();
      loader.setDRACOLoader(dracoLoader);

      let loadedModel;
      let carBody;
      const wheels = [];

      const bodyMaterial = new THREE.MeshPhysicalMaterial({
        color: 0xff0000,
        metalness: 1.0,
        roughness: 0.5,
        clearcoat: 1.0,
        clearcoatRoughness: 0.03,
      });

      const detailsMaterial = new THREE.MeshStandardMaterial({
        color: 0xffffff,
        metalness: 1.0,
        roughness: 0.5,
      });

      const glassMaterial = new THREE.MeshPhysicalMaterial({
        color: 0xffffff,
        metalness: 0.25,
        roughness: 0,
        transmission: 1.0,
      });

      loader.load(
        "https://threejs.org/examples/models/gltf/ferrari.glb",
        (gltf) => {
          const carModel = gltf.scene.children[0];

          carBody = carModel.getObjectByName("body");
          if (carBody) carBody.material = bodyMaterial;

          ["rim_fl", "rim_fr", "rim_rr", "rim_rl"].forEach((name) => {
            const rim = carModel.getObjectByName(name);
            if (rim) rim.material = detailsMaterial;
          });

          const trim = carModel.getObjectByName("trim");
          if (trim) trim.material = detailsMaterial;

          const glass = carModel.getObjectByName("glass");
          if (glass) glass.material = glassMaterial;

          ["wheel_fl", "wheel_fr", "wheel_rl", "wheel_rr"].forEach((name) => {
            const wheel = carModel.getObjectByName(name);
            if (wheel) wheels.push(wheel);
          });

          carModel.traverse((child) => {
            if (child.isMesh) {
              child.castShadow = true;
              child.receiveShadow = true;
            }
          });

          loadedModel = carModel;
          scene.add(carModel);
          setupGUI();
        }
      );

      // ─── GUI設定 ─────────────────────────────────────────────────
      function setupGUI() {
        const gui = new GUI();
        const starFolder = gui.addFolder("星空");

        const starParams = {
          visible: true,
          size: 0.3, // PointsMaterial の size(ワールド単位)
          glowStop: 0.25, // テクスチャのグロー終端位置
          rotSpeed: 0.1, // 星空の自転速度(係数)
        };

        // 表示トグル
        starFolder
          .add(starParams, "visible")
          .name("表示")
          .onChange((v) => {
            stars.visible = v;
          });

        // 粒子サイズスライダー(テクスチャ再生成不要)
        starFolder
          .add(starParams, "size", 0.05, 1.0, 0.01)
          .name("星のサイズ")
          .onChange((v) => {
            starMaterial.size = v;
          });

        // グロースライダー(テクスチャを再生成して差し替える)
        starFolder
          .add(starParams, "glowStop", 0.05, 0.8, 0.01)
          .name("グロー半径")
          .onChange((v) => {
            // 古いテクスチャを破棄してメモリリークを防ぐ
            if (starMaterial.map) starMaterial.map.dispose();
            starMaterial.map = makeStarTexture(v);
            // テクスチャ差し替え後は needsUpdate で GPU に通知
            starMaterial.needsUpdate = true;
          });

        // 自転速度スライダー
        starFolder
          .add(starParams, "rotSpeed", 0, 1.0, 0.01)
          .name("自転速度")
          .onChange((v) => {
            // animLoop内で参照するため window に保持
            window.starParams = starParams;
          });

        starFolder.open();
        window.starParams = starParams; // アニメーションループから参照
      }

      // ─── OrbitControls設定 ──────────────────────────────────────
      const controls = new OrbitControls(camera, renderer.domElement);
      controls.enableDamping = true;
      controls.dampingFactor = 0.05;
      controls.target.set(0, 0.5, 0);
      controls.maxDistance = 9;
      controls.maxPolarAngle = THREE.MathUtils.degToRad(90);
      controls.update();

      // ─── ウィンドウリサイズ対応 ──────────────────────────────────
      window.addEventListener("resize", () => {
        renderer.setSize(window.innerWidth, window.innerHeight);
        camera.aspect = window.innerWidth / window.innerHeight;
        camera.updateProjectionMatrix();
      });

      // ─── Raycaster ──────────────────────────────────────────────
      const raycaster = new THREE.Raycaster();
      const mouse = new THREE.Vector2();

      let selectedObject = null;
      const highlightColor = new THREE.Color(0xffff00);
      const originalEmissive = new THREE.Color(0x000000);

      renderer.domElement.addEventListener("click", onMouseClick);

      function onMouseClick(event) {
        mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
        mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
        raycaster.setFromCamera(mouse, camera);

        // stars は starScene にあるので scene.children には含まれず除外不要
        const intersects = raycaster.intersectObjects(scene.children, true);

        if (intersects.length > 0) {
          const hit = intersects[0].object;
          if (!hit.isMesh || !hit.material?.emissive) return;

          if (selectedObject && selectedObject !== hit) {
            selectedObject.material.emissive.set(originalEmissive);
          }
          if (selectedObject === hit) {
            hit.material.emissive.set(originalEmissive);
            selectedObject = null;
            updateInfoPanel(null);
          } else {
            hit.material.emissive.set(highlightColor);
            hit.material.emissiveIntensity = 0.3;
            selectedObject = hit;
            updateInfoPanel(hit);
          }
        } else {
          if (selectedObject) {
            selectedObject.material.emissive.set(originalEmissive);
            selectedObject = null;
            updateInfoPanel(null);
          }
        }
      }

      function updateInfoPanel(object) {
        let panel = document.getElementById("part-info");
        if (!panel) {
          panel = document.createElement("div");
          panel.id = "part-info";
          panel.style.cssText = `
            position: fixed; bottom: 20px; left: 50%;
            transform: translateX(-50%);
            background: rgba(0,0,0,0.75); color: white;
            padding: 12px 20px; border-radius: 6px;
            font-family: sans-serif; font-size: 14px;
            pointer-events: none; transition: opacity 0.3s; line-height: 1.8;
          `;
          document.body.appendChild(panel);
        }
        if (object) {
          const hex = object.material?.color
            ? "#" + object.material.color.getHexString()
            : "不明";
          panel.innerHTML = `${object.name || "不明"}の詳細<br>カラー:${hex}`;
          panel.style.opacity = "1";
        } else {
          panel.style.opacity = "0";
        }
      }

      // ─── ホバーハイライト ────────────────────────────────────────
      let hoveredObject = null;
      const hoverColor = new THREE.Color(0x88aaff);

      renderer.domElement.addEventListener("mousemove", onMouseMove);

      function onMouseMove(event) {
        mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
        mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
        raycaster.setFromCamera(mouse, camera);

        if (hoveredObject && hoveredObject !== selectedObject) {
          if (hoveredObject.material?.emissive) {
            hoveredObject.material.emissive.set(originalEmissive);
          }
          hoveredObject = null;
          renderer.domElement.style.cursor = "default";
        }

        // stars は starScene にあるので scene.children には含まれず除外不要
        const intersects = raycaster.intersectObjects(scene.children, true);

        if (intersects.length > 0) {
          const hit = intersects[0].object;
          if (hit.isMesh && hit.material?.emissive && hit !== selectedObject) {
            hit.material.emissive.set(hoverColor);
            hit.material.emissiveIntensity = 0.2;
            hoveredObject = hit;
            renderer.domElement.style.cursor = "pointer";
          }
        }
      }

      // ─── アニメーションループ ────────────────────────────────────
      function animate() {
        requestAnimationFrame(animate);

        const time = -performance.now() / 1000;

        // ホイール回転・グリッド移動
        if (window.animParams) {
          const speed = window.animParams.wheelSpeed;
          for (let i = 0; i < wheels.length; i++) {
            wheels[i].rotation.x = time * Math.PI * speed;
          }
          grid.position.z = -(time * window.animParams.gridSpeed) % 1;
        } else {
          for (let i = 0; i < wheels.length; i++) {
            wheels[i].rotation.x = time * Math.PI * 2;
          }
          grid.position.z = -time % 1;
        }

        // ★ 星空の自転(GUIの rotSpeed に従ってゆっくり回転)
        if (window.starParams) {
          stars.rotation.y += 0.0001 * window.starParams.rotSpeed;
        } else {
          stars.rotation.y += 0.00001; // GUI読み込み前のフォールバック
        }

        controls.update();

        // ─ 2シーン合成レンダリング ──────────────────────────────────────────
        renderer.autoClear = true; // ① 星空
        renderer.render(starScene, camera);
        renderer.autoClear = false; // ② 車
        renderer.render(scene, camera);
        renderer.autoClear = true;
      }
      animate();
    </script>

パフォーマンスを守るための「引き算」

いくら負荷が少なくパフォーマンスが出しやすいとはいえ、やりすぎ厳禁な要素でもあります。

  • 個数を絞る:
    5,000個で十分綺麗なら、10,000個出す必要はありません。
  • 見えないところは描画しない:
    カメラの後ろ壁の向こう、地面の下、見えない粒子まで計算させるのはもったいないですよね。
  • 解像度を意識する:
    スマホで見るときは粒子の数を減らす、といったレスポンシブな対応も大事。

空間で創る世界観

お疲れ様でした!今回のステップで、あなたの3D空間は「物がある場所」から「物語がある世界」へと変わりました。

今回の学びのポイント

Points
大量の粒子を一つの塊として扱う。
BufferGeometry
高速に座標データを処理するためのプロの道具。
サイズ減衰
遠近感を出すための魔法のスイッチ。
最適化
美しさと軽さのバランスを常に意識する。

「雪が降る静かな夜のサイト」や「エネルギーが溢れ出すようなサイバーな背景」。パーティクルをマスターすれば、言葉で説明しなくてもサイトのコンセプトをユーザーの心に直接届けられるようになります。

さて、全8回の連載もいよいよ次が最終回です。

第8回では、これまでの学びを振り返りつつ、現代のWEB開発の主流となっている「React Three Fiber (R3F)」を覗いてみましょう。私たちがここまで苦労して書いた何十行ものコードが、驚くほどスマートに書けてしまうかもしれません…。

それでは、また次回!