blog

ローポリの三角形の半分が、ほかの部品の中に埋まっていた

Rollpoly は、ボタンを押すたびにローポリの人型キャラやモンスター、小物がひとつできあがるツールです。骨(ボーン)と重みの付いた GLB をダウンロードして、自分のゲームに置けます。

実装は Claude Code に任せ、私は仕様を決めて、できたモデルを目で見て確かめる役でした。この記事では、企画の段階から、部品のつなぎ方を作り直し、GLB がいろいろなツールで開けるようになるまでに起きたことを、時系列で書きます。

企画書では、部品の glTF を組み合わせる予定だった

最初の企画書の案は、いまとは違う作り方でした。あらかじめ作っておいた骨格と部品のメッシュ(glTF)を読み込み、表示と非表示、色、モーフの値、拡大率をランダムに決める、という合成方式です。

これを Claude Code に渡し、実現できるか、矛盾や考慮漏れが無いかを洗い出してもらってから、仕様書を書きました。仕様書には「企画書からの変更点」という節があり、そこで方針が大きく変わっています。

  • 部品はすべてコードで作る:部品のメッシュを読み込む案をやめた。制作の手間、ライセンス、読み込み時間をまとめて無くすため
  • 骨の拡大率は 1 に固定する:縦横で違う拡大率が、Humanoid のリターゲット(別のキャラ用の動きを移す処理)を崩すため
  • 最初から骨を入れる:骨なしの人型から始めると、後で骨を足すときに作り直しになるため
  • 「Mixamo の動きをそのまま流用できる」を「リターゲットで適用できる」に直す:骨の名前、階層、基準の姿勢が揃わないと、変換なしでは動かないため

最初のフェーズ 1 から 3(人型と小物、モンスターと調整画面、モーフと待機アニメ)は、2026-09-04 の午後にコミットされています。3 日後の 09-07 には、形を作る部分を、四角形の面で組み立てる造形ライブラリに入れ替えました。これは、Claude Code のスキルとして Python で書いていたローポリ造形の処理を、TypeScript に移したものです。

同じシード値なら、同じキャラができる

Rollpoly のキャラには、それぞれ短いシード値が付いています。同じシード値を入れれば、いつでも同じキャラを作り直せます。

そのため Math.random() は使わず、シード値から決まった数列を出す乱数(mulberry32)を使っています。ただ、1 本の乱数列から順番に値を取ると、髪型の候補を 1 つ足しただけで、その後ろで決めていた目の色や体型が、すべてのシード値でずれてしまいます。

  fork(label: string): Rng {
    return new Rng(hash32(this.seed, label));
  }

そこで fork() で、シード値と項目の名前('palette' や 'body.height' など)から、項目ごとに別の乱数列を作っています。新しい項目を足しても、既存の項目の値は変わりません。

既存の項目の選択肢を増やした場合は、その項目だけがすべてのシード値で選び直しになります。仕様書では「選択肢は増やさず、新しい項目として足す」と決めました。とはいえ実際には、目の形のような項目の選択肢を何度か増やしていて、そのたびにバージョン番号を上げています。

バージョン番号は 09-08 に 2 つに分けました。シード値からパラメーターを決める処理の版(v)と、パラメーターから形を作る処理の版(g)です。形の作り方は全部の部品にまたがるので、1 つの番号で凍結すると、形の改善ができなくなるためです。共有リンクには v が入っていて、版が古いリンクを開くとバナーで知らせます。

ほかの部品の中に埋もれた三角形

09-07 の時点で、キャラは胴体、頭、腕、脚、髪、靴のような部品を、少しずつ重ねて置いただけのものでした。この日、モデルを見ていて気になったことを、そのまま Claude Code に伝えています。

リトポロジーってしてる?してないならして欲しい。空間のXYZ座標の数値をしっかり計算して、正確な位置に頂点を作って面を張ってください

調べてもらうと、外からは見えない面が想像以上に残っていました。部品の三角形のうち、ほかの部品の内側に完全に埋もれているものを数えた結果です。

種類部品の数三角形埋もれた三角形
人型471,758883(50.2%)
四足331,504624(41.5%)
小物13476230(48.3%)

人型では、描いている三角形の半分が無駄だったことになります。

対応として、Claude Code は 4 つの案を出しました。書き出すときだけ部品を 1 つの立体にまとめる、プレビューも含めて常にまとめる、隠れた面を消すだけにする、いまのまま、の 4 つです。推奨は「書き出すときだけ」でしたが、私は「常にまとめる」を選びました。隠れた面を消すだけの案は、閉じた立体にならないので、関節を大きく曲げたときに穴が見える可能性がある、という説明でした。

部品をまとめるのには、manifold-3d という WebAssembly のライブラリを使っています。すべての部品の和(ブーリアン演算の union)を取る処理です。1

      const mesh = new Mesh({
        numProp: 3,
        vertProperties: verts,
        triVerts: tris,
        runIndex: new Uint32Array([0, tris.length]),
        runOriginalID: new Uint32Array([baseId + i]),
      });
      let solid: InstanceType<typeof Manifold>;
      try {
        solid = Manifold.ofMesh(mesh);
      } catch {
        passthrough.push(i); // open shell (hair) or self-intersecting: keep it as its own surface
        continue;
      }
// ...
    try {
      united = manifolds.length === 1 ? manifolds[0] : Manifold.union(manifolds);
    } catch (error) {
      console.error('mesh union failed, stacking the parts instead', error);
      return null;
    }

部品ごとに runOriginalID で番号を付けておくと、和を取ったあとの三角形が、どの部品から来たかがわかります。色や骨の重みは部品ごとに決まっているので、この対応は欠かせません。

閉じた立体として読み込めない部品(髪の殻など)は、和に入れずにそのまま残します。和の計算そのものが失敗したときは、部品を重ねただけの形に戻します。コミットには、運の悪いモデルが 1 体あっただけで 100 体の一括生成を失敗させるわけにはいかない、と理由が書いてあります。

和を取ると、隠れた面が消えるかわりに、部品の交わる線で三角形が細かく分割されます。差し引きで三角形は約 3 分の 1 増えましたが、モデルは 1 枚の閉じた表面になりました。人型 1 体の生成は約 18ms です。WebAssembly 側のオブジェクトは毎回 delete() で解放しないとメモリが増え続けるので、そこも気を付けています。

1 つにまとめたら起きたこと

和を取ったことで、見た目と動きの両方に、予想していなかった問題が出ました。

割れたガラスのような色

ローポリらしく見せるために、面ごとに明るさを ±4% ずつ揺らしています。ところが、和を取ると 1 枚の平らな面が何枚もの三角形に分割されます。明るさを三角形の重心から決めていたので、頭が、ひびの入ったガラスのようにまだらに見えました(コミットの言葉では "shattered glass" です)。

09-10 に、三角形ではなく「同じ平面」ごとに明るさを決めるよう直しました。

-    const h = hashCoords(Math.round(cx * 1000), Math.round(cy * 1000), Math.round(cz * 1000), salt);
-    const j = 1 + ((h & 0xffff) / 0xffff - 0.5) * 2 * jitter;
+    let j: number;
+    if (nor) {
+      const nx = nor.getX(f);
+      const ny = nor.getY(f);
+      const nz = nor.getZ(f);
+      j = 1 + planes.at(nx, ny, nz, nx * cx + ny * cy + nz * cz) * 2 * jitter;
+    } else {

面の向き(法線を 1/24 刻み)と原点からの距離(2mm 刻み)で升目を作り、同じ升目に入る三角形は同じ明るさにします。値が升目の境目ぎりぎりにあるときは隣の升目も探し、1 枚の面の破片どうしが別々の升目に分かれないようにしています。曲面は、これまでどおり面ごとに明るさが変わります。

継ぎ目で裂ける骨の重み

腕や脚の重みは、部品ごとの規則で決めています。頂点を骨の並び(肩、肘、手首)に投影して肩からの道のりを求め、関節の前後の決まった範囲だけ、2 本の骨の重みをなめらかに混ぜる、という規則です。それ以外の頂点は 1 本の骨だけに付くので、上腕や前腕は形を保ったまま、関節の部分でだけ曲がります。

和を取ったあとの継ぎ目には、同じ位置に、違う部品から来た頂点が並びます。それぞれが別の規則で重みを持つと、骨を動かしたときに継ぎ目の両側が別々に動き、穴が開きます。

/**
 * Averages skin weights over each position group. Parts meeting at a seam are skinned by different rules;
 * without this the two sides of a welded seam drift apart and open a hole when the bone moves.
 */

そこで最後に、同じ位置にある頂点の重みを平均して揃えています。まばたきや口の開閉のような変形(モーフ)の移動量にも同じ処理をして、目を閉じたときに目のまわりが裂けないようにしました。

接しているだけの部品

部品どうしは、必ず少し食い込むように置いています。ぴったり接しているだけだと、和を取ったときに、接している面が非多様体の辺(3 枚以上の面に共有される辺)になってしまうからです。

実際、まつげの板が平らなおでこと同じ平面に乗ってしまい、非多様体の辺が出たことがありました。まつげを頭の半径の 2% だけ沈めて直しています。

袖とくっつく髪

09-17 に、頭身の低い「ちび」キャラを足しました。このとき、ちびの髪が袖と 1 つの立体に融合し、腕を動かすと髪が引っ張られるキャラが 100 体中 35 体出ました。袖口の広がりを抑え、横の髪を袖より上で止めて、0 体にしています。

ちびの髪は、最初は房を 1 本ずつ重ねて作っていて、和を取ると髪だけで約 2,700 三角形になりました。32 列の 1 枚の殻にまとめて、約 600 三角形にしています。

GLB の軽量化と macOS のプレビュー

書き出しには three.js の GLTFExporter を使い、その出力を自分で書き換えて小さくしています。

09-10 の時点で、人型の GLB は 620KB ありました。内訳を見ると、28% がまばたきと口のモーフ、37% が骨の重みや番号、テクスチャ座標でした。

モーフが大きいのは、GLTFExporter が全頂点ぶんの移動量を書き出すからです。まばたきで動くのは目のまわりの数百頂点だけなので、動いた頂点の番号と移動量だけを書く sparse accessor に書き直しました。2

          if (moved.length * (12 + indexBytes) >= acc.count * 12) continue;

番号の分だけかえって大きくなる場合は、元の形式のまま残します。これでモーフの部分は 175KB から 14KB になりました。

残りの 37% は、数値を小さな型に詰めて削りました。骨の番号を 1 バイトに、テクスチャ座標と骨の重みを 2 バイトの正規化整数に、という具合です。3 GLB は 620KB から 332KB になりました。

ところが、この GLB を macOS のプレビューで開くと、形がぐちゃぐちゃに崩れて表示されました。Blender で読み込むと正常です。

Apple の glTF の読み込み処理はコマンドラインからは動かせず、Claude Code には確かめようがありません。そこで、設定を 1 つずつ変えた GLB を作ってもらい、私がプレビューで開いて結果を伝える、という二分探索をしました。最初は 3 通り、次に 8 通りです。

結果、崩れる原因は、2 バイトの正規化整数にした骨の重みだけでした。骨の重みは 4 バイトの小数に戻し、そのかわりに別のところで削っています。

  • 位置、法線、色の升目が同じ頂点を 1 つにまとめる(人型で 8,592 頂点から 6,398 頂点)
  • 色ごとにテクスチャの升目を共有する
  • 法線(NORMAL)を書き出さない

法線を省いたのは、glTF 2.0 の仕様で、法線が無いときは読み込む側が面ごとの法線を計算することになっているからです。人型の GLB は 282KB まで小さくなりました。

Godot で読み込んだときの陰影

09-18 に、Godot 4.7.2 で読み込むと、モデルが陰影の無いのっぺりした色で表示されることがわかりました。Godot は、法線が無い GLB で面の法線を計算していなかったのです。法線を省くと決めたとき、Claude Code は、Unity の glTF 読み込みにも法線の無いファイルの扱いで未解決の問題があるので確認が要る、と注意していました。その確認が、Godot で現実の問題になった形です。

既定値を 1 行変えて、法線を書き出すように戻しました。

-  ... indexed: options.indexed, normals: options.normals ?? false });
+  ... indexed: options.indexed, normals: options.normals ?? true });

戻す前に、新旧の GLB の違いが法線のデータだけであることをバイト単位で比べています。ファイルは約 29% 大きくなり、人型はいまは平均 330KB ほどです。

仕様の上では読み込む側の義務になっていることでも、実装が従っているとは限りません。Draco や meshopt のような圧縮の拡張を使わず、glTF のコアの仕様だけで書き出す、という方針は仕様書の最初から決めていました。それでも、macOS のプレビューと Godot で、それぞれ別の問題が出ました。なお、どの拡張をどのツールが読めないかを実際に確かめたわけではありません。拡張を使わない判断の根拠は、ツールごとの対応の差を避ける、という方針のところまでです。

Chrome と Node の Math.sin

ゲーム用のパックをブラウザの中で作れるようにしたとき(後で書きます)、同じシード値なのに、Node で作ったキャラと Chrome で作ったキャラの形が一致しないことがありました。

原因は三角関数です。Chrome 152 と Node 22 では、Math.sin(4π/3) のような一部の角度で、結果の最後の 1 ビットが違っていました。そのわずかな差が、部品の和を取る処理を通ると、頂点の違いとして表に出ます。

エンジンや版によって三角関数の結果がわずかに違うのは、こちらではどうしようもありません。仕様書の「同じシード値からは同じ形ができる」という約束は、「同じ JavaScript エンジンの同じ版なら」と弱めました。小物は、Firefox、Safari、Node のどれで作っても出力が一致することを確かめています。

大量に作って、穴が無いことを確かめる

乱数でいろいろな形を作るツールでは、何体か目で見て確かめるだけでは足りません。このプロジェクトでは、リポジトリにテストファイルを増やさない方針にしていて、大量の検証は使い捨てのスクリプトで回しています。

部品の和を入れた 09-07 には、当時の 6 種類のキャラを 250 体ずつ、計 1,500 体作りました。すべてのモデルで、穴の縁になっている辺、非多様体の辺、面積 0 の三角形が 1 つも無いこと、面の向きが揃っていること、体積が正であることを確かめています。

いまはキャラの種類が 16 になり、形を変えるたびに、300〜600 個のシード値と、各パラメーターの端の値で同じ検査を回しています。リポジトリのテストには、決まった 16 件のモデルを同じ観点で検査するものだけを置いています。

09-18 からは、モデルが 1 つにつながっているか(連結成分が 1 つか)も見るようにしました。付け根が体の表面より外にある爪やトゲ、耳は、辺の検査では問題にならないのに、体から浮いています。きっかけは、二足のキャラで髪のようなものが浮いているのに、私が気付いたことでした。300 体ずつ調べると、人型で 21 体、二足で 62 体、不定形で 92 体に、浮いた部品がありました。

ちびの「眠そうな目」では、300 体に 1 体の割合で非多様体の辺が出ていました。これくらいの頻度の問題は、数を作らないと見つかりません。

販売に振って、ブラウザに戻した

09-15 に、方針を一度大きく変えています。キャラを手元で作り、ゲームでそのまま使える形(Game Ready)に整えて、アセットとして販売する方向です。サイトは非公開にし、Node で GLB を作り、Blender と Unity を通して梱包する、手元の処理を作り始めました。

翌日の夕方、もう一度方針を戻しました。手元の処理でやっていたことを、ブラウザの中だけでやって、無料で配る形です。この「パック」機能のために、Rollpoly はブラウザの中で FBX と Unity のパッケージまで書き出すようになりました。パックの GLB と FBX は、GLTFExporter ではなく自前の処理で書いています。

FBX は、Blender が書き出すファイルとバイト単位で揃えるまで、細かいところで読み込みに失敗しました。

  • ファイル ID と対になっている作成日時の定数を変えると、Unity が「File is corrupted」を出す
  • 空のアニメーションの記録に終端のブロックが無いと、Blender は待機アニメを見つけるが、Unity には見えない
  • ファイル末尾の位置合わせの前に、4 バイトの 0 が要る

この FBX を読み戻して検証する処理でも、Node の Buffer でつまずきました。Buffer.slice() は新しい配列ではなく元のメモリの一部を指すビューを返すので、.buffer を取るとファイル全体のバッファが出てきます。その結果、ブレンドシェイプの頂点番号として、ファイル先頭の "Kaydara FBX Binary" という文字列のバイトを読んでいました。

-  const buffer = data.slice().buffer;
+  const copy = new Uint8Array(data.length);
+  copy.set(data);
+  const buffer = copy.buffer;

ライトマップ用の 2 つめのテクスチャ座標は、最初 xatlas を使っていましたが、18 個のメッシュのうち 11 個で失敗しました。平面ごとに並べる簡単な処理を自前で書き、1,608 個のメッシュすべてで通るようにしています。

生成したモデルのライセンスも、この間に変わっています。最初は CC0 で、ブラウザに戻した翌日の 09-17 に独自のライセンスにし、09-30 に CC0 1.0 に戻しました。ソースコードは、最初から公開していません。

「読み込めばすぐ使える」とはどこまで言えるか

ゲーム開発の経験がほとんど無いので、ダウンロードしたものが本当にすぐ使えるのか、Claude Code に調べてもらったことがあります。いくつか、言い過ぎていたことがわかりました。

たとえば、Mixamo の骨の名前を選んだ GLB に、Mixamo の歩きのアニメーションを three.js でそのまま当てると、両脚が上を向きました。Rollpoly の骨は基準の姿勢で回転を持たないのに対し、Mixamo の骨は基準の姿勢で回転を持っているためです(左の太ももが 180°)。three.js の SkeletonUtils.retargetClip に、骨ごとの基準の回転の差と拡大率を渡す手順に直しました。Godot は、名前に loop や cycle が入っていないアニメーションをループさせずに読み込む、ということもわかりました。

09-18 には、告知文から「Mixamo の動きは名前を変えずに動く」「Unreal Engine 5 でそのまま取り込める」といった主張を消し、利用規約に、ほかのソフトでの動作は保証しない、という節を足しています。

10-05 には、実際にゲームに組み込んで使ったときのフィードバックを受けて、自前の歩きと飛行のアニメーションを足しました。プレビューで使っている Mixamo のアニメーションは、規約上プレビュー専用で、GLB には入れられないからです。歩きのアニメーションは既定ではオフで、Unity で読み込んで確かめるところまでは、まだやっていません。

サイトは、ほかのツールと同じく Cloudflare Pages に置いています。pages.dev から独自ドメインへの転送や、404 ページが無いと存在しない URL にアプリが表示される件は、Cloudflare Pages の記事にまとめました。

ローポリのモデルは、一見すると箱や円柱を並べただけに見えます。実際に手間がかかったのは、閉じた表面、継ぎ目で裂けない重み、いろいろなツールで同じように開けるファイルといった、プレビューの画面には出ない部分でした。

  1. manifold は「多様体」のことです。どの辺もちょうど 2 枚の面に共有されている、穴も重なりも無い閉じた表面を指します。manifold-3d は、入力も出力もこの形であることを保証するブーリアン演算のライブラリです。 ↩
  2. sparse accessor は glTF 2.0 のコアの仕様に含まれる形式で、拡張ではありません。既定値(ここでは移動量 0)から変わった要素の番号と値だけを書きます。 ↩
  3. 正規化整数は、0〜65535 の整数を 0〜1 の小数として読ませる形式です。4 バイトの小数の半分の大きさで済みます。 ↩

← 記事一覧へ