[JavaScript] Lain Clock カレンダー機能の WebGL 3D メッシュ化と描画順序(renderOrder/depthWrite)の最適化

[JavaScript] Lain Clock カレンダー機能の WebGL 3D メッシュ化と描画順序(renderOrder/depthWrite)の最適化

はじめに

前回の記事「[JavaScript] Lain Clock プリセット管理機能の実装 — WebGL設定の保存・復元・JSON Export/Import」では、WebGL空間の各種パラメータを localStorage や JSON ファイルとして保存・復元するプリセットシステムを構築しました。

当初、カレンダー機能は DOM 要素(HTML/CSS)を WebGL Canvas の上にオーバーレイ表示する構成で試作していました。しかし、ポストプロセス(画面全体のシェーダーエフェクト)との親和性や3D空間の一体感を高めるため、Three.js の 3D メッシュ(PlaneGeometry + CanvasTexture)として空間内に統合する設計へ大幅に変更・刷新しました。

今回は、2D Canvas を用いた動的テクスチャ生成ロジックと、3D空間上の透明オブジェクト同士で発生する**「深度バッファ(Z-Buffer)干渉問題」の解決アプローチ**について解説します。

[JavaScript] Lain Clock カレンダー機能の WebGL 統合 [JavaScript] Lain Clock カレンダー機能の WebGL 統合

1. DOM オーバーレイから WebGL 3D メッシュへの移行方針

DOM ベースのオーバーレイは実装が容易な反面、以下のような課題がありました。

  1. 3D 空間・エフェクトとの分離: ブルームやトーンマッピングなどの WebGL ポストプロセス効果が DOM 要素に乗らない。
  2. 前後関係の破綻: キャラクター(MMDモデル)や吹き出しスプライトなど、3D空間上のオブジェクトとの前後交差処理が描画層レベルで固定化されてしまう。

これらを解消するため、「2D Canvas 上でカレンダーを描画し、それを CanvasTexture として 3D の板ポリゴン(PlaneGeometry)に貼り付ける」 方式を採用しました。


2. 実装手順とコード解説

① 2D Canvas 描画と 3D メッシュ生成 (src/calendar.js)

Off-screen の Canvas 要素を用意し、日付グリッドを描画した上で Three.js のテクスチャとして登録します。

// ./src/calendar.js
import * as THREE from "three";

let calendarMesh = null;
let calendarCanvas = null;
let calendarTexture = null;
let ctx = null;

export function createCalendarMesh(scene) {
  if (calendarMesh) return calendarMesh;

  // 1. Off-screen Canvas の生成
  calendarCanvas = document.createElement("canvas");
  calendarCanvas.width = 512;
  calendarCanvas.height = 512;
  ctx = calendarCanvas.getContext("2d");

  // 2. テクスチャ化
  calendarTexture = new THREE.CanvasTexture(calendarCanvas);
  calendarTexture.colorSpace = THREE.SRGBColorSpace;

  // 3. 3D メッシュの構築
  const geometry = new THREE.PlaneGeometry(4.0, 4.0);
  const material = new THREE.MeshBasicMaterial({
    map: calendarTexture,
    transparent: true,
    depthWrite: false, // ★ 重要:透明部分の深度バッファ書き込みをOFFにする
    depthTest: true,
    side: THREE.DoubleSide,
  });

  calendarMesh = new THREE.Mesh(geometry, material);
  calendarMesh.position.set(0, 0, 0.05);

  // 描画順を明示的に指定(背景: 0 -> カレンダー: 5 -> 吹き出し: 100)
  calendarMesh.renderOrder = 5;

  scene.add(calendarMesh);
  return calendarMesh;
}

// Canvas へのカレンダー描画処理
export function renderCalendarCanvas(now = new Date(), config = {}) {
  if (!ctx) return;

  ctx.clearRect(0, 0, 512, 512);

  // 背景パネル(半透明)
  ctx.fillStyle = config.bgColor || "rgba(15, 15, 25, 0.65)";
  roundRect(ctx, 20, 20, 472, 472, 24);
  ctx.fill();

  // 年月ヘッダー描画
  const year = now.getFullYear();
  const month = now.getMonth() + 1;
  ctx.fillStyle = config.headerColor || "#ff79c6";
  ctx.font = "bold 32px sans-serif";
  ctx.textAlign = "center";
  ctx.fillText(`${year}年 ${month}月`, 256, 75);

  // ... (曜日・日付のグリッドループ描画処理) ...

  // テクスチャの更新フラグを立てる
  if (calendarTexture) calendarTexture.needsUpdate = true;
}

function roundRect(ctx, x, y, w, h, r) {
  ctx.beginPath();
  ctx.moveTo(x + r, y);
  ctx.lineTo(x + w - r, y);
  ctx.quadraticCurveTo(x + w, y, x + w, y + r);
  ctx.lineTo(x + w, y + h - r);
  ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
  ctx.lineTo(x + r, y + h);
  ctx.quadraticCurveTo(x, y + h, x, y + h - r);
  ctx.lineTo(x, y + r);
  ctx.quadraticCurveTo(x, y, x + r, y);
  ctx.closePath();
}

② ハマりポイント:透明メッシュの深度バッファ(depthWrite)干渉

3D 空間にアルファ透明を含む板ポリゴン(カレンダーやキャラクターの吹き出しスプライト)を配置した際、「吹き出しがカレンダーの後ろに隠れて消えてしまう」 という不具合が発生しました。

原因

デフォルトの設定では、板ポリゴンの透明な領域(文字の無いアルファ値 0 の部分)であっても Z バッファ(深度バッファ)に描画書き込みが行われてしまうため、GPU が「手前に遮蔽物がある」と誤判定して後ろの描画をマスクしてしまうのが原因です。

解決策

カレンダーおよび吹き出しスプライトのマテリアル設定で、以下の 3 点を制御します。

  1. depthWrite: false: 透明部分が Z バッファを汚染しないように無効化する。
  2. depthTest: false(最前面に表示したい吹き出し側): 3D空間上の奥行き判定をスキップし、常に手前に表示させる。
  3. renderOrder: Three.js の不透明/半透明ソート順を指定し、明示的に描画順序をコントロールする。
// ./src/mmd-talk.js (吹き出し生成箇所)

function createTextSprite(text) {
  // ... Canvas 描画処理 ...

  const texture = new THREE.CanvasTexture(canvas);

  const material = new THREE.SpriteMaterial({
    map: texture,
    transparent: true,
    depthTest: false,  // ★ カレンダー等の3Dオブジェクトに遮蔽されないよう深度テストを無視
    depthWrite: false, // ★ Zバッファへの書き込みを行わない
  });

  const sprite = new THREE.Sprite(material);
  sprite.renderOrder = 100; // ★ カレンダー(renderOrder: 5)より大きな値を指定して最前面へ

  return sprite;
}

この調整により、3D空間上のモデル、カレンダー、UI吹き出しが意図通りの重ね合わせ順で正しくアルファブレンディングされるようになりました。


③ 設定ストアとプリセット連携 (src/settings-store.js)

カレンダーの位置(X/Y/Z)、スケール、カラー、透明度などのパラメータは、既存の settings-store に組み込んで統合管理します。

// ./src/settings-store.js

export function getDefaultSettings() {
  return {
    version: 1,
    // ...
    calendar: {
      enabled: true,
      posX: 0.0,
      posY: 0.0,
      posZ: 0.05,
      scale: 1.0,
      headerColor: "#ff79c6",
      dateColor: "#ffffff",
      todayColor: "#ff5555",
      alpha: 0.85,
    },
  };
}

export function loadSettings() {
  try {
    const raw = localStorage.getItem(STORAGE_KEY);
    if (!raw) return getDefaultSettings();

    const saved = JSON.parse(raw);
    const base = getDefaultSettings();

    return {
      ...base,
      ...saved,
      // 旧バージョンのプリセット読込時も破綻しないようディープマージ
      calendar: {
        ...base.calendar,
        ...saved.calendar,
      },
    };
  } catch (e) {
    return getDefaultSettings();
  }
}

3. プリセット機能・JSON Export/Import との検証

カレンダーが 3D メッシュ化された後も、設定データ構造自体は settings-store の配下に閉じています。そのため、以前構築した JSON Export/Import 機能(preset-store.js)は一切コードを修正することなくそのまま完全動作します。

  • + Save Current: カレンダーの 3D 座標・スケール・色設定を含めてローカルストレージへ保存。
  • **Export All / Import JSON**: カレンダーの 3D パラメータを含む設定ファイル(JSON)として完全な保存・復元が可能。
  • 後方互換性: カレンダー項目を含まない古い JSON プリセットを読み込んだ場合でも、loadSettings() のディープマージにより安全に初期値が補完されます。

4. まとめ

カレンダーを DOM オーバーレイから WebGL 3D メッシュへ移行したことで、Lain Clock の画面全体の一体感が飛躍的に向上しました。

  • CanvasTexture による柔軟な動的 UI 描画
  • depthWrite: falserenderOrder による半透明 3D オブジェクトの重なり不具合の克服
  • 3D 空間パラメータの一体化したプリセット保存・復元

WebGL 空間内で複数の透明スプライトや UI メッシュを扱う際、Z バッファの非表示領域汚染は非常に引っかかりやすいポイントです。depthWriterenderOrder を適切にハンドリングすることで、クリーンな 3D ダッシュボード表現を実現できました。