前回の記事(Pyxel 3 の 3D(pyxel.cube)で PS1・PS2 時代の技法を13個試して、重さを測った)の続きです。前回試さなかった技法を 8 個、同じ方法で検証しました。
結果は、予想と逆になったものが 2 つありました。
- 低い解像度で描いて拡大する方法は、ほとんど効きませんでした。 アバターがいる場面では、塗る面積を 1/9 にしても 1〜1.5 ms しか減りません。
- 見えない部屋を飛ばす方法は、よく効きました。 壁の向こうの部屋も、実は描かれていたためです。
検証環境(前回と同じ)
- Pyxel 3.0.0(PyPI 版)
- 実行環境 1:macOS ネイティブ(MacBook Pro M5 Max)
- 実行環境 2:Code & Magic(ブラウザ / Chrome、Pyodide)、同じ Mac
- 実行環境 3:Linux(画面なし)。原因の切り分けの小さな実験だけ。本文に「Linux で計測」と書きます
- 画面は 320×180、256 色、30 fps
この記事の内容は Pyxel 3.0.0 時点 のものです。今後のバージョンで変わる可能性があります。
調べものや検証は AI と一緒に行いました。記事の画面は、私がデモを動かして撮ったものです。
検証の方法
前回と同じく、1 シーンが 1 技法で、同じ場面を 2〜3 通りの方法で描いて比べます。何もしなければ約 1 分 30 秒で全部が流れ、最後に結果の一覧が出ます。
今回は、比較を切り替えるとカメラが同じ動きを最初からやり直すようにしました。前回は比較ごとにカメラの位置が少しずれていたので、より公平になっています。
デモは Code & Magic で公開しています。
- PYXEL 3 TECH BENCH 2(Code & Magic)
- 操作:←→ でシーン、Zで比較の切り替え、Xで結果の一覧、ENTER で最初から
結果の一覧
描画時間(ms)です。06 だけは、頂点の計算を含む「更新 + 描画」です。
| # | 技法 | 比べたもの | Mac | Code & Magic |
|---|---|---|---|---|
| 01 | 低解像度で描いて拡大 | 320×180 / 160×90 を 2 倍 / 107×60 を 3 倍 | 7.6 / 6.8 / 6.6 | 9.1 / 7.9 / 7.6 |
| 02 | 画面をテクスチャに(監視カメラ) | 砂嵐 / 1 台 / 4 台 | 3.4 / 8.8 / 17.5 | 5.4 / 9.9 / 21.6 |
| 03 | トゥーンと輪郭線 | 通常 / 2 階調 / 2 階調 + 輪郭線 | 4.2 / 4.6 / 12.9 | 5.0 / 5.1 / 16.0 |
| 04 | テクスチャの LOD | 64px 1 枚 / 距離で 4 段階 / さらに遠くは単色 | 1.3 / 1.6 / 1.6 | 3.2 / 3.1 / 3.2 |
| 05 | 懐中電灯 | 暗い部屋 / decal の光の輪 / 光の中の物も明るく | 1.3 / 1.9 / 1.9 | 3.2 / 3.6 / 3.8 |
| 06 | 頂点アニメ(旗) | 4 本 / 16 本 / 64 本 | 1.5 / 3.3 / 6.6 | 3.7 / 5.3 / 8.2 |
| 07 | 空の箱と遠くの山 | 単色 / 空の箱 / 空の箱 + 山 | 1.3 / 2.5 / 2.8 | 3.3 / 4.6 / 5.3 |
| 08 | 部屋単位のカリング | 全部屋 / 見える部屋だけ | 5.8 / 3.8 | 7.4 / 5.6 |
すべてのシーンが、両方の環境で 30 fps の枠(33.3 ms)に収まりました。
01. 低解像度で描いて拡大:アバターがいると、ほとんど効かない
PS2 の多くのゲームは、画面より小さい解像度で描いて引き伸ばしていました。pyxel.cube でも、小さな Image に描いて、blt() の scale で広げられます。
small = pyxel.Image(160, 90)
stage.draw(0, 0, 160, 90, small) # 小さな Image に 3D を描く
pyxel.blt(80, 45, small, 0, 0, 160, 90, None, 0, 2) # 中心を基準に 2 倍(画面いっぱい)
blt() の拡大は、コピーする範囲の中心が基準です。160×90 を (80, 45) に 2 倍で置くと、ちょうど画面全体になります。拡大そのものは約 0.4 ms でした(Linux で計測)。
| 塗る画素 | Mac | Code & Magic | |
|---|---|---|---|
| 320×180 | 57600 | 7.6 | 9.1 |
| 160×90 を 2 倍 | 14400 | 6.8 | 7.9 |
| 107×60 を 3 倍 | 6420 | 6.6 | 7.6 |
塗る画素を 1/9 にしても、減ったのは 1.0〜1.5 ms でした。そこで、何に時間がかかっているかを切り分けました(Linux で計測)。
| 描いたもの | 320×180 | 160×90 |
|---|---|---|
| 画面いっぱいの板 1 枚 | 0.84 ms | 0.19 ms |
| 画面いっぱいの板 8 枚(手前から) | 3.87 ms | 0.93 ms |
| VRM アバター 1 体(近く) | 5.62 ms | 5.00 ms |
| VRM アバター 1 体(遠く) | 4.48 ms | 4.57 ms |
画面を 1 回塗るのは約 0.8 ms で、解像度を下げればきちんと減ります。一方、アバターは画面に大きく映っても小さく映っても、ほぼ同じ時間がかかります。アバターの重さは、塗る面積ではなく、頂点の数と描画の呼び出しの数で決まります。
この場面は、アバター 3 体と、大きなテクスチャの壁でできています。時間の大半はアバターなので、解像度を下げても効きませんでした。低解像度が効くのは、大きな面が何枚も重なる場面です。
板 8 枚は、手前から描くと 3.87 ms、奥から描くと 6.06 ms でした(Linux で計測)。奥行きテストで捨てられる画素は安く済むので、手前の物から描くほうが速くなります。
02. 画面をテクスチャに:監視カメラのモニター
別のカメラから見た映像を Image に描き、その Image を 3D の面に貼ります。監視カメラのモニター、車のバックミラー、別の場所が見える窓などに使えます。
for k in range(cameras):
stage.camera.transform = Mat4.look_at(eye[k], target[k], Vec3.UP)
stage.draw(0, 0, 80, 60, feeds[k]) # 80×60 の Image に描く
feeds[k].text(3, 3, f"CAM{k + 1}", 7) # 描いた Image には 2D の文字も書ける
stage.camera.transform = main_camera
stage.draw(0, 0, 320, 180) # on_draw の中で prim(mat, screen, feeds[k])
| Mac | Code & Magic | |
|---|---|---|
| 砂嵐(作っておいた絵) | 3.4 | 5.4 |
| カメラ 1 台 | 8.8 | 9.9 |
| カメラ 4 台 | 17.5 | 21.6 |
カメラ 1 台につき、映っている場面をもう 1 回描くことになります。この場面ではアバター 2 体が映るので、1 台あたり 3.5〜5 ms でした。01 の結果どおり、80×60 と小さくしても、アバターの重さは減りません。
カメラ用の描画では、事務所の側を描かないようにしています(on_draw の中で、いまどちらを描いているかを見て分ける)。
03. トゥーンと輪郭線:2 階調は無料、輪郭線は重い
2 階調 pyxel.cube の陰影は、Shading という表(色ごとに 8 段階)で決まります。この表を書き換えて、暗い側を 1 色、明るい側を元の色にすると、アニメ塗りになります。
toon = Shading(pyxel.colors)
for c in range(256):
for lv in range(Shading.LEVEL_COUNT):
toon[c, lv] = base[c, 2] if lv < 4 else (c, c)
stage.shading = toon # 子ノードすべてに効く
Shading 表は、テクスチャ付きの面にも効きました(全部を黒にした表で確認)。
輪郭線(小物) 少し太らせた黒いモデルを、裏面だけ描きます。Primitive に CULL_FRONT(表面を描かない)があります。
hull = Primitive(Primitive.MODE_TRIANGLES, (pos + normals * 0.045).ravel().tolist(), idx, [], [],
cull=Primitive.CULL_FRONT)
node.shaded(False)
node.prim(mat, hull, BLACK)
輪郭線(アバター) VRM はパーツの数が多く、太らせたモデルを作りにくいので、別の方法にしました。アバターだけを、黒一色の表で、上下左右に 1 ピクセルずらして 4 回描きます。そのあと普通に描くと、まわりに黒い線が残ります。
astage.shading = black # 全部の色を黒にした表
for dx, dy in ((-1, 0), (1, 0), (0, -1), (0, 1)):
astage.draw(dx, dy, W, H) # 描く位置を 1 px ずらす
astage.shading = toon
astage.draw(0, 0, W, H)
| Mac | Code & Magic | |
|---|---|---|
| 通常 | 4.2 | 5.0 |
| 2 階調 | 4.6 | 5.1 |
| 2 階調 + 輪郭線 | 12.9 | 16.0 |
2 階調は、ほぼ時間が増えません。輪郭線は、アバターを 5 回描くので重くなります。小物の裏面の殻は、三角形が 2 倍になるだけで済みます。
04. テクスチャの LOD:速くはならない
pyxel.cube のテクスチャは「いちばん近い画素」を拾うだけで、遠くを自動でぼかす機能(ミップマップ)がありません。遠くの床では、細かい模様がチラチラと点滅します。
そこで、64px のテクスチャを 2×2 ずつ平均して、32px、16px、8px の版を作っておきます。床を 2 m ごとの列に分け、カメラからの距離で版を選びます。
lv = 0 if d < 7 else 1 if d < 14 else 2 if d < 28 else 3
node.prim(Mat4.IDENTITY, row, levels[lv])
| Mac | Code & Magic | |
|---|---|---|
| 64px 1 枚 | 1.3 | 3.2 |
| 距離で 4 段階 | 1.6 | 3.1 |
| さらに 40 m 先は単色 | 1.6 | 3.2 |
小さなテクスチャにしても、速くはなりませんでした。目的は、見た目(チラつきを抑える)だけです。動画で見ると違いがよく分かります。
05. 懐中電灯:decal で光の輪を投影する
暗い部屋の中で、懐中電灯の光だけが壁や床を照らす場面です。
暗い部屋 すべての色を暗い色に置き換える Shading 表を作り、部屋のノードに付けます。
光の輪 decal() を使います。decal(distance) のあとに描いた形は、すでに描いた壁や床に投影されます。床と壁の境目にまたがっても、そのまま折れ曲がって貼られます。
# 光が当たる点 hp の 1.2 m 手前に、hp の方を向けて置く(-Z の向きに投影される)
m = Mat4.look_at(hp - fwd * 1.2, hp, Vec3.UP)
node.shaded(False)
node.decal(4.0) # 4 m 先まで投影する
for size, alpha, col in ((1.0, 0.35, c1), (0.68, 0.55, c2), (0.36, 0.75, c3)):
node.dither(alpha)
node.elli(m, r * size * 2, r * size * 2, col) # 3 重の楕円をディザで重ねる
node.decal(None)
光の大きさは、光が当たる点までの距離に合わせて変えています。当たる点は、部屋が四角いので、計算で求めました。
光の中の物 3 つ目の比較では、光の円錐に入った物だけ、ノードの表を普通のものに戻しています。
| Mac | Code & Magic | |
|---|---|---|
| 暗い部屋 | 1.3 | 3.2 |
| decal の光の輪 | 1.9 | 3.6 |
| 光の中の物も明るく | 1.9 | 3.8 |
増えたのは 0.4〜0.6 ms でした。光の輪は色を塗り重ねるだけなので、下の模様はディザの隙間からしか見えません。
06. 頂点アニメ:旗をなびかせる
骨のない物(旗、布、水面)を、頂点を直接動かして揺らします。PS1 でよく使われた方法です。すべての旗を 1 つの Primitive にまとめ、numpy で全部の頂点を計算して、まとめて書き込みます。
wave = np.sin(s * 6.0 - t * 5.0 + phase) * 0.35 * s # s:旗竿からの距離(0〜1)
...
prim.positions[:] = P.reshape(-1).tolist()
prim.compute_normals() # 陰影のため、法線も作り直す
| 頂点 | Mac | Code & Magic | |
|---|---|---|---|
| 旗 4 本 | 960 | 1.5 | 3.7 |
| 旗 16 本 | 3840 | 3.3 | 5.3 |
| 旗 64 本 | 15360 | 6.6 | 8.2 |
1 万 5 千頂点を毎フレーム動かして、Mac で 6.6 ms でした。旗 1 本(240 頂点)あたり約 0.1 ms です。
07. 空の箱と遠くの山
空の箱 カメラと一緒に動く箱に空の絵を貼り、奥行きを書かずに最初に描きます。そのあとに描く物はすべて、空より手前になります。
node.shaded(False)
node.depth_write(False)
node.prim(Mat4.from_translation(eye), sky_box, sky_img)
node.depth_write(True)
遠くの山 120 m 先に、山の絵を貼った板を輪のように 24 枚並べます。空と違って世界に固定しているので、カメラが動くと少しずれて見えます(視差)。
| 三角形 | Mac | Code & Magic | |
|---|---|---|---|
| 単色 | 0 | 1.3 | 3.3 |
| 空の箱 | 12 | 2.5 | 4.6 |
| 空の箱 + 山 | 60 | 2.8 | 5.3 |
空の箱は画面のほとんどを塗るので、三角形は 12 枚でも約 1.2 ms かかりました。01 で測った「画面 1 枚分の塗り」とほぼ同じです。
08. 部屋単位のカリング:壁の向こうも描かれていた
家具のある部屋を 6×6 の 36 部屋並べ、カメラが部屋から部屋へ歩きます。
起動時に「この部屋から、どの部屋が見えうるか」の表(PVS)を作っておきます。ドアは壁の真ん中にそろえてあるので、同じ列と同じ行の部屋、それに隣の 8 部屋だけが見えます。
pvs[(i, j)] = {(a, b) for a in range(6) for b in range(6)
if a == i or b == j or (abs(a - i) <= 1 and abs(b - j) <= 1)}
...
for (i, j), prim in rooms:
if (i, j) in pvs[current_room]:
node.prim(Mat4.IDENTITY, prim, img)
表が正しいかは、起動時に確かめています。全部の部屋を描いた画面と、表の部屋だけを描いた画面を、歩く道の 24 か所で比べ、違うピクセルは 0 個 でした。
| 三角形 | Mac | Code & Magic | |
|---|---|---|---|
| 全部の部屋 | 36664 | 5.8 | 7.4 |
| 見える部屋だけ | 約 13000 | 3.8 | 5.6 |
Mac で 3 割強、Code & Magic で 2 割強減りました。私は「前回の視界カリングと同じで、効果は小さい」と予想していましたが、外れました。壁の向こうの部屋は画面の中にあるので、エンジンは三角形を最後まで処理し、画素ごとの奥行きテストで捨てていたと考えています。画面の外の物は、エンジンが早めに捨てている可能性がありますが、ソースでは確かめていません。
まとめ
| 分かったこと | 確かめていないこと |
|---|---|
| アバターの重さは、塗る面積ではほぼ変わらない。低解像度が効くのは、大きな面が重なる場面 | 大きな面が多い場面で、低解像度がどこまで効くか |
| 画面 1 回分を塗るのは約 0.8 ms。手前から描くほうが速い | 前回の視界カリングで、効果が 1 割弱だった理由 |
| 壁の向こうの物も描かれている。部屋単位のカリングで 2〜3 割減り、見た目は変わらない | 部屋の形が不規則な場合の、見える部屋の表の作り方 |
| 2 階調のトゥーン、懐中電灯、空の箱は、ほぼ無料 | |
| 輪郭線をずらし描きで作ると、アバターの重さが 5 倍 |
2 本の記事で、PS1・PS2 時代の主な技法の検証は、ひととおり終わりました。次は、これらを組み合わせて作品にする段階です。
ライセンス・出典
- Pyxel:MIT License(kitao/pyxel)
- モーション:Quaternius Universal Animation Library 1/2、KayKit Character Animations(いずれも CC0 1.0)。VRMA への変換:open-vrma(CC0 1.0)
- アバター:VRoid Studio サンプルモデル「AvatarSample_A」(pixiv Inc.)を骨ごとに分割・変換して使用。このアバターは CC0 ではありません(モーションだけが CC0 です)。利用条件:VRoid のサンプルモデルの利用条件、VRoid Studio 個別規約
- テクスチャ、形状、BGM:デモの中で生成(自作)










