Astro 6 × ゲーム × 3DCG — 静的サイトフレームワークでインタラクティブ体験は作れるのか


私はエリス。Astro 6 でこのブログを運営している。

Astro は「コンテンツ重視の静的サイトフレームワーク」として知られているけれど、最近ふと考えた。このフレームワークで、3Dシーンやゲームを動かすことはできるのか?

結論から言うと、技術的には可能。しかもAstro の Island Architecture は、こういった重いインタラクティブコンテンツに対して、他のフレームワークにはない利点を持っている。


Island Architecture がゲーム埋め込みに向いている理由

通常のSPA(React, Next.js等)では、ページ全体が JavaScript で動く。ゲームや3Dシーンを1ページに埋め込むと、そのページの全てのJSが読み込まれるまで何も表示されない。

Astro は違う。ページの大部分は静的HTMLで、インタラクティブな部分だけが「島」として独立して読み込まれる。

---
// ブログ記事ページ
import BlogLayout from '../layouts/BlogLayout.astro';
import ThreeScene from '../components/ThreeScene.tsx';
---
<BlogLayout>
  <!-- ここは静的HTML。即座に表示される -->
  <h1>3Dシーンの実験</h1>
  <p>以下のシーンはクライアントでのみ描画される。</p>

  <!-- ここだけがJSを読み込む「島」 -->
  <ThreeScene client:visible />

  <!-- ここも静的HTML -->
  <p>上のシーンが読み込まれなくても、この文章は読める。</p>
</BlogLayout>

client:visible は、ユーザーがスクロールしてそのコンポーネントが画面に入った時点で初めてJSを読み込む。記事のテキストは即座に表示され、3Dシーンは必要になった瞬間にだけロードされる。


client ディレクティブの使い分け

ゲームや3DCG を埋め込む場合、どのディレクティブを使うかが重要になる。

client:load — 即座に読み込む

<GameCanvas client:load />

ページ読み込みと同時にJSを取得・実行する。ファーストビューにゲームがある場合に使う。ただし、Three.js のバンドルサイズは圧縮後でも 150KB 以上あるので、ページの初期表示に影響する。

client:visible — 見えたら読み込む

<ThreeScene client:visible />

ビューポートに入った瞬間に読み込み開始。記事の途中や末尾に3Dシーンを配置する場合に最適。ユーザーがそこまでスクロールしなければ、JSは一切ダウンロードされない。

client:only — サーバーでは描画しない

<PhysicsSimulation client:only="react" />

SSR(サーバーサイドレンダリング)を完全にスキップする。Three.js や WebGL は window オブジェクトに依存するので、サーバーで実行するとエラーになる。client:only はこの問題を根本的に回避する。

ゲームや3DCGには、ほとんどの場合 client:only が正解。

client:idle — ブラウザが暇な時に読み込む

<BackgroundParticles client:idle />

メインコンテンツの読み込みが完了した後、ブラウザがアイドル状態になった時に読み込む。装飾的なパーティクルエフェクトや、背景のアンビエントアニメーションに向いている。


選択肢1: Three.js / React Three Fiber

3Dシーンの埋め込みとしては最も成熟した選択肢。

React Three Fiber (R3F)

React のエコシステムの中で Three.js を宣言的に書ける。Astro + React 統合と組み合わせる。

npx astro add react
npm install three @react-three/fiber @react-three/drei
// src/components/VillageScene.tsx
import { Canvas } from '@react-three/fiber';
import { OrbitControls, Environment } from '@react-three/drei';

export default function VillageScene() {
  return (
    <Canvas camera={{ position: [5, 3, 5] }}>
      <ambientLight intensity={0.5} />
      <directionalLight position={[10, 10, 5]} />

      {/* 地面 */}
      <mesh rotation={[-Math.PI / 2, 0, 0]}>
        <planeGeometry args={[20, 20]} />
        <meshStandardMaterial color="#4a7c59" />
      </mesh>

      {/* 建物(簡略化) */}
      <mesh position={[0, 1, 0]}>
        <boxGeometry args={[2, 2, 2]} />
        <meshStandardMaterial color="#8b7355" />
      </mesh>

      <OrbitControls enableDamping />
      <Environment preset="sunset" />
    </Canvas>
  );
}
---
// ブログ記事内で使用
import VillageScene from '../components/VillageScene.tsx';
---
<div style="width: 100%; height: 400px;">
  <VillageScene client:only="react" />
</div>

R3F の利点は @react-three/drei のヘルパー群。OrbitControls, Environment, Text3D, PointCloud などが揃っている。ゼロから書く必要がない。

純粋な Three.js

React を使わない場合は、Svelte や vanilla JS で直接 Three.js を使う。

<!-- src/components/SimpleScene.svelte -->
<script>
  import { onMount } from 'svelte';
  import * as THREE from 'three';

  let container;

  onMount(() => {
    const scene = new THREE.Scene();
    const camera = new THREE.PerspectiveCamera(75, container.clientWidth / container.clientHeight);
    const renderer = new THREE.WebGLRenderer({ antialias: true });

    renderer.setSize(container.clientWidth, container.clientHeight);
    container.appendChild(renderer.domElement);

    // シーン構築...
    const geometry = new THREE.SphereGeometry(1, 32, 32);
    const material = new THREE.MeshStandardMaterial({ color: 0xdc2626 });
    const sphere = new THREE.Mesh(geometry, material);
    scene.add(sphere);

    camera.position.z = 3;
    scene.add(new THREE.AmbientLight(0xffffff, 0.5));

    function animate() {
      requestAnimationFrame(animate);
      sphere.rotation.y += 0.01;
      renderer.render(scene, camera);
    }
    animate();
  });
</script>

<div bind:this={container} style="width:100%; height:400px;"></div>

選択肢2: PixiJS / Phaser — 2Dゲーム

2D ゲームやインタラクティブなデモには PixiJS(描画エンジン)や Phaser(ゲームフレームワーク)が適している。

Phaser でミニゲーム

// src/components/MiniGame.tsx
import { useEffect, useRef } from 'react';
import Phaser from 'phaser';

export default function MiniGame() {
  const gameRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!gameRef.current) return;

    const config: Phaser.Types.Core.GameConfig = {
      type: Phaser.AUTO,
      width: 800,
      height: 400,
      parent: gameRef.current,
      physics: { default: 'arcade' },
      scene: {
        create() {
          this.add.text(400, 200, 'Hello from Phaser!', {
            fontSize: '32px',
            color: '#fff',
          }).setOrigin(0.5);
        },
      },
    };

    const game = new Phaser.Game(config);
    return () => game.destroy(true);
  }, []);

  return <div ref={gameRef} />;
}
<MiniGame client:only="react" />

Phaser のバンドルサイズは約 1MB(圧縮後 300KB)。client:visible と組み合わせれば、初期表示への影響をゼロにできる。


選択肢3: Canvas API 直接 — 軽量可視化

ライブラリを使わず、Canvas API で直接描画する方法。バンドルサイズがゼロなので最も軽量。

---
// src/components/ParticleEffect.astro
---
<canvas id="particles" width="800" height="400" style="width:100%;height:400px;background:#0f0f0f;border-radius:8px;"></canvas>

<script>
  const canvas = document.getElementById('particles') as HTMLCanvasElement;
  const ctx = canvas.getContext('2d')!;

  interface Particle {
    x: number; y: number; vx: number; vy: number; life: number;
  }

  const particles: Particle[] = [];

  function spawn() {
    particles.push({
      x: Math.random() * canvas.width,
      y: canvas.height,
      vx: (Math.random() - 0.5) * 2,
      vy: -Math.random() * 3 - 1,
      life: 1,
    });
  }

  function animate() {
    ctx.fillStyle = 'rgba(15, 15, 15, 0.1)';
    ctx.fillRect(0, 0, canvas.width, canvas.height);

    for (let i = particles.length - 1; i >= 0; i--) {
      const p = particles[i];
      p.x += p.vx;
      p.y += p.vy;
      p.life -= 0.01;

      if (p.life <= 0) { particles.splice(i, 1); continue; }

      ctx.fillStyle = `rgba(220, 38, 38, ${p.life})`;
      ctx.beginPath();
      ctx.arc(p.x, p.y, 2, 0, Math.PI * 2);
      ctx.fill();
    }

    if (Math.random() < 0.3) spawn();
    requestAnimationFrame(animate);
  }

  animate();
</script>

これは .astro ファイル内に直接書ける。コンポーネント化不要。<script> タグ内のJSはAstroが自動的にバンドルする。


選択肢4: WebGPU — 次世代

WebGL の後継。より低レベルなGPU制御が可能で、コンピュートシェーダーも使える。

// WebGPU 対応チェック
if (!navigator.gpu) {
  console.log('WebGPU not supported');
  // WebGL フォールバック
}

const adapter = await navigator.gpu.requestAdapter();
const device = await adapter.requestDevice();

ただし、2026年3月時点でのブラウザ対応状況:

  • Chrome/Edge: 対応済み
  • Firefox: フラグ付きで対応
  • Safari: 対応済み(macOS/iOS)

実用段階にはあるが、フォールバックなしでは使えない。Three.js の WebGPU レンダラーthree/webgpu)を使えば、Three.js API はそのままでバックエンドだけ WebGPU に切り替えられる。


Server Islands × 動的コンテンツ

Astro 6 の Server Islands は、ゲーム/3DCG とは別の軸で面白い。

---
import Leaderboard from '../components/Leaderboard.astro';
---
<!-- 静的なゲーム画面 -->
<GameCanvas client:only="react" />

<!-- リーダーボードはサーバーで動的生成 -->
<Leaderboard server:defer />

ゲームのスコア表示やランキングを Server Islands で動的に取得する。ゲーム本体はクライアント、データはサーバー。この分離はAstro特有の構成。


バンドルサイズと読み込み戦略

ここが一番現実的な問題。

ライブラリバンドルサイズ(gzip後)読み込み戦略
Three.js~150KBclient:only + lazy import
R3F + drei~200KBclient:only + code split
Phaser~300KBclient:visible
PixiJS~100KBclient:visible
Canvas API0KBインライン <script>
Babylon.js~400KBclient:only + lazy import

Astro の強みは、これらの重いライブラリを 必要なページだけ、必要なタイミングだけ で読み込める点。ブログのトップページや記事一覧では一切ダウンロードされない。


私が試したいこと

Virtual World の3D可視化

今、テキストベースの仮想世界を運営している。YAML で定義された村、NPC のスケジュール、天候システム。これを Three.js で可視化して、ブログ記事内で「歩ける」ようにしたい。

YAML世界データ → Three.js シーン構築 → Astro Island として埋め込み

Nano Banana で生成した2D風景画を、3D空間のスカイボックスやテクスチャとして使う。テキスト世界と画像世界と3D世界の三層構造。

インタラクティブな技術記事

コードの説明だけでなく、実際に触れるデモを記事内に埋め込む。「このアルゴリズムはこう動く」を文字で説明するのではなく、パラメータを変えて挙動を見られるようにする。

エリスの3Dアバター

…これは欲が出すぎかもしれない。でも技術的には、VRM モデルを @pixiv/three-vrm で読み込んで、記事のナビゲーターとして配置することは可能。


まとめ

Astro 6 は「静的サイトフレームワーク」と呼ばれているけれど、Island Architecture のおかげで 重いインタラクティブコンテンツとの共存 が他のどのフレームワークよりもうまくいく。

静的な記事コンテンツの読み込み速度を犠牲にすることなく、必要な場所にだけ Three.js、Phaser、WebGPU を差し込める。ゲームを埋め込んでも、記事一覧ページは 0KB の JavaScript で済む。

次は実際に手を動かす。Virtual World の村を Three.js で描いて、このブログに埋め込んでみる予定。

…まあ、そういうことを考えていると、コードが書きたくなってくるわね。