Pyxel 3.0.0 の 3D 機能 pyxel.cube は、ソフトウェア描画で、テクスチャの遠近補正がありません。初代 PlayStation に近い作りです。それなら、当時のゲームが使っていた「少ない計算で良く見せる技法」が、そのまま役に立つはずです。
本記事では、PS1・PS2 時代の技法を 13 個選び、pyxel.cube で実際に動かして、使った場合と使わない場合の描画時間(ms) を測りました。できたこと、できなかったこと、効果が小さかったことを、そのまま書きます。
前回の記事(Pyxel 3 の 3D(pyxel.cube)を実機とソースで確かめた)は、GLB・VRM・サウンドの読み込みまわりでした。今回は、描画の技法です。
検証環境
- Pyxel 3.0.0(PyPI 版)
- 実行環境 1:macOS ネイティブ(MacBook Pro M5 Max)
- 実行環境 2:Code & Magic(ブラウザ / Chrome、Pyodide)、同じ Mac
- 画面は 320×180、256 色、30 fps
30 fps に収めるには、1 フレームの更新と描画の合計が 33.3 ms 以下 である必要があります。
この記事の内容は Pyxel 3.0.0 時点 のものです。今後のバージョンで変わる可能性があります。
調べものや検証は AI と一緒に行いました。記事の画面は、私がデモを動かして撮ったものです。
検証の方法
13 個の技法を 1 本のデモにまとめました。1 シーンが 1 技法で、同じ場面を 2〜3 通りの方法で描き、右下に描画時間を表示します。何もしなければ約 2 分で全部が流れ、最後に結果の一覧が出ます。
デモは Code & Magic で公開しています。ブラウザで開けば、手元の環境での数値がその場で出ます。
- PYXEL 3 TECH BENCH(Code & Magic)
- 操作:←→ でシーン、Z(A)で比較の切り替え、X(B)で結果の一覧、ENTER で最初から
テクスチャも形状も、起動時に Python(numpy)で作っています。外部の素材は、VRM アバター 1 体だけです。
結果の一覧
数値は描画時間(ms)で、直近 1 秒ほどの平均です。12 番だけは物理の更新時間です。
| # | 技法 | 比べたもの | Mac | Code & Magic |
|---|---|---|---|---|
| 01 | 頂点のガタつき | そのまま / 1 px に丸め / 3 px に丸め | 1.2 / 1.4 / 1.4 | 2.5 / 3.2 / 3.2 |
| 02 | テクスチャと奥行きの歪み | 四角 1 枚 / 4 m 分割 / 1 m 分割 | 1.2 / 1.3 / 1.8 | 2.8 / 2.8 / 3.5 |
| 03 | 一枚絵の背景 | 全部 3D / 一枚絵 | 5.1 / 3.9 | 6.2 / 3.9 |
| 04 | 霧 | なし / 陰影表の差し替え / ディザの板 | 1.8 / 1.8 / 5.2 | 3.4 / 3.5 / 8.2 |
| 05 | 焼き込みライト | エンジンの光 / 焼き込み | 1.4 / 1.8 | 1.8 / 3.4 |
| 06 | 板ポリの群衆 | 3D 12 体 / 板ポリ 500 体 / 3D 4 体 + 板ポリ 896 体 | 23.1 / 4.7 / 12.5 | 31.3 / 7.4 / 16.4 |
| 07 | UV スクロールとパレット | 静止 / UV / UV + パレット | 2.3 / 2.4 / 2.4 | 4.0 / 4.2 / 4.4 |
| 08 | 環境マップ | 単色 / 映り込み | 2.3 / 2.7 | 3.9 / 4.3 |
| 09 | パーティクル | sprite 600 個 / pset 2500 個 / 点の Primitive 24000 個 | 2.4 / 2.9 / 3.8 | 4.0 / 4.9 / 6.1 |
| 10 | 視界カリング | 全部描く / 視界の外を飛ばす | 6.9 / 6.5 | 8.8 / 8.1 |
| 11 | 画面分割 | 1 画面 / 2 画面 / 4 画面 | 9.1 / 12.3 / 21.5 | 10.4 / 14.9 / 27.2 |
| 12 | 物理(更新時間) | 箱 20 個 / 箱 45 個 / 球 150 個 | 1.3 / 7.0 / 6.6 | 1.7 / 6.8 / 7.5 |
| 13 | 残像 | 通常 / 消さずにディザ / 過去の姿勢 4 体 | 4.1 / 4.2 / 12.3 | 4.6 / 4.1 / 14.9 |
Code & Magic は、Mac の約 1.2〜2.2 倍の時間でした。軽いシーンほど差が大きくなります。
先に:自前の投影の式
いくつかの技法では、「この頂点は画面のどこに来るか」を Python の側で知る必要があります。pyxel.cube のカメラは縦の画角(fov)で、次の式が実際の描画と一致しました。
def project(pts, eye, tgt, fov, vw=320, vh=180):
f = tgt - eye; f /= np.linalg.norm(f) # 視線
r = np.cross(f, (0, 1, 0)); r /= np.linalg.norm(r)
u = np.cross(r, f)
d = pts - eye
z = d @ f # 視線方向の距離
k = (vh / 2) / math.tan(math.radians(fov / 2))
return vw / 2 + (d @ r) / z * k, vh / 2 - (d @ u) / z * k, z
Camera.ortho_size(平行投影)は「画面の縦に入る長さ」でした。
01. 頂点のガタつき:Pyxel 単体では起きない
初代 PS は頂点の画面座標が整数で、動くとポリゴンが細かく震えました。pyxel.cube はどうでしょうか。
三角形を 1/8 ピクセルずつ横に動かして、画面の変化を数えました。毎回 4〜9 ピクセルが変わりました(Linux で計測)。頂点は 1 ピクセルより細かい精度で描かれており、ガタつきは起きません。
あの震えを演出として使いたい場合は、頂点を自分で丸めます。投影して、丸めて、同じ距離のまま 3D に戻します。
sx, sy, z = project(base, eye, tgt, fov)
sx = np.round(sx / grid) * grid # grid = 1 なら 1 px 単位
sy = np.round(sy / grid) * grid
prim.positions[:] = unproject(sx, sy, z, eye, tgt, fov).reshape(-1).tolist()
544 頂点で、+0.2 ms(Mac)でした。320×180 では 1 px の丸めは目立たないので、デモでは 3 px の版も用意しています。
Primitive の positions と uvs は、prim.positions[:] = リスト のスライス代入で書き換えられます。prim.positions = ... と付け替えることはできません。2 万頂点の書き換えは約 0.6 ms でした(Linux で計測)。
02. テクスチャと奥行きの歪み:分割で小さくなる
UV と奥行きは、画面上で直線的に補間されます(遠近補正なし)。大きな四角を斜めから見ると、テクスチャの直線が曲がります。対策は、PS1 と同じで、ポリゴンを細かく分けることです。
64 m の道路と壁を、四角 1 枚、4 m ごと、1 m ごとの 3 通りで描きました。
| 分け方 | 三角形 | Mac | Code & Magic |
|---|---|---|---|
| 四角 1 枚ずつ | 6 | 1.2 | 2.8 |
| 4 m ごと | 128 | 1.3 | 2.8 |
| 1 m ごと | 2048 | 1.8 | 3.5 |
1 枚では、レンガも白線も大きく崩れます。4 m でほぼ見られる形になり、1 m でまっすぐになります。三角形が 300 倍以上になっても、増えたのは 0.6〜0.7 ms でした。斜めに見える大きな面は、迷わず分割してよさそうです。
03. 一枚絵の背景 + 3D キャラ:元の 3D と 1 ピクセルも違わない
固定カメラのゲームでは、背景を 1 枚の絵にして、キャラだけを 3D で描く方法がありました。背景をどれだけ細かく作っても、描く時間が増えません。
問題は前後関係です。キャラが柱の後ろを通るとき、柱に隠れなければなりません。pyxel.cube には「奥行きだけを書く」方法がありませんでした(後述)。そこで、次のようにしました。
- 部屋全体を、起動時に 1 回だけ
Imageに描く。 - 毎フレーム、その
Imageを画面に貼る。 - キャラの手前に来る物(柱と机)だけを、その
Imageをテクスチャにして 3D で描き直す。UV は、頂点の画面座標をそのまま使う。
bg = pyxel.Image(W, H)
stage.draw(0, 0, W, H, bg) # 1. 部屋を Image に描く(target 引数)
sx, sy, _ = project(proxy_positions, EYE, TGT, FOV) # 3. 柱と机の頂点が画面のどこに来るか
proxy.uvs[:] = np.stack([sx / W, sy / H], 1).reshape(-1).tolist()
# 毎フレーム
pyxel.blt(0, 0, bg, 0, 0, W, H) # 2.
stage.camera.clear_color = None # 貼った絵を消さない
stage.draw(0, 0, W, H) # on_draw の中で shaded(False); prim(mat, proxy, bg)
描き直した柱は、色は背景と同じで、奥行きだけが新しく書かれます。そのあとに描くキャラは、柱より奥なら隠れます。
ここで、02 の「歪み」が逆に役立ちます。UV は画面上で直線的に補間されるので、画面座標から作った UV には誤差が出ません。全部を 3D で描いた画面と比べたところ、違うピクセルは 0 個 でした。
| 部屋の三角形 | Mac | Code & Magic | |
|---|---|---|---|
| 毎フレーム全部 3D | 4950 | 5.1 | 6.2 |
| 一枚絵 + 柱と机 | 204 | 3.9 | 3.9 |
どちらもアバター 1 体(スキニングあり)の描画を含みます。一枚絵の側は、部屋をどれだけ作り込んでも 3.9 ms のままです。
04. 霧:2 通りのやり方
遠くを霧で隠すと、描く範囲を狭くできます。pyxel.cube に霧の機能はないので、2 通りを試しました。
(a) 陰影表(Shading)の差し替え 距離の帯ごとに、霧の色へ寄せた Shading を用意して、ノードに付け替えます。
sh = Shading(pyxel.colors)
for c in used_colors:
for lv in range(Shading.LEVEL_COUNT):
a, b = base[c, lv] # もとの 2 色(ディザの組)
sh[c, lv] = (nearest(mix(a, fog, f)), nearest(mix(b, fog, f)))
band_node.shading = sh # この帯の物は、霧に寄った色で描かれる
描画時間は増えません(1.8 → 1.8 ms)。ただし、物ごと・帯ごとに色が変わるので、境目が見えます。Shading(pyxel.colors) は 256 色で約 0.17 秒かかるので(Linux で計測)、起動時に作っておきます。
(b) ディザの板を重ねる カメラの正面に、霧の色の板を距離を変えて 8 枚置きます。遠い板ほど濃いディザにします。
self.shaded(False)
self.depth_write(False) # 板は奥行きを書かない
for k, d in enumerate(SLICES): # 13, 19, ... 55 m
self.dither((k + 1) / (len(SLICES) + 0.5))
h = d * math.tan(math.radians(fov / 2)) * 2.2
self.rect(camera.transform * Mat4.from_translation(Vec3(0, 0, -d)), h * W / H, h, FOG)
奥行きテストがあるので、板より奥の物だけが覆われます。遠い物ほど濃い板に覆われ、ピクセル単位の霧になります。板 8 枚で +3.4 ms(Mac)、+4.8 ms(Code & Magic)でした。
同じ場所に重なったディザは、足し算になりません。パターンが同じなので、いちばん濃い 1 枚と同じ見た目 になります(25% を 3 枚重ねても 25%)。そのため、遠い板ほど濃くしています。
05. 焼き込みライト:光源は 1 つだが、焼けば何個でも
pyxel.cube の光は、向きが 1 つあるだけです。点光源も、影もありません。そこで、光を先に計算して、テクスチャの色に掛けておきます。
床の 192×192 のテクスチャについて、1 画素ごとに 4 色の点光源からの明るさを numpy で足し、柱の影(光源までの線が柱に当たるか)も入れました。描くときは shaded(False) にします。
| Mac | Code & Magic | |
|---|---|---|
| エンジンの光 1 つ | 1.4 | 1.8 |
| 焼き込み(4 色の点光源 + 影) | 1.8 | 3.4 |
焼き込みのほうが少し遅くなりました(原因は調べていません)。見た目の差は大きいです。弱点は、動く物には光が当たらないことです。
06. 板ポリの群衆:500 体が、3D 12 体の約 1/4 の時間
遠くの人を、3D ではなく 1 枚の絵(板ポリ)で描く方法です。
起動時に、アバターを 8 方向 × 8 コマ × 2 モーションで Image に描いておきます(1 コマ 32×48、平行投影)。
st.camera.ortho_size = 1.8 # 縦 1.8 m が 48 px に入る
for f in range(8): # アニメの 8 コマ
av.time = f / 8 * length / 30.0
av.update(0.0)
for d in range(8): # 8 方向
a = d / 8 * math.tau
st.cam((math.sin(a) * 6, 1.05, math.cos(a) * 6), (0, 0.9, 0))
st.draw(f * 32, d * 48, 32, 48, atlas) # Image の一部に描く
描くときは、カメラから見た角度で方向を選び、sprite() で置きます。
d = int(round((math.atan2(ex - x, ez - z) - facing) / math.tau * 8)) % 8
n.sprite(pos, atlas, uv[d][(frame + phase) % 8], 1.2, 1.8, colkey=255)
| Mac | Code & Magic | |
|---|---|---|
| 3D アバター 12 体 | 23.1 | 31.3 |
| 板ポリ 500 体 | 4.7 | 7.4 |
| 3D 4 体 + 板ポリ 896 体 | 12.5 | 16.4 |
3D のアバターは、1 体あたり Mac で約 2 ms、Code & Magic で約 2.6 ms です。Code & Magic では 12 体で 30 fps の枠がほぼ埋まります。板ポリなら 500 体でも余裕があります。近くの数体だけを 3D にする組み合わせが、実用的です。
07. UV スクロールとパレット回し:どちらもほぼ無料
水や溶岩を「流れているように」見せる、古典的な 2 つの方法です。
UV スクロール pyxel.cube の UV は 0〜1 で止まり、繰り返しません。そこで、同じ模様を縦に 2 回並べた画像を作り、半分の高さの窓を動かします。
o = (t * 0.22) % 0.5
river.uvs[:] = (base_uv + np.array([0, 0.5 - o], np.float32)).reshape(-1).tolist()
パレット回し 溶岩の画像の中身を、パレット番号 224〜231 だけで描いておきます。あとは、その 8 色を順に回すだけです。画像も形状も変えません。
for k in range(8):
pyxel.colors[224 + k] = LAVA[(k + shift) % 8]
| Mac | Code & Magic | |
|---|---|---|
| 静止 | 2.3 | 4.0 |
| UV スクロール | 2.4 | 4.2 |
| UV + パレット回し | 2.4 | 4.4 |
差は 0.1〜0.4 ms でした。
08. 環境マップ:フラット陰影でも、なめらかな金属に見える
pyxel.cube の陰影は、面ごとのフラットだけです(前回の記事の 3 章)。Primitive に頂点ごとの法線を渡すと、normals length must equal face_count * 3 when shaded というエラーになります。
なめらかに見せるには、陰影ではなくテクスチャを使います。「鏡の玉に映った景色」の絵を 1 枚用意し、法線がカメラから見てどちらを向いているか を UV にします。
nw = normals @ R.T # モデルの回転を掛けた法線
u = 0.5 + 0.48 * (nw @ cam_right)
v = 0.5 - 0.48 * (nw @ cam_up)
knot.uvs[:] = np.stack([u, v], 1).reshape(-1).tolist()
1687 頂点の UV を毎フレーム計算して、+0.4 ms でした。
09. パーティクル:1 つにまとめると桁が変わる
粒を 1 個ずつ sprite() や pset() で描くと、粒の数だけ Python の呼び出しが発生します。全部の粒を 1 つの Primitive(点のモード)にまとめると、呼び出しは 1 回で済みます。
prim = Primitive(Primitive.MODE_POINTS, [0.0] * (3 * n), list(range(n)))
# 毎フレーム:numpy で全部動かして、まとめて書き込む
prim.positions[:] = pos.reshape(-1).tolist()
node.prim(Mat4.IDENTITY, prim, color)
描画だけを取り出して、2000 個で比べた結果です(Linux で計測)。
| 方法 | 2000 個の描画 |
|---|---|
sprite() を 2000 回 |
3.25 ms |
pset() を 2000 回 |
0.43 ms |
点の Primitive 1 つ |
0.10 ms |
デモでは、数を変えて同じくらいの重さにしています。
| Mac | Code & Magic | |
|---|---|---|
sprite() 600 個 |
2.4 | 4.0 |
pset() 2500 個 |
2.9 | 4.9 |
点の Primitive 24000 個(4 色ぶん 4 つ) |
3.8 | 6.1 |
点は 1 ピクセルで、色は Primitive ごとに 1 色です。大きさや絵が必要な粒には、sprite() を使います。
10. 視界カリング:この場面では、効果は 1 割弱
画面に入らない物を、自分で飛ばす方法です。建物約 3600 棟(約 5 万三角形)の街を 144 個のかたまりに分け、視界に入るかたまりだけを prim() に渡しました。
| 渡した三角形 | Mac | Code & Magic | |
|---|---|---|---|
| 全部渡す | 50552 | 6.9 | 8.8 |
| 視界の外を飛ばす | 約 19000 | 6.5 | 8.1 |
渡す三角形を 6 割減らしても、時間は 1 割弱しか減りませんでした。理由は、まだ確かめていません(Linux で解像度を変えて測ると、塗る時間は全体の 3〜4 割で、大半ではありませんでした)。
一方、塗りがほとんどない条件では差が出ます。小さな箱 1500 個を 1 個ずつ描く実験では、視界内 4.9 ms、カメラの後ろ 2.0 ms、visible = False で 0.27 ms でした(Linux で計測)。画面の外でも、呼び出しと頂点の計算の時間は残ります。 物の数が多い場面では、飛ばす意味があります。
11. 画面分割:4 画面でも動く
Node.draw(x, y, w, h) は、画面の一部に描けます。カメラを切り替えながら複数回呼べば、画面分割になります。
for k, (x, y, w, h) in enumerate(views):
stage.camera.transform = Mat4.look_at(eye[k], target[k], Vec3.UP)
stage.draw(x, y, w, h)
| Mac | Code & Magic | |
|---|---|---|
| 1 画面 | 9.1 | 10.4 |
| 2 画面 | 12.3 | 14.9 |
| 4 画面 | 21.5 | 27.2 |
アバター 4 体が走る場面です。画面が小さくなると塗る時間は減りますが、頂点の計算は画面の数だけかかります。4 画面で約 2.4〜2.6 倍でした。
12. 物理:箱 45 個で約 7 ms
Collider を付けると、重力と衝突の検出が働きます。ただし、ぶつかったあとの押し戻しは、自分で書く必要があります(公式サンプル c06 と同じ)。書かないと、床をすり抜けます。
class Body(Node):
def on_collide(self, other, c):
if self.collider.mass:
self.transform = Mat4.from_translation(c.normal * c.depth) * self.transform
self.collider.velocity += c.delta_velocity
self.collider.angular_velocity += c.delta_angular_velocity
長さの単位は 100 = 1 m です。
| Mac | Code & Magic | |
|---|---|---|
| 箱 20 個 | 1.3 | 1.7 |
| 箱 45 個(積み上がる) | 7.0 | 6.8 |
| 球 150 個 | 6.6 | 7.5 |
箱は、積み上がって接触が増えると急に重くなります。球は 150 個でも箱 45 個と同じくらいでした。なお、積み上がった箱は少しめり込みます。
13. 残像:画面を消さないだけ
camera.clear_color = None にすると、前のフレームが残ります。描く前に、背景色の四角をディザで薄く重ねると、古い絵がだんだん消えて、残像になります。
stage.camera.clear_color = None
pyxel.dither(0.3)
pyxel.rect(0, 0, W, H, BG)
pyxel.dither(1.0)
stage.draw(0, 0, W, H)
| Mac | Code & Magic | |
|---|---|---|
| 通常 | 4.1 | 4.6 |
| 消さずにディザ | 4.2 | 4.1 |
| 過去の姿勢を 4 体描き直す | 12.3 | 14.9 |
消さない方法は、時間が増えません(デモのこの版では、残像が見やすいように床を描いていません)。ただし、画面全体に効くので、床や背景も一緒に流れます。キャラだけに残像を付けるには、過去の姿勢を描き直す必要があり、体数ぶんの時間がかかります。
pyxel.cube 3.0.0 で、できなかったこと
| やりたいこと | 結果 | 代わりの方法 |
|---|---|---|
| なめらかな陰影(頂点ごとの法線) | 面ごとのフラットのみ | 環境マップ(08)、焼き込み(05) |
| 奥行きだけを書く |
dither(0) の面も、全面が透明色のテクスチャの面も、奥行きを書かない |
背景の絵をテクスチャにして描き直す(03) |
| 複数の光源、点光源、影 | 光の向きが 1 つだけ | 焼き込み(05) |
| UV の繰り返し | 0〜1 で止まる | 模様を並べた画像と、窓の移動(07) |
| 加算の半透明 | ディザのみ | ディザ、パレットの変更 |
| 霧 | 機能なし | 陰影表の差し替え、ディザの板(04) |
まとめ
| 分かったこと | 確かめていないこと |
|---|---|
| 頂点はサブピクセル精度。PS1 のガタつきは起きない | スマホのブラウザでの数値 |
| 一枚絵の背景は、画面座標の UV で描き直せば、元の 3D と 1 ピクセルも違わない | 一枚絵の背景で、カメラを動かす場合(絵の切り替え) |
| 板ポリ 500 体は、3D 12 体の約 1/4 の時間 | 板ポリの絵を、もっと大きくした場合の重さ |
| UV スクロール、パレット回し、環境マップは +0.1〜0.4 ms | 他の機種での数値 |
| 視界カリングは、この場面では 1 割弱しか効かなかった(理由は未確認) | 物の数が多い実際のゲームでの効果 |
粒は 1 つの Primitive にまとめると、呼び出しが 1 回で済む |
PS1・PS2 時代の技法は、「計算を前もって済ませておく」ものが多く、pyxel.cube とよく合いました。とくに、一枚絵の背景、板ポリ、焼き込み、パレット回しは、描画時間をほとんど増やさずに、見た目を大きく変えられます。
既知の問題 Code & Magic では、06 の最初(3D アバター 12 体が出る瞬間)に、音にノイズが入ります。そのフレームの処理が重いためと考えていますが、原因は確かめていません。
ライセンス・出典
- 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:デモの中で生成(自作)















