インフィニットループ 技術ブログ

2022年12月23日 (金)

著者 : nob

PHPとSDLで始めるコンピューターグラフィックス – 透視投影で3D

こんにちは、普段はインフラや情シスをお仕事にしていますが、グラフィックスも趣味にしている nobuh です。前回の記事 PHPとSDLで始めるコンピューターグラフィックス に続きまして、今回は PHP での SDL を使った 3D グラフィックスに挑戦したいと思います!

前回までのおさらい

前回の記事 では

  • PHP と php-sdl のインストール方法
  • SDL のウインドウを出してグラフィックスを描画する方法
  • フレーム毎にキー入力を処理し、多数の四角を描画する

という 2D グラフィックスの基本的なところを紹介いたしました。今回は 線を引く という原始的な機能を使って 3D のワイヤーフレームで物体を描画して動かすところまで挑戦してみます!

3D を 2D に描画する透視投影

3次元の立体を2次元の平面上に書く手法としては、美術の授業で教わる2点透視図法などいろいろな方法があります。私も手で書く絵の方法としては2点透視図が大好きですが、コンピューターのスクリーンに描画する方法としては 透視投影 (Perspective Projection) がよく使われています。

物体は遠く離れると小さく見え、近づくと大きく見えます。この現象をシンプルに視点と対象物との間の光線として考え、光線がその中間に存在する投影面を通る位置で描画するというのが透視投影になります。

3D CG で使われる3次元の座標には 視点座標系とも言われる左手系と、ワールド座標系とも言われる右手系がありますが、本記事では左手系を採用しました。下の図にありますように、親指が X、人差し指が Y、中指が Z の方向になっています。

ここでの登場人物は

  • 物体のコンピューター上の概念での3次元の位置
  • 視点(コンピューターの中での概念上の視点ではなく実際に見ている人の視点)
  • そしてその中間に位置する投影面である 2D のスクリーンの位置

になります。左手系を使いますので、視点が原点 (0,0,0) になります。次に X軸の先から見ている状態を想像してみてください。それが下図になります。

横軸が奥行きの Z で、縦軸が高さの Y だけの関係になります。物体の位置は (y, z) で表しています。

投影面は原点から d の位置にあります。物体の頂点 y のスクリーン上での投影後の位置を yp とすると、三角形の相似比から、yp = d/z * y の関係が求まります。X 軸を省略して考えていますが、逆に Y を省略して考えても同様の関係になり、xp = d/z * x が求まります。

このように透視投影を使うと 3D の座標から単純な計算で 2D への座標変換が行えます。

PHP で実装してみよう

polygon.php というファイル名で作成し、冒頭は以下のようにします

    <?php declare(strict_types=1);
    error_reporting(E_ALL);

    const SCREEN_WIDTH = 640;
    const SCREEN_HEIGHT = 480;
    const SCREEN_DISTANCE = SCREEN_WIDTH;

スクリーンのサイズはとりあえず 640×480 とし、SCREEN_DISTANCE がスクリーンの原点からの距離で、これは使用者の顔から画面までの距離にもなります。座ってる姿勢で測ってみてもよいですが、ちょうどスクリーンのサイズくらい離れたところにすると臨場感もあり良いようですので SCREEN_WIDTH をそのまま距離にしました。

続いて 2D と 3D の座標を表現するベクターの構造を定義します。

    class Vec2
    {
        public float $x;
        public float $y;

        public function __construct($x, $y)
        {
            $this->x = $x;
            $this->y = $y;
        }
    }

    class Vec3
    {
        public float $x;
        public float $y;
        public float $z;

        public function __construct($x, $y, $z)
        {
            $this->x = $x;
            $this->y = $y;
            $this->z = $z;
        }
    }

float にした以外は特に特徴はないですねw 次に 3次元の物体の面の要素になる Polygon クラスを作ります。オブジェクトのコンストラクタには以下のように3次元座標の任意の個数の配列 $points を与えるようにしました。

    // Vec3 の座標を配列で与えて初期化
    // ポリゴンとして配列の順の点で線を引きます
    class Polygon
    {
        public array $vertices;

        public function __construct(array $points)
        {
            $this->vertices = $points;
        }

        //各種操作
    }

次に本記事のメインテーマの透視投影を行うメソッド Projection を定義します。前述した yp = d/z * y で計算してから、画面の中心が (0,0) ではなく左上が (0,0) で、かつ下に向かって y が大きくなる座標に変更します。

    public function Projection(Vec3 $point): Vec2 
    {
        // 3D の座標から透視投影する
        $xp = SCREEN_DISTANCE / $point->z * $point->x;
        $yp = SCREEN_DISTANCE / $point->z * $point->y;

        // 画面の中心が (0,0) から、左上が (0,0) の座標軸に変換
        $xp = SCREEN_WIDTH / 2 + $xp;
        $yp = SCREEN_HEIGHT / 2 - $yp;
        return new Vec2($xp, $yp);
    }

3D の点を 2D に投影するメソッドが出来ましたので、次にポリゴンの内の座標をすべて 2D に変換しながら線分を描画するメソッド Draw を定義します。

SDL の DrawLine にはレンダラーの指定が必要になりますので、引数として $renderer を足しています。

プロパティ vertices に格納されている点をすべて 2D に変換し配列 $sp に格納しています。

    public function Draw($renderer): void
    {
        $sp = [];  // スクリーン上の座標

        // スクリーン上の 2D 座標生成
        for ($i = 0; $i < count($this->vertices); $i++) {
            $sp[$i] = $this->Projection($this->vertices[$i]);
        }

線を引くには2点使いますが、頂点を結ぶ多角形として描画したいため、予め先頭の点を末尾に複製しておきます。

        // 末端の点として最初の点を再追加
        // 例:三角形の辺 p0 => p1 => p2 => p0 
        $sp[] = $sp[0];

あとは要素より1個少なくループさせ、$i$i+1 の間での線を引けば完了です。

        // 先頭から点の数-1個まで繰り返して辺を描く
        for ($i = 0; $i < count($sp) - 1; $i++) {
            SDL_RenderDrawLine($renderer, (int)$sp[$i]->x, (int)$sp[$i]->y,(int)$sp[$i+1]->x, (int)$sp[$i+1]->y);
        }
    }

以上で Polygon は完成です。とりあえず一番単純な三角形のデータ $triangle を作ります。

    // 三角形データ
    $a = new Vec3(0,100,1000);
    $b = new Vec3(100,-50,1000);
    $c = new Vec3(-100,-50,1000);
    $triangle = new Polygon([$a, $b, $c]);

あとは、前回記事と同様に、メインループと入力処理、描画本体の処理を記述します。ここでの処理本体は $triangle->Draw($renderer); の1行のみです。

    // SDL 初期化
    SDL_Init(SDL_INIT_VIDEO);
    $window = SDL_CreateWindow("PHP SDL で 3D", 
                                SDL_WINDOWPOS_UNDEFINED, 
                                SDL_WINDOWPOS_UNDEFINED, 
                                SCREEN_WIDTH, SCREEN_HEIGHT, 
                                SDL_WINDOW_SHOWN);
    $renderer = SDL_CreateRenderer($window, 0, SDL_RENDERER_ACCELERATED);

    // メインループ
    $event = new SDL_Event;
    $quit = false;
    while (!$quit) {
        // 未処理のイベントがある限り処理
        while (SDL_PollEvent($event)) {
            if ($event->type == SDL_MOUSEBUTTONDOWN || $event->type == SDL_QUIT){
                $quit = true;
            }
        }

        // クリアしたあと描画色を指定
        SDL_SetRenderDrawColor($renderer, 0xE0, 0xF8, 0xD0, 0xFF);
        SDL_RenderClear($renderer);
        SDL_SetRenderDrawColor($renderer, 0x08, 0x18, 0x20, 0xFF);

        // ==== 処理本体 =====
        $triangle->Draw($renderer);
        
        // フレームでまとめて描画
        SDL_RenderPresent($renderer);

        // 最速でも 60 FPS を超えないようにする
        SDL_Delay(intval(1000/60));
    }

    // 終了
    SDL_DestroyRenderer($renderer);
    SDL_DestroyWindow($window);
    SDL_Quit();

php polygon.php で実行します。無事ポリゴンが表示されました!

動かしてみる

このままだと 3D なのか 2D なのかわかりませんw 視点を動かしてみれば多少わかるかもしれないので、動かしてみましょう!

視点(原点)を動かすのも、座標系上の物体の方を動かすのも等価ですので、 今回はオフセットを別に保持して描画毎にそのオフセット分、物体をシフトして計算する方式を使います。グローバルに $offset をもたせます。

    // 視点と投射面(スクリーン)を動かす代わりに
    // 透視投影時にワールドの物体をこのオフセットを使って動かす
    $offset = new Vec3(0,0,0);

あとはマウスと同様入力処理のところでキー操作に応じて $offset を増減するようにします。変化量は色々ためして 10.0 にしてみました。また変更毎にウィンドウのタイトルに座標を表示するようにもしました。

        if ($event->type == SDL_MOUSEBUTTONDOWN || $event->type == SDL_QUIT){
            $quit = true;
        }
        if ($event->type == SDL_KEYDOWN){
            $keyboardState = SDL_GetKeyboardState($num);
            // 上下左右キーでカメラのオフセットを移動
            if($keyboardState[SDL_SCANCODE_UP]) {
                $offset->z = $offset->z + 10.0;
            }
            if($keyboardState[SDL_SCANCODE_DOWN]) {
                $offset->z = $offset->z - 10.0;
            }
            if($keyboardState[SDL_SCANCODE_RIGHT]) {
                $offset->x = $offset->x + 10.0;
            }
            if($keyboardState[SDL_SCANCODE_LEFT]) {
                $offset->x = $offset->x - 10.0;
            }
            $title = "Camera (" . $offset->x . "," . $offset->y . "," . $offset->z . ")";
            SDL_SetWindowTitle($window, $title);
        }

次に Projection でも $offset の分、物体の位置をシフトするように変更します。

    public function Projection(Vec3 $point, Vec3 $offset): Vec2 
    {
        // 3D の座標から透視投影する
        $xp = SCREEN_DISTANCE / ($point->z - $offset->z) * ($point->x - $offset->x);
        $yp = SCREEN_DISTANCE / ($point->z - $offset->z) * ($point->y - $offset->y);

呼び出す Draw 側も $offset を追加します。

    public function Draw($renderer, Vec3 $offset): void
    {
        $sp = [];  // スクリーン上の座標

        // スクリーン上の 2D 座標生成
        for ($i = 0; $i < count($this->vertices); $i++) {
            $sp[$i] = $this->Projection($this->vertices[$i], $offset);
        }

Draw を呼び出すメインループの本体も同様です。

    // 物体描画
    $triangle->Draw($renderer, $offset);

以上の変更で無事キー操作で三角形が動くようになりました!

四面体とキューブ

板状のポリゴンは完成していますので、次に四面体とキューブに挑戦してみます。

四面体 $tetrahedron は頂点 a,b,c,d を用意し、三角形のポリゴン4枚で、以下のデータにしました。

    // 四面体データ
    //      a
    //          d
    //   b    c
    $a = new Vec3(0,100,1000);
    $b = new Vec3(100,-50,1000);
    $c = new Vec3(-100,-50,1000);
    $d = new Vec3(0,-50,1100);
    $tetrahedron[] = new Polygon([$a, $b, $c]);
    $tetrahedron[] = new Polygon([$b, $c, $d]);
    $tetrahedron[] = new Polygon([$a, $b, $d]);
    $tetrahedron[] = new Polygon([$a, $c, $d]);

同様にキューブ $cube は頂点を p1 〜 p8 まで用意し、四角形を6枚使って定義しています。

    // 立方体データ
    //    2   4
    //   1   3
    //    6   8
    //   5   7
    $p1 = new Vec3(-200,100,600);
    $p2 = new Vec3(-200,100,800);
    $p3 = new Vec3(0,100,600);
    $p4 = new Vec3(0,100,800);
    $p5 = new Vec3(-200,-100,600);
    $p6 = new Vec3(-200,-100,800);
    $p7 = new Vec3(0,-100,600);
    $p8 = new Vec3(0,-100,800);
    $cube[] = new Polygon([$p1, $p2, $p4, $p3]);  
    $cube[] = new Polygon([$p1, $p2, $p6, $p5]);  
    $cube[] = new Polygon([$p2, $p4, $p8, $p6]);  
    $cube[] = new Polygon([$p3, $p4, $p8, $p7]);  
    $cube[] = new Polygon([$p1, $p3, $p7, $p5]);  
    $cube[] = new Polygon([$p5, $p6, $p8, $p7]);  

データを用意するのは量が多いので大変ですww

描画の本体はいたって簡単で、foreach で取りだした面の要素のポリゴン毎に Draw を実行するのみとなります。

    // 物体描画
    foreach ($tetrahedron as $polygon) {
        $polygon->Draw($renderer, $offset);
    }
    foreach ($cube as $polygon) {
        $polygon->Draw($renderer, $offset);
    }

では動かしてみましょう!

まとめ

一見難しそうな 3D 表示ですが、コア部分は意外にもシンプルな数式で構成されているところを感じとっていただけると幸いです!

PHP と SDL を使ったグラフィックスですが、プリミティブなところでいろいろ試すのは大変面白く、今後もいろいろ追求してみたいと思います!

そして弊社では、PHP で面白いことをいろいろやってみたい!という方の応募を絶賛お待ちしていますー!

ブログ記事検索

このブログについて

このブログは、札幌市・仙台市の「株式会社インフィニットループ」が運営する技術ブログです。 お仕事で使えるITネタを社員たちが発信します!