C#で頂点変換をゼロから実装してリアルタイムレンダリングを勉強する

2023/12/24に公開

前書き

この記事はAkatsuki Games Advent Calendar 2023 24日目の記事です。
23日目の記事はyasuさんの「credoにオプションを追加してspecを自動補完させたい」でした。

今回やること

今回はリアルタイムレンダリングの勉強のために、ソフトウェアラスタライザを目指して実装を進めていました。今のところそこまでは行けてないですが、とりあえず頂点変換まではできたので、そこまでの内容となります。

objファイルをロードし、その3Dモデルの頂点の座標からスクリーン上のどのピクセルに対応するかを計算し、その結果を画像として出力するまでをやります。
以下の画像が今回最終的に得られる画像になります。

また、今回書いたコードは以下のリポジトリにあります。

https://github.com/sakastudio/VertexTransform/tree/master/VertexTransform

事前準備

プロジェクトの作成

今回はゼロから実装することがコンセプトなので、Riderを使用して空のコンソールアプリケーションのプロジェクトを作成しました。

標準ライブラリ以外何も入っていないので、ここからいろいろ追加して実装していきます。

頂点を扱うクラスの作成

1つの頂点データを扱いやすくする簡単なクラスを作成します。
頂点変換の過程でどのようなデータが入るかを表しています。

public class SRVertex
{
    public Vector4 ModelPosition;
    public Vector4 WorldPosition;
    public Vector4 ViewPosition;
    public Vector4 ClipPosition;
    public Vector2Int ScreenPosition; 
        
    public int VertexIndex;
}

ライブラリのインポート

今回はゼロから実装がコンセプトですが、objファイルのロードや画像の出力まで独自実装しては大変なので、その部分はライブラリにお任せします。

画像出力

画像出力にはImageSharpというライブラリを使用します。nugetで公開されているため、nugetでインストールします。
https://github.com/SixLabors/ImageSharp

また、使いやすいように、色の2次元配列を渡して画像を出力できるようにラップします、

public class ImageExporter
{
    public static void ExportImage(Rgba32[,] pixels, string path)
    {
        var width = pixels.GetLength(0);
        var height = pixels.GetLength(1);
        
        using var image = new Image<Rgba32>(width,height);

        for (var x = 0; x < width; x++)
        {
            for (var y = 0; y < height; y++) 
            {
                image[x, y] = pixels[x, y];
            }
        }

        //export
        image.Save(path);
        
        Console.WriteLine("画像を出力しました :" + Path.GetFullPath(path));
    }
}

objファイルのロード

objファイルのロードにはObjLoaderというライブラリを使用します。こちらもnugetでインストールします。
https://github.com/chrisjansson/ObjLoader

こちらも、簡単にobjファイルをロードして頂点データを得られるようにラップします。
このメソッドでは頂点データと、どの頂点同士がつながっているかを表す面のデータの2つのデータをロードします。
今回の主題では面は使いませんが、最終的にラスタライズするときに必要になるのでここで返しておきます。


public class ObjLoader
{
    public static (Dictionary<int, Vertex> vertices, List<List<int>> faces) LoadVertex(string path)
    {
        var obj = LoadObj(path);

        var vertices = LoadVertices(obj);
        
        var faces = LoadFaces(obj);
        
        return (vertices,faces);
    }

    private static LoadResult LoadObj(string path)
    {
        Console.WriteLine("LoadObj FullPath:" + Path.GetFullPath(path));
        
        var objLoaderFactory = new ObjLoaderFactory();
        return objLoaderFactory.Create().Load(new FileStream(path, FileMode.Open));
    }
    
    private static Dictionary<int,Vertex> LoadVertices(LoadResult obj)
    {
        var vertices = new Dictionary<int,Vertex>();

        for (var i = 0; i < obj.Vertices.Count; i++)
        {
            var vertex = obj.Vertices[i];
            vertices.Add(i+1, new Vertex()
            {
                VertexIndex = i+1,
                ModelPosition = new Vector4 
                {
                    X = vertex.X,
                    Y = vertex.Y,
                    Z = vertex.Z,
                    W = 1
                },
            });
        }

        return vertices;
    }
    
    private static List<List<int>> LoadFaces(LoadResult obj)
    {
        var faces = new List<List<int>>();

        foreach (var group in obj.Groups)
        {
            foreach (var face in group.Faces)
            {
                var faceIndexList = new List<int>();
                for (var j = 0; j < face.Count; j++)
                {
                    faceIndexList.Add(face[j].VertexIndex);
                }
                faces.Add(faceIndexList);
            }
        }

        return faces;
    }
}

頂点変換

事前準備が終わったので、実際に頂点変換をしていきます。
座標変換は以下の4ステップで行います。

  1. モデル座標変換
  2. ビュー座標変換
  3. プロジェクション座標変換
  4. スクリーン座標変換

説明が大変なので、今回はそれぞれでどのような座標変換が行われているか、どのような意味があるかなどは割愛します。

頂点変換の全体像

それぞれの変換をメソッドごとに分け、見通しをよくしました。
これら4つのメソッドが上記の4つのメソッド対応しており、各変換に必要なデータを渡しています。

    public static Dictionary<int, Vertex> ConvertVertex(Dictionary<int, Vertex> vertexDict,
        Vector3 objectPos, Vector3 objectRotateDegree, Vector3 objectScale,
        Vector3 cameraPos, Vector3 cameraTarget,
        Vector2Int screenSize)
    {
        var modelTransformed = ModelTransform(vertexDict, objectPos, objectRotateDegree, objectScale);
        
        var viewTransformed = ViewTransform(modelTransformed, cameraPos, cameraTarget);
        
        var clipTransformed = ClipTransform(viewTransformed, screenSize);
        
        var screenTransformed = ScreenTransform(clipTransformed, screenSize);
        
        return screenTransformed;
    }

モデル座標変換

まずはモデル座標変換です。
ここでは、3Dモデルの原点からの座標から、ワールド空間に配置した際の座標に変換します。

そのため、オブジェクトの設置座標、設置角度、大きさを設定します。

    private static Dictionary<int, Vertex> ModelTransform(Dictionary<int, Vertex> vertexDict, Vector3 objectPos, Vector3 objectRotateDegree, Vector3 objectScale)
    {
        // モデル変換行列(オブジェクト座標系からワールド座標系へ変換する)
        //http://marupeke296.sakura.ne.jp/DXG_No39_WorldMatrixInformation.html
        var posMatrix = new Matrix4x4(
            1, 0, 0, objectPos.X,
            0, 1, 0, objectPos.Y,
            0, 0, 1, objectPos.Z,
            0, 0, 0, 1
        );

        var objectRoteRadius = new Vector3(
            objectRotateDegree.X * (MathF.PI / 180),
            objectRotateDegree.Y * (MathF.PI / 180),
            objectRotateDegree.Z * (MathF.PI / 180)
        );
        
        //回転行列をどうやって表すのか
        // https://rikei-tawamure.com/entry/2019/11/04/184049
        var rotXMatrix = new Matrix4x4(
            1, 0, 0, 0,
            0, cos(objectRoteRadius.X), -sin(objectRoteRadius.X), 0,
            0, sin(objectRoteRadius.X), cos(objectRoteRadius.X), 0,
            0, 0, 0, 1
        );
        var rotYMatrix = new Matrix4x4(
            cos(objectRoteRadius.Y), 0, sin(objectRoteRadius.Y), 0,
            0, 1, 0, 0,
            -sin(objectRoteRadius.Y), 0, cos(objectRoteRadius.Y), 0,
            0, 0, 0, 1
        );
        var rotZMatrix = new Matrix4x4(
            cos(objectRoteRadius.Z), -sin(objectRoteRadius.Z), 0, 0,
            sin(objectRoteRadius.Z), cos(objectRoteRadius.Z), 0, 0,
            0, 0, 1, 0,
            0, 0, 0, 1
        );


        var scaleMatrix = new Matrix4x4(
            objectScale.X, 0, 0, 0,
            0, objectScale.Y, 0, 0,
            0, 0, objectScale.Z, 0,
            0, 0, 0, 1
        );

        var a = posMatrix * rotYMatrix;
        var b = a * rotXMatrix;
        var c = b * rotZMatrix;
        var modelMatrix = c * scaleMatrix;

        foreach (var teaPodPoint in vertexDict.Values)
        {
            teaPodPoint.WorldPosition = MatrixUtil.Multi(modelMatrix, teaPodPoint.ModelPosition);
        }
        
        return vertexDict;
    }

ビュー座標変換

次にビュー変換です。ここではワールド座標系になった頂点の座標を、カメラを原点とする座標に変換します。

そのため、カメラの位置と、カメラが向いている方向を渡します。

    private static Dictionary<int, Vertex> ViewTransform(Dictionary<int, Vertex> vertexDict ,Vector3 cameraPos, Vector3 cameraTarget)
    {
        // ビュー変換行列 (ワールド座標からカメラ座標への変換)
        // https://yttm-work.jp/gmpg/gmpg_0003.html
        //http://marupeke296.com/DXG_No72_ViewProjInfo.html
        var cameraUp = new Vector3(0, 1, 0);

        var forward = Vector3Util.Normalize(cameraTarget - cameraPos);
        var right = Vector3Util.Normalize(Vector3.Cross(cameraUp, forward));
        var up = Vector3.Cross(forward, right);

        var viewMatrix = new Matrix4x4(
            right.X, right.Y, right.Z, -Vector3.Dot(right, cameraPos),
            up.X, up.Y, up.Z, -Vector3.Dot(up, cameraPos),
            forward.X, forward.Y, forward.Z, -Vector3.Dot(forward, cameraPos),
            0, 0, 0, 1);

        foreach (var teaPodPoint in vertexDict.Values)
        {
            teaPodPoint.ViewPosition = MatrixUtil.Multi(viewMatrix, teaPodPoint.WorldPosition);
        }
        
        return vertexDict;
    }

プロジェクション座標変換

次にプロジェクション変換です。これはカメラの画角をベースに3D空間から2D空間へ投影します。

    private static Dictionary<int, Vertex> ProjectionTransform(Dictionary<int, Vertex> vertexDict, Vector2Int screenSize)
    {
        //プロジェクション座標変換行列(カメラ座標からクリップ座標への変換)
        //https://yttm-work.jp/gmpg/gmpg_0004.html
        //http://marupeke296.com/DXG_No70_perspective.html
        const float viewAngle = 100 * (MathF.PI / 180);
        const float cameraNear = 0.1f;
        const float cameraFar = 100;
        var aspectRate = (float)screenSize.X / screenSize.Y;


        var perspectiveMatrix = new Matrix4x4(
            1 / (float)Math.Tan(viewAngle / 2) / aspectRate, 0, 0, 0,
            0, 1 / (float)Math.Tan(viewAngle / 2), 0, 0,
            0, 0, 1 / (cameraFar - cameraNear) * cameraFar, 1,
            0, 0, -cameraNear / (cameraFar - cameraNear) * cameraFar, 0
        );

        foreach (var teaPodPoint in vertexDict.Values)
        {
            teaPodPoint.ClipPosition = MatrixUtil.Multi(perspectiveMatrix, teaPodPoint.ViewPosition);
                
            //正規化デバイス座標系変換
            teaPodPoint.ClipPosition.X /= teaPodPoint.ClipPosition.W;
            teaPodPoint.ClipPosition.Y /= teaPodPoint.ClipPosition.W;
            teaPodPoint.ClipPosition.Z /= teaPodPoint.ClipPosition.W;
            teaPodPoint.ClipPosition.W /= teaPodPoint.ClipPosition.W;
        }
        return vertexDict;
    }

スクリーン座標変換

最後に、プロジェクション座標系から表示するスクリーン座標に変換します。
これで、必要な変換が終わりました。

    private static Dictionary<int, Vertex> ScreenTransform(Dictionary<int, Vertex> vertexDict, Vector2Int screenSize)
    {
        //スクリーン座標変換
        foreach (var teaPodPoint in vertexDict.Values)
        {
            var x = (int)((teaPodPoint.ClipPosition.X + 1) * screenSize.X / 2);
            var y = (int)((teaPodPoint.ClipPosition.Y + 1) * screenSize.Y / 2);
            teaPodPoint.ScreenPosition = new Vector2Int(x, y);
        }
        return vertexDict;
    }

画像を出力する

上記のスクリーン座標変換でその頂点がどの座標にあるかを計算することができたので、最終的な画像に変換します。
とりあえず今は頂点があるピクセルは白、それ以外のピクセルを黒で塗りつぶします。

        var pixels = new Rgba32[width, height];
        for (int i = 0; i < width; i++)
        {
            for (int j = 0; j < height; j++)
            {
                pixels[i, j] = new Rgba32(0, 0, 0);
            }
        }
        
        var path = "step1.png";
        foreach (var vertex in convertedVertex.Values)
        {
            var point = vertex.ScreenPosition;
            var x = point.X;
            var y = point.Y;
            
            if (x < 0 || x >= width || y < 0 || y >= height) continue;
            
            pixels[x, y] = new Rgba32(255, 255, 255);
        }

        
        //画像出力
        ImageExporter.ExportImage(pixels, path);
        
        
        //画像ファイルを開く
        using var ps1 = new Process();
        ps1.StartInfo.UseShellExecute = true;
        ps1.StartInfo.FileName = path;
        ps1.Start();

これで、以下のような画像が出力されます。

モデル座標変換で3Dモデルの座標を設定してるため、その値を変更するとそれに応じて頂点の位置も変わります。

まとめ

今回はリアルタイムレンダリングの勉強を兼ねて頂点変換を独自実装しました。普段Unityを触っているとなかなか触ることがないので、中でどんなことが行われているのかを知れるいい機会になりました。

最初はラスタライズまで目指して作っていたのですが、深度値周りがうまくいかず断念しました。(このコードも入っています)

また気が向いたら今後は最後まで作りたいですね。

最後に、アカツキゲームスでは一緒に働くエンジニアを募集しています。
カジュアル面談もやっていますので、気軽にご応募ください。
応募はここから:https://herp.careers/v1/aktskgames/requisition-groups/47f46396-e08a-4b2f-8f9b-b2fc79e63b83

Discussion