0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Pyxel 3 の 3D(pyxel.cube)で PS1・PS2 時代の技法を13個試して、重さを測った

0
Last updated at Posted at 2026-11-10

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 で公開しています。ブラウザで開けば、手元の環境での数値がその場で出ます。

テクスチャも形状も、起動時に 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

Mac の結果
Code & Magic の結果

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 の版も用意しています。

01

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

02

1 枚では、レンガも白線も大きく崩れます。4 m でほぼ見られる形になり、1 m でまっすぐになります。三角形が 300 倍以上になっても、増えたのは 0.6〜0.7 ms でした。斜めに見える大きな面は、迷わず分割してよさそうです。

03. 一枚絵の背景 + 3D キャラ:元の 3D と 1 ピクセルも違わない

固定カメラのゲームでは、背景を 1 枚の絵にして、キャラだけを 3D で描く方法がありました。背景をどれだけ細かく作っても、描く時間が増えません。

問題は前後関係です。キャラが柱の後ろを通るとき、柱に隠れなければなりません。pyxel.cube には「奥行きだけを書く」方法がありませんでした(後述)。そこで、次のようにしました。

  1. 部屋全体を、起動時に 1 回だけ Image に描く。
  2. 毎フレーム、その Image を画面に貼る。
  3. キャラの手前に来る物(柱と机)だけを、その 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 のままです。

03

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)でした。

04

同じ場所に重なったディザは、足し算になりません。パターンが同じなので、いちばん濃い 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

05

焼き込みのほうが少し遅くなりました(原因は調べていません)。見た目の差は大きいです。弱点は、動く物には光が当たらないことです。

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

06

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

07

差は 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 でした。

08

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

09

点は 1 ピクセルで、色は Primitive ごとに 1 色です。大きさや絵が必要な粒には、sprite() を使います。

10. 視界カリング:この場面では、効果は 1 割弱

画面に入らない物を、自分で飛ばす方法です。建物約 3600 棟(約 5 万三角形)の街を 144 個のかたまりに分け、視界に入るかたまりだけを prim() に渡しました。

渡した三角形 Mac Code & Magic
全部渡す 50552 6.9 8.8
視界の外を飛ばす 約 19000 6.5 8.1

10

渡す三角形を 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

11

アバター 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

12

箱は、積み上がって接触が増えると急に重くなります。球は 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

13

消さない方法は、時間が増えません(デモのこの版では、残像が見やすいように床を描いていません)。ただし、画面全体に効くので、床や背景も一緒に流れます。キャラだけに残像を付けるには、過去の姿勢を描き直す必要があり、体数ぶんの時間がかかります。


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:デモの中で生成(自作)
0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?