8
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【AI と一緒に作る 3D!】(Part 1)Pyxelでワイヤーフレームレンダリングを実装するまで

8
Last updated at Posted at 2025-12-06

こんにちは、土居 意弘(ムンペイ)です。
ふだんは皆さんご存じレトロゲームエンジン Pyxel を使ったプログラミング学習サービス「Code & Magic」を開発・運営しています。

今回はそのサービス向けのネタ研究の一環として、AI(ChatGPT)とペアプログラミングしながら Pyxel で3Dエンジンを作成した話をまとめてみました。

はじめに

3D グラフィックがすっかり身近になって現実との境目もわからないくらいになった現在ですが、私がコンピュータを学んだ頃はどうやればリアルな3次元画像を作れるのか、高速化できるのかといった研究進化の真っただ中でした。今さらそのノスタルジーを押し付けるものでもないとは思いますが、せっかくのレトロゲームエンジンですので、その背後にある様々な工夫の歴史をたどって思いをはせるのも楽しいと思っております。

この記事では、以下のところまで完成します:

  • 3D 頂点の変換
  • 球体メッシュの作成
  • ワイヤーフレームレンダリング

pyxel-20251207-000329.png

AI が相棒としてどんなふうに助けてくれたかも含めて紹介します。


🧱 1. まずは 3D → 2D 変換から

まずとっかかりとして、手元の「パソコンによる3次元グラフィックスの実際」(山本強著、1983年)の 3D → 2D 変換(座標変換と透視変換)に掲載された F-BASIC のコードを、Pythonに変換させることから始めました。

この書籍は、Google の AI 検索によると下記のような説明があり、3Dマニアなら押さえておいて損はない一冊です。(私事ですが、大学で著者の山本先生に教えをいただきました。)

(抜粋)この本は、日本の初期のパソコンにおける3DCG技術の普及に大きな影響を与えた歴史的な書籍の一つと言えます。現在では主に中古市場や Amazon.co.jp などで入手可能です。
https://share.google/aimode/5stUzQ6VvNLmaWpNj

DSC_0281.JPG

変換したのは、下記のような透視変換のコードです。F-BASICなので行番号が付いています。

DSC_0283.JPG
これを、

下記のBASICのプログラムをPythonのサブルーチンに変換してください。

という雑なプロンプトで変換してもらったのが下記です。このコードがこの先も基盤となっています。


def rotate_xyz(X: int, Y: int, Z: int, RX: int, RY: int, RZ: int):
    """
    BASICコードと同じ回転行列処理を行い、
    (X, Y, Z) を RX, RY, RZ(度数法)の回転で変換する。
    """

    # --- matrix generation (degree → radian)
    CRX = math.cos(RX * math.pi / 180)
    SRX = math.sin(RX * math.pi / 180)

    CRY = math.cos(RY * math.pi / 180)
    SRY = math.sin(RY * math.pi / 180)

    CRZ = math.cos(RZ * math.pi / 180)
    SRZ = math.sin(RZ * math.pi / 180)

    # --- rotation matrix (BASIC と完全対応)
    R00 = CRY * CRZ
    R01 = SRX * SRY * CRZ - CRX * SRZ
    R02 = CRX * SRY * CRZ + SRX * SRZ

    R10 = CRY * SRZ
    R11 = SRX * SRY * SRZ + CRX * CRZ
    R12 = CRX * SRY * SRZ - SRX * CRZ

    R20 = -SRY
    R21 = SRX * CRY
    R22 = CRX * CRY

    # --- matrix multiply
    X1 = X * R00 + Y * R01 + Z * R02
    Y1 = X * R10 + Y * R11 + Z * R12
    Z1 = X * R20 + Y * R21 + Z * R22

    return X1, Y1, Z1

すごくそのままですねw。Python では変数は小文字にするべきですが、ここはそのままにしておきましょう。

同様に、ワイヤーフレーム用のクリッピング処理も変換しました。

DSC_0282.JPG

プロンプトはこうです。

次は下記のクリッピングを行うBASICのルーチンをPythonプログラムに変換してください。 これは、1983年出版の山本強著THE 3 DIMENSIONAL COMPUTER GRAPHICS に掲載の、リスト1-2です。(なお、1150行はバグがあるんじゃないかと思ってます)

すると、

以下に、提示された BASIC のクリッピングルーチン(リスト1-2)を忠実に Python へ移植したバージョン を示します。
アルゴリズムは Cohen–Sutherland に類似した視錐台クリッピングで、
視点からの投影空間での X/Z, Y/Z による 4bit コード判定を行っています。

🚨 指摘:1150行は明らかに誤り

1150   IF Y1> AY*Z1 THEN C1=C1+8

ここは (Y0, Z0) に対する判定のはずなので、

1150   IF Y0> AY*Z0 THEN C0=C0+8

が正しいです。

といった解説付きでコードを出してくれました。

def clip_segment(
    X0: int, Y0: int, Z0: int, X1: int, Y1: int, Z1: int, AX: int, AY: int
):
    """
    Python版クリッピングルーチン(BASIC のリスト1-2を忠実に移植)

    入力:
        (X0,Y0,Z0)-(X1,Y1,Z1): 線分
        AX, AY: 視野パラメータ
    出力:
        F, X0, Y0, Z0, X1, Y1, Z1
            F=0 → 描画すべき(線分が残った)
            F=1 → 描画不要(完全に視野外)
    """

    for _ in range(1, 100):
        # --- 4bit region code for point 1
        C1 = 0
        if X1 < -AX * Z1:
            C1 |= 1
        if X1 > AX * Z1:
            C1 |= 2
        if Y1 < -AY * Z1:
            C1 |= 4
        if Y1 > AY * Z1:
            C1 |= 8

        # --- 4bit region code for point 0
        C0 = 0
        if X0 < -AX * Z0:
            C0 |= 1
        if X0 > AX * Z0:
            C0 |= 2
        if Y0 < -AY * Z0:
            C0 |= 4
        if Y0 > AY * Z0:
            C0 |= 8  # BASIC の 1150行のバグを修正

        # どちらも領域コード 0 → 完全に視野内(クリップ不要)
        if (C0 | C1) == 0:
            return 0, X0, Y0, Z0, X1, Y1, Z1

        # 論理積が非0 → 完全に視野外(交わらない)
        if (C0 & C1) != 0:
            return 1, X0, Y0, Z0, X1, Y1, Z1

        # C0 を clipping する点にする
        if C0 == 0:
            # SWAP (X0,Y0,Z0) ⇔ (X1,Y1,Z1)
            X0, X1 = X1, X0
            Y0, Y1 = Y1, Y0
            Z0, Z1 = Z1, Z0
            C0, C1 = C1, C0

        # --- クリップ面との交点割合 T を計算(BASIC そのまま)
        if (C0 & 1) != 0:  # X < -AX*Z
            T = (-X0 - AX * Z0) / ((X1 - X0) + AX * (Z1 - Z0))

        elif (C0 & 2) != 0:  # X > AX*Z
            T = (AX * Z0 - X0) / ((X1 - X0) - AX * (Z1 - Z0))

        elif (C0 & 4) != 0:  # Y < -AY*Z
            T = (-Y0 - AY * Z0) / ((Y1 - Y0) + AY * (Z1 - Z0))

        else:  # (C0 & 8) != 0: Y > AY*Z
            T = (AY * Z0 - Y0) / ((Y1 - Y0) - AY * (Z1 - Z0))

        # --- 線分をクリップされた交点に更新
        X0 = X0 + (X1 - X0) * T
        Y0 = Y0 + (Y1 - Y0) * T
        Z0 = Z0 + (Z1 - Z0) * T

        # 再判定へ(BASIC の GOTO 1110)
    else:
        return 1, X0, Y0, Z0, X1, Y1, Z1

AI は 3D CG の知識はたっぷりありそうです。
どんどん行けそうな気がしました。


🎨 2. ワイヤーフレーム描画の実装

変換ができたら三角形の頂点を線で結ぶだけ。
なんですが、モデル(メッシュ)を作ったりする処理もあるので、ちょっと量があります。

書籍の中では手軽なものがなかったので、ここからは ChatGPT とだけ進めました。

ChatGPT とはいろいろ相談しながら順番に進めたのですが、途中をなぞると長いので、要点のみ書いていきます。

2. 球体メッシュの作成

表示するものがないと行けませんので、球体モデルを作りました。

この2つのwireframe用メソッドに互換性のある、球体のメッシュデータを生成してください。

と頼むと、緯度・経度分割による一般的な球体生成を即座に構築。

def generate_sphere_wireframe(radius=1.0, lat_steps=8, lon_steps=16):
    """
    球体のワイヤーフレーム用メッシュを生成する。

    戻り値:
        points  : [(x, y, z), ...]  # 3D座標(球は原点中心)
        segments: [(i, j), ...]     # points[i] と points[j] を線で結ぶ
    パラメータ:
        radius   : 球の半径
        lat_steps: 緯度分割数(縦方向のリング数)
        lon_steps: 経度分割数(横方向のリング数)
    """

    points = []
    segments = []

    # 頂点生成
    # 緯度 θ: 0 .. π(0: 北極, π: 南極)
    # 経度 φ: 0 .. 2π
    for lat in range(lat_steps + 1):  # 極を含めて lat_steps+1 本
        theta = math.pi * lat / lat_steps  # 0 .. π
        sin_theta = math.sin(theta)
        cos_theta = math.cos(theta)

        for lon in range(lon_steps):
            phi = 2.0 * math.pi * lon / lon_steps  # 0 .. 2π
            sin_phi = math.sin(phi)
            cos_phi = math.cos(phi)

            x = radius * sin_theta * cos_phi
            y = radius * cos_theta      # Y軸が「縦」方向
            z = radius * sin_theta * sin_phi

            points.append((x, y, z))

    # インデックス用のヘルパ
    def idx(lat, lon):
        return lat * lon_steps + lon

    # 経度方向(横リング)の線分
    for lat in range(lat_steps + 1):
        for lon in range(lon_steps):
            i0 = idx(lat, lon)
            i1 = idx(lat, (lon + 1) % lon_steps)  # 周回
            segments.append((i0, i1))

    # 緯度方向(縦線)の線分
    for lat in range(lat_steps):
        for lon in range(lon_steps):
            i0 = idx(lat, lon)
            i1 = idx(lat + 1, lon)
            segments.append((i0, i1))

    return points, segments

すごいです。


🚫 3. ワイヤーフレームレンダラー

次は、先ほどのクリッピング処理を利用したレンダラーです。

実は先ほどの球体生成のときに、こんな回答も付いていました。

🔧 あなたのワイヤーフレーム処理との接続例
たとえば、以前の:

  • rotate_xyz_fast(x, y, z, rx, ry, rz)
  • clip_segment_fast(x0, y0, z0, x1, y1, z1, AX, AY)
    と組み合わせると、こんな感じになります。
    (コードが続く)

そのままだと使いにくいと思うといったところ、Camera や Mesh 、 Renderer などのクラスに分ける構成を提案してくれました。

一般的な構成はこうです:

Camera → 世界座標をカメラ座標・投影座標に変換する
Renderer → 投影された結果を描画する
Mesh → points/segments を持つ
Scene → Mesh と Camera の関係を保持

Scene はまだ不要だと思ったのでメインクラスにその役割をさせることにします。

で紆余曲折を経て出来上がったワイヤーフレームレンダラークラスが下記です。最初に変換させたクリッピング処理が使われています。

class WireframeRenderer:
    def __init__(self, camera: Camera, color: int = 7):
        self.camera = camera
        self.color = color

    def draw_mesh(self, mesh: Mesh, color: int | None = None):
        if color is None:
            color = self.color

        cam = self.camera

        # ---------------------------------------
        # 1. 頂点: local → world → camera
        # ---------------------------------------
        cam_pts: list[tuple[float, float, float]] = []
        for lx, ly, lz in mesh.points:
            wx, wy, wz = mesh.local_to_world(lx, ly, lz)
            cx, cy, cz = cam.world_to_camera(wx, wy, wz)
            cam_pts.append((cx, cy, cz))

        # ---------------------------------------
        # 2. セグメントが空なら、faces から生成(お好みで)
        # ---------------------------------------
        segments = mesh.segments
        if not segments and mesh.faces:
            edges = set()
            for i0, i1, i2 in mesh.faces:
                e01 = tuple(sorted((i0, i1)))
                e12 = tuple(sorted((i1, i2)))
                e20 = tuple(sorted((i2, i0)))
                edges.add(e01)
                edges.add(e12)
                edges.add(e20)
            segments = list(edges)

        # ---------------------------------------
        # 3. 各セグメントをクリッピング&描画
        # ---------------------------------------
        for i, j in segments:
            x0, y0, z0 = cam_pts[i]
            x1, y1, z1 = cam_pts[j]

            # 両端ともカメラ後方なら捨てる
            if z0 <= 0 and z1 <= 0:
                continue

            # 視錐台クリッピング
            F, X0c, Y0c, Z0c, X1c, Y1c, Z1c = clip_segment(
                x0, y0, z0,
                x1, y1, z1,
                cam.AX, cam.AY,
            )

            # F=0 → 描画すべき / F=1 → 描画不要
            if F != 0:
                continue

            # 投影
            sx0, sy0 = cam.project(X0c, Y0c, Z0c)
            sx1, sy1 = cam.project(X1c, Y1c, Z1c)

            pyxel.line(sx0, sy0, sx1, sy1, color)

カメラはこちらです。同じく透視変換処理が使われています。

class Camera:
    def __init__(
        self,
        rx=0,
        ry=0,
        rz=0,
        tx=0,
        ty=0,
        tz=0,
        fov_y_deg=60,
        aspect_ratio=160 / 120,
        screen_w=160,
        screen_h=120,
        scale=50,
    ):

        # カメラ位置
        self.tx = tx
        self.ty = ty
        self.tz = tz

        # 回転(度)
        self.rx = rx
        self.ry = ry
        self.rz = rz  # 通常は0で使うことが多い

        # FOV設定(縦方向)
        self.fov_y = math.radians(fov_y_deg)
        self.aspect = aspect_ratio

        # FOVから AX/AY を自動生成
        self.AY = math.tan(self.fov_y / 2)
        self.AX = self.AY * self.aspect

        # 描画座標変換
        self.cx = screen_w // 2
        self.cy = screen_h // 2
        self.scale = scale

    # ---------------------------------------
    # look_at: カメラを指定ターゲットに向ける
    # ---------------------------------------
    def look_at(self, tx, ty, tz):
        dx = tx - self.tx
        dy = ty - self.ty
        dz = tz - self.tz

        # yaw(y軸回転) 左右方向
        self.ry = math.degrees(math.atan2(dx, dz))

        # pitch(x軸回転) 上下方向
        dist = math.sqrt(dx * dx + dz * dz)
        self.rx = -math.degrees(math.atan2(dy, dist))

        # roll は通常使わない
        # self.rz = 0

    # ---------------------------------------
    # 世界座標 → カメラ座標
    # ---------------------------------------
    def world_to_camera(self, x, y, z):
        # カメラ位置で平行移動
        x -= self.tx
        y -= self.ty
        z -= self.tz

        # カメラ回転を適用
        return rotate_xyz(x, y, z, self.rx, self.ry, self.rz)

    # ---------------------------------------
    # カメラ座標 → 投影座標(画面座標)
    # ---------------------------------------
    def project(self, x, y, z):
        sx = x / z
        sy = y / z
        return (int(self.cx + sx * self.scale), int(self.cy - sy * self.scale))

👀 4. ワイヤーフレームレンダリングの完成

その後、球体は Icosphere という正二十面体を基にしたモデルが現代的だということを ChatGPT に教えてもらいまして、それに差し替えました。

全体をまとめるクラスを作り、ChatGPT と相談しながら、FPS や Utilization% (フレーム内で実際に使用している時間に割合)計測を追加して、次のような感じとなりました。

class Wireframe:
    def __init__(self):
        self.rx = 0
        self.ry = 0
        self.rz = 0

        points, faces = generate_icosphere_mesh(subdivisions=2, radius=1.0)
        self.sphere = Mesh(points, faces, tx=0, ty=0, tz=0, scale=1.2)
        self.camera = Camera(
            rx=0,
            ry=0,
            rz=0,
            tx=0,
            ty=0,
            tz=-3,
            fov_y_deg=60,
            aspect_ratio=512 / 512,
            screen_w=512,
            screen_h=512,
            scale=300,
        )

        self.renderer = WireframeRenderer(self.camera, color=7)

        self.last_time = time.time()
        self.frame_counter = 0
        self.current_fps = 0
        self.target_frame_time = 1.0 / FPS  # 60FPS の理論フレーム時間

        self.measure_start = 0.0
        self.frame_compute_time = 0.0

        pyxel.init(512, 512, title="Wireframe", fps=FPS)
        pyxel.run(self.update, self.draw)

    def update(self):
        # 処理測定開始
        self.measure_start = time.time()

        # Update処理
        self.sphere.ry += 1

        # FPS 計測
        now = time.time()
        self.frame_counter += 1
        # now = time.time()
        if now - self.last_time >= 1.0:
            self.current_fps = self.frame_counter
            self.frame_counter = 0
            self.last_time = now

    def draw(self):
        # Draw処理
        pyxel.cls(0)
        # self.renderer.clear_zbuffer()
        self.renderer.draw_mesh(self.sphere)

        # 処理終了
        end = time.time()
        self.frame_compute_time = end - self.measure_start

        # Utliziation%の計算
        utilization = self.frame_compute_time / self.target_frame_time
        if utilization > 1.0:
            utilization = 1.0

        # FPS 表示(左上)
        pyxel.text(5, 5, f"FPS {self.current_fps}", 7)

        # 利用率%(0-100)
        util_percent = int(utilization * 100)
        pyxel.text(5, 15, f"UTIL {util_percent}%", 7)

        # わかりやすいバー表示も可能
        pyxel.rect(5, 25, util_percent, 5, 8)  # 簡易バーグラフ


Wireframe()

🌀 回転させるとこうなる

こうして出来上がったのを見ると、球体が「それっぽく」回転してくれていますね!
pyxel-20251206-235859.gif

メッシュが綺麗に見えるよう、512 x 512 という Pyxel アプリとしてはやや大きめのウィンドウサイズに設定していますが、FPS と Util% を見てみると、まだまだ余裕たっぷりですね。


🎉 Part 1 のまとめ

今回 AI と協力して次を実装しました。

  • 3D座標 → Pyxel座標の変換
  • 球体モデルの生成
  • ワイヤーフレーム描画

AI に相談しながらであれば、このレベルの 3D レンダラが 1 日で作れてしまうという点には驚きましたね。

🔮 Part 2 予告:AI と一緒に Gouraud / Phong シェーディングへ

さて、いろいろやってるうちにもう少しという気持ちが出てきてしまいまして、もう少し続きが書けそうです。
Part 2 として後日公開したいと思います。

Part 2 では、陰面消去からさらに進んで、

  • Z バッファ付き Gouraud (グーロー)シェーディング
  • Pyxel HighColor パレットによる 擬似フルカラー Phong (フォン)シェーディング

まで AI と共に作り込んだ話を書きます。

Pyxel でここまでできるのか!という驚きをぜひ一緒に体験してください。
「AI とペアプログラミングで 3D レンダラを作る」という体験は、レトロゲーム開発の新しい楽しみ方にもなるはず。

Part 2 もお楽しみに!

付録

完全なコードはこちらです。(Part 2のネタバレに注意)
https://github.com/munepi0713/pyxel-3d

AIとの会話の履歴はこちらからどうぞ。(Part 2のネタバレに注意)
https://chatgpt.com/share/69343819-6c68-800c-9c2a-cf97347747db

8
3
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
8
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?