[JavaScript] Lain Clock アップデート — ZIP文字化け修正・床パターンシステム・モデル復元機能
はじめに
昨日公開したMMD時計アプリ「Lain Clock」に、大幅な改善を入れたメモです。
1. ZIPファイル名のShift_JIS文字化け修正
問題
MMDモデルのZIPをドロップして読み込むと、ファイル名が □□□□□qロT のように文字化けする。IndexedDBの中身も壊れている。
原因
ZIPフォーマットは1989年に設計され、ファイル名のエンコーディング規定がなかった。日本のWindowsで作られたZIPはShift_JIS(CP932)でファイル名が格納されるが、JSZipはそれをUTF-8として解釈するため文字化けが発生する。
2006年にbit 11(Language Encoding Flag)が追加され、UTF-8であることを明示できるようになったが、MMD界隈のZIPは古いツールで作られたものが大半で、このフラグは立っていない。
解決策
JSZipにファイル名の解決を任せず、ZIPのCentral Directoryを自前でパースする方式を採用した。
function parseZipFilenames(buffer) {
const view = new DataView(buffer);
const sjis = new TextDecoder("shift_jis");
const utf8 = new TextDecoder("utf-8");
const names = [];
// End of Central Directory を後方検索
let eocd = -1;
for (let i = buffer.byteLength - 22; i >= Math.max(0, buffer.byteLength - 65557); i--) {
if (view.getUint32(i, true) === 0x06054b50) { eocd = i; break; }
}
if (eocd === -1) return names;
const count = view.getUint16(eocd + 10, true);
let offset = view.getUint32(eocd + 16, true);
for (let i = 0; i < count; i++) {
if (offset + 46 > buffer.byteLength) break;
if (view.getUint32(offset, true) !== 0x02014b50) break;
const flags = view.getUint16(offset + 8, true);
const nameLen = view.getUint16(offset + 28, true);
const extraLen = view.getUint16(offset + 30, true);
const commentLen = view.getUint16(offset + 32, true);
const nameBytes = new Uint8Array(buffer, offset + 46, nameLen);
const isUtf8 = (flags & 0x800) !== 0;
names.push(isUtf8 ? utf8.decode(nameBytes) : sjis.decode(nameBytes));
offset += 46 + nameLen + extraLen + commentLen;
}
return names;
}
ポイントは以下の通り。
- Central Directoryの各エントリからbit 11を確認
- フラグが立っていればUTF-8、なければShift_JISでデコード
- JSZipはデータ展開だけに使い、ファイル名の解決は自前で行う
- ブラウザの
TextDecoder('shift_jis')は標準サポートなので外部ライブラリ不要
この修正はLain Clockだけでなく、同じパイプラインを使っていたMMD to GLBツールにも適用した。
2. 床パターンシステム
設計
床のシェーダーをプラグイン構造に変更した。
src/floor-patterns/
index.js ← レジストリ
brick.js ← ブリック(既存)
paw.js ← 肉球 + チェッカー + グロー
hexagon.js ← 六角タイル
stars.js ← 流れる星
wave.js ← 波
diamond.js ← ひし形タイル
neon-grid.js ← ネオングリッド
circuit.js ← 基板パターン
solid.js ← 単色
各パターンは統一フォーマットで定義する。
export default {
name: "brick",
label: "Brick",
uniforms: {
uScale: { value: 10.0 },
uMortar: { value: 0.01 },
// ...
},
vertexShader: `...`,
fragmentShader: `...`,
}
mmd-floor.js はパターン定義から ShaderMaterial を自動生成し、uTime や uAlpha といった共通uniformを注入する。選択されていないパターンはGPUに一切触らないため、パターンをいくら増やしてもノーコスト。
透明度の自動注入
全パターンに透明度を対応させるため、各シェーダーを個別に書き換えるのではなく、マテリアル生成時にフラグメントシェーダーへ自動注入する方式を採った。
// 先頭に uniform 宣言を追加
frag = 'uniform float uAlpha;\n' + frag;
// main() の最後の } の直前に alpha 乗算を挿入
const lastBrace = frag.lastIndexOf('}');
frag = frag.slice(0, lastBrace) + ' gl_FragColor.a *= uAlpha;\n' + frag.slice(lastBrace);
この方法ならシェーダーの内部構造に一切依存しない。どんなパターンを追加しても透明度が自動的に使える。
パフォーマンス
全パターンがプロシージャルシェーダー(GPU内の計算のみ)で完結しており、テクスチャを使わない。PBRマテリアル(MeshStandardMaterial)より軽い。
3. モデル状態の保存・復元
変更前
起動時に必ずハードコードされたデフォルトモデル(lain_v1)が読み込まれ、前回の状態は失われていた。
変更後
settings.activeModelsに表示中のモデルキーを保存- 起動時にIndexedDBから復元
- デフォルトモデルもモデル一覧に表示し、他のモデルと同じ操作で切り替え可能
- モデル無し(時計のみ)の表示にも対応
デフォルトモデルの読み込みもZIPパイプラインに統一した。/public/clock/models/lain_v1.zip からfetchし、IndexedDBのモデルと全く同じ loadActorFromRecord で読み込む。コードパスが一本化され、Shift_JISデコードも自動的に適用される。
4. 設定のリアルタイム更新
変更前
設定パネルの値を変更しても、Saveボタンを押すまで反映されなかった。
変更後
input イベントリスナーを追加し、スライダーやカラーピッカーの操作が即座に画面に反映されるようにした。Saveボタンは localStorage への永続化のみの役割になった。
settingsBody.addEventListener("input", (ev) => {
const t = ev.target;
if (!t.dataset?.bind) return;
if (t.matches('[data-bind="floorAlpha"]')) {
setFloorAlpha(toNum(t.value, 1));
return;
}
collectCurrentSettings();
applySettingsToRuntime(currentSettings);
});
まとめ
一日で入れた変更の一覧。
- ZIPファイル名のShift_JISデコーダー(Lain Clock + MMD to GLB)
- 床パターンシステム(9種類のプロシージャルシェーダー)
- 床の透明度対応(uAlpha自動注入)
- モデル状態の保存・復元
- デフォルトモデルのZIPパイプライン統一
- デフォルトモデルのモデル一覧表示
- 設定パネルのリアルタイム更新
- 設定パネルにパターン選択ドロップダウン追加
- 設定パネルに床透明度スライダー追加
バグ修正から始まった一日が、結果的にアプリ全体のアーキテクチャ改善になった。ZIPの文字化けという35年前の設計判断に殴られたおかげで、コードが綺麗になったのは皮肉な話だ。