1. 程式人生 > >系列教程(1):學習如何用C#編寫一個軟渲染引擎

系列教程(1):學習如何用C#編寫一個軟渲染引擎

宣告:轉載請註明出處!!!本人摘錄其中C#的部分進行翻譯,同時捨棄了其中一些無關緊要的話。另外,英語水平渣,見諒。

由於這位大神是用的win8和XAML,而本人是win7,沒法進行實踐。所以自己用普通的WinForm寫了一個上傳到了github。

地址:https://github.com/dx50075/SoftEngine.git


系列教程:學習如何用C#編寫一個軟渲染引擎

這個系列教程中,我將與大家分享我是如何學習並構建一個眾所周知的 “3D soft engine”。 “Soft engine”意味著我們用一種僅僅使用CPU的古老方式來構建一個3D引擎。

那麼為什麼要構建一個“3D soft engine

”?這是因為它真的能很好的幫助我們理解3D模型是如何在GPU上運作的。當我年輕的時候,我也夢想著能編寫這樣一個引擎,但是我覺得它對我來說太難了。但是最後,你會發現並沒有那麼難。你只不過需要一個人去幫助你用一種簡單方式去理解相關的原則、概念和規則。

通過這個系列教程,你將學會如何投影一個3D座標到與之關聯的2D螢幕座標,如何在不同的點之間畫線,如何填充三角形,處理光照,材質等。本篇第一個教程將簡單的展示Cube8個頂點以及如何將他們移動到虛擬3D世界。

這個系列教程的各部分如下:

1、編寫camerameshdevice object的核心邏輯(本篇教程)

2、畫線和三角形來得到一個線框渲染

3、載入BlenderJson格式匯出的Meshes

4、光柵化三角形並使用Z-Buffer

5、Flat著色和Gouraud著色處理光照

6、運用紋理,背面消隱和WebGL

如果你跟著完成這個系列教程,你將知道如何構建屬於自己的3D Soft Engine!你的引擎將擁有線框渲染,光柵化,gouraud著色,運用紋理等功能

閱讀前置條件

我考慮如何寫這個教程很久了。最後決定不由我來解釋所有需要的知識概念等。網上有太多比我解釋的更好的資源來解釋那些重要的知識概念。但我仍然花了一些時間來收集一些供大家選擇

Tutorial 3 : Matrices that will provide you an introduction to matrices, the model, view & projection matrices. 

Cameras on OpenGL ES 2.x – The ModelViewProjection Matrix : this one is really interesting also as it explains the story starting by how cameras and lenses work. 

A brief introduction to 3D: an excellent PowerPoint slides deck ! Read at least up to slide 27. After that, it’s too linked to a technology talking to GPU (OpenGL or DirectX). 

閱讀這些文章不關注相關一些技術(OpenGL或者DirectX)或三角形的概念,這些我們將會在後面見到。

通過閱讀這些文章,你需要明白,有一系列的轉換需要完成:

1、我們以1箇中心位於物體本身中心的3D物體開始(原諒我的渣英語水平)

2、通過矩陣的平移、縮放、旋轉操作移動物體到3D虛擬世界

3、通過一個相機在3D世界座標中去看這個3D物體

4、最後投影到你的螢幕2D空間

所有這些魔法般的變換都是通過矩陣操作來完成。在進行這個系列教程之前,你真的應該至少有一點點熟悉這些概念。如果你不瞭解這些東西,你第一時間應該去了解。否則在你後面自己編寫3D soft engine的時候,你很可能也會回過頭來去讀那些文章。這很正常,別擔心!

學習3D最好的方式就是不斷試驗和犯錯。

我們將不再花時間在矩陣如何運作上面了。好訊息是其實你不必真的去了解矩陣。把它看作是一個做了正確操作的黑盒子吧。我也不是矩陣方面的大師,但我能自己編寫3D soft engine。因此你們也能成功。

我們將使用一些庫來完成我們的工作:SharpDX,一個供C#開發者使用的DirectX頂層封裝庫。

軟體需求

1、1 - Windows 82 - Visual Studio 2012 Express for Windows Store Apps. You can download it for free: http://msdn.microsoft.com/en-US/windows/apps/br211386

請建立一個名為“SoftEngine”的工程,通過NeGet新增“SharpDX core assembly

Back Buffer & Rendering loop

在一個3D引擎中,我們在每幀中渲染完整的場景,並希望保持一個最佳的60fps流暢畫面。為了完成我們的渲染工作,我們需要back buffer。它可以視為一個和螢幕/視窗大小一致的二維陣列。

陣列的每一個單元對應著螢幕上的一個畫素。

在我們的XAML Windows Store Apps中,我們將使用 byte[]來表示動態的 back bufferFor every frame being rendered in the animation loop (tick), this buffer will be affected to aWriteableBitmap acting as the source of a XAML image control that will be called the front buffer. For the rendering loop, we’re going to ask to the XAML rendering engine to call us for every frame it will generate. The registration is done thanks to this line of code: (怕翻譯的不準,見諒)

CompositionTarget.Rendering += CompositionTarget_Rendering;

Camera & Mesh Objects

我們開始編碼。首先,我們定義一些物件來描述相機和網格的細節。網格是一個很酷的用來描述3D物體的名字。

Camera將擁有2個屬性:它在世界中的位置和它觀察的目標。它們都是用3D座標Vector3來描述。C#將用SharpDX.Vector3表示。

Mesh將含有頂點集合來組建3D物件,它在世界中的位置和它的旋轉狀態。

// Camera.cs & Mesh.cs
using SharpDX;namespace SoftEngine
{
    public class Camera
    {
        public Vector3 Position { get; set; }
        public Vector3 Target { get; set; }
    }
    public class Mesh
    {
        public string Name { get; set; }
        public Vector3[] Vertices { get; private set; }
        public Vector3 Position { get; set; }
        public Vector3 Rotation { get; set; }

        public Mesh(string name, int verticesCount)
        {
            Vertices = new Vector3[verticesCount];
            Name = name;
        }
    }
}



例如,如果你想通過Mesh物件來表示一個Cube,你需要建立與cube相關的8個頂點。下面是在Blendercube的座標展示:

採用了左手座標系。記住當你建立一個mesh,座標系統原點在mesh中心。因此x=0,y=0,z=0在cube的中心。

程式碼如下:

var mesh = new Mesh("Cube", 8);
mesh.Vertices[0] = new Vector3(-1, 1, 1);
mesh.Vertices[1] = new Vector3(1, 1, 1);
mesh.Vertices[2] = new Vector3(-1, -1, 1);
mesh.Vertices[3] = new Vector3(-1, -1, -1);
mesh.Vertices[4] = new Vector3(-1, 1, -1);
mesh.Vertices[5] = new Vector3(1, 1, -1);
mesh.Vertices[6] = new Vector3(1, -1, 1);
mesh.Vertices[7] = new Vector3(1, -1, -1);


最重要的部分:the Device object(學過圖形學的應該都知道這個東西吧)

既然我們已經有了基本知識並知道如何構建3d mesh,我們將需要最重要的部分:the device object。它是我們3D引擎的核心。

在渲染函式中,我們將構造基於camera的View matrix和projection matrix。

然後,我們遍歷可訪問的mesh並構造他們平移,旋轉,縮放的world matrix。最後的變換矩陣如下:

var transformMatrix = worldMatrix * viewMatrix * projectionMatrix;

通過閱讀之前的先決條件的資源,這是你絕對需要理解的概念。否則,你將很可能簡單的複製/貼上程式碼而不瞭解其原理。這對接下來的教程來講並不算太大的問題,但再次宣告,你最好先了解這些原理再開始編碼。

通過這個變換矩陣,我們將通過mesh每個頂點的x,y,z座標來得到2d座標x,y。為了最後繪製在螢幕上,我們在PutPixel函式中新增一段邏輯來顯示可見畫素。

下面是Device object的程式碼。我會註釋程式碼來幫助大家儘可能的理解。

using Windows.UI.Xaml.Media.Imaging;
using System.Runtime.InteropServices.WindowsRuntime;
using SharpDX;
namespace SoftEngine
{
    public class Device
    {
        private byte[] backBuffer;
        private WriteableBitmap bmp;

        public Device(WriteableBitmap bmp)
        {
            this.bmp = bmp;
            // the back buffer size is equal to the number of pixels to draw
            // on screen (width*height) * 4 (R,G,B & Alpha values). 
            backBuffer = new byte[bmp.PixelWidth * bmp.PixelHeight * 4];
        }

        // This method is called to clear the back buffer with a specific color
        public void Clear(byte r, byte g, byte b, byte a) {
            for (var index = 0; index < backBuffer.Length; index += 4)
            {
                // BGRA is used by Windows instead by RGBA in HTML5
                backBuffer[index] = b;
                backBuffer[index + 1] = g;
                backBuffer[index + 2] = r;
                backBuffer[index + 3] = a;
            }
        }

        // Once everything is ready, we can flush the back buffer
        // into the front buffer. 
        public void Present()
        {
            using (var stream = bmp.PixelBuffer.AsStream())
            {
                // writing our byte[] back buffer into our WriteableBitmap stream
                stream.Write(backBuffer, 0, backBuffer.Length);
            }
            // request a redraw of the entire bitmap
            bmp.Invalidate();
        }

        // Called to put a pixel on screen at a specific X,Y coordinates
        public void PutPixel(int x, int y, Color4 color)
        {
            // As we have a 1-D Array for our back buffer
            // we need to know the equivalent cell in 1-D based
            // on the 2D coordinates on screen
            var index = (x + y * bmp.PixelWidth) * 4;

            backBuffer[index] = (byte)(color.Blue * 255);
            backBuffer[index + 1] = (byte)(color.Green * 255);
            backBuffer[index + 2] = (byte)(color.Red * 255);
            backBuffer[index + 3] = (byte)(color.Alpha * 255);
        }

        // Project takes some 3D coordinates and transform them
        // in 2D coordinates using the transformation matrix
        public Vector2 Project(Vector3 coord, Matrix transMat)
        {
            // transforming the coordinates
            var point = Vector3.TransformCoordinate(coord, transMat);
            // The transformed coordinates will be based on coordinate system
            // starting on the center of the screen. But drawing on screen normally starts
            // from top left. We then need to transform them again to have x:0, y:0 on top left.
            var x = point.X * bmp.PixelWidth + bmp.PixelWidth / 2.0f;
            var y = -point.Y * bmp.PixelHeight + bmp.PixelHeight / 2.0f;
            return (new Vector2(x, y));
        }

        // DrawPoint calls PutPixel but does the clipping operation before
        public void DrawPoint(Vector2 point)
        {
            // Clipping what's visible on screen
            if (point.X >= 0 && point.Y >= 0 && point.X < bmp.PixelWidth && point.Y < bmp.PixelHeight)
            {
                // Drawing a yellow point
                PutPixel((int)point.X, (int)point.Y, new Color4(1.0f, 1.0f, 0.0f, 1.0f));
            }
        }

        // The main method of the engine that re-compute each vertex projection
        // during each frame
        public void Render(Camera camera, params Mesh[] meshes)
        {
            // To understand this part, please read the prerequisites resources
            var viewMatrix = Matrix.LookAtLH(camera.Position, camera.Target, Vector3.UnitY);
            var projectionMatrix = Matrix.PerspectiveFovRH(0.78f, 
                                                           (float)bmp.PixelWidth / bmp.PixelHeight, 
                                                           0.01f, 1.0f);

            foreach (Mesh mesh in meshes) 
            {
                // Beware to apply rotation before translation 
                var worldMatrix = Matrix.RotationYawPitchRoll(mesh.Rotation.Y, 
                                                              mesh.Rotation.X, mesh.Rotation.Z) * 
                                  Matrix.Translation(mesh.Position);

                var transformMatrix = worldMatrix * viewMatrix * projectionMatrix;

                foreach (var vertex in mesh.Vertices)
                {
                    // First, we project the 3D coordinates into the 2D space
                    var point = Project(vertex, transformMatrix);
                    // Then we can draw on screen
                    DrawPoint(point);
                }
            }
        }
    }
}


整合所有程式碼

我們最後需要建立一個mesh。建立一個camera並看向mesh。初始化Device object

一旦完成,我們將啟動動畫/渲染迴圈。在最佳情況下,迴圈沒16ms呼叫一次(60fps)。在每次迴圈中,我們將執行如下邏輯:

1、清除螢幕所有相關的畫素並用黑色填充(Clear() function)

2、更新meshes 各頂點的位置和旋轉

3、通過必要的矩陣操作渲染到back buffer(Render() function)

4、Flush back buffer的資料到front buffer並顯示到螢幕 (Present() function)

private Device device;
Mesh mesh = new Mesh("Cube", 8);
Camera mera = new Camera();private void Page_Loaded(object sender, RoutedEventArgs e)
{
    // Choose the back buffer resolution here
    WriteableBitmap bmp = new WriteableBitmap(640, 480);

    device = new Device(bmp);

    // Our XAML Image control
    frontBuffer.Source = bmp;

    mesh.Vertices[0] = new Vector3(-1, 1, 1);
    mesh.Vertices[1] = new Vector3(1, 1, 1);
    mesh.Vertices[2] = new Vector3(-1, -1, 1);
    mesh.Vertices[3] = new Vector3(-1, -1, -1);
    mesh.Vertices[4] = new Vector3(-1, 1, -1);
    mesh.Vertices[5] = new Vector3(1, 1, -1);
    mesh.Vertices[6] = new Vector3(1, -1, 1);
    mesh.Vertices[7] = new Vector3(1, -1, -1);

    mera.Position = new Vector3(0, 0, 10.0f);
    mera.Target = Vector3.Zero;

    // Registering to the XAML rendering loop
    CompositionTarget.Rendering += CompositionTarget_Rendering;
}// Rendering loop handlervoid CompositionTarget_Rendering(object sender, object e)
{
    device.Clear(0, 0, 0, 255);

    // rotating slightly the cube during each frame rendered
    mesh.Rotation = new Vector3(mesh.Rotation.X + 0.01f, mesh.Rotation.Y + 0.01f, mesh.Rotation.Z);

    // Doing the various matrix operations
    device.Render(mera, mesh);
    // Flushing the back buffer into the front buffer
    device.Present();
}


本教程示意圖:

 

在下個教程中,我們將學習如何在不同頂點之間畫線,下個教程示意圖:

 

-----------------------------------------------以上是翻譯-------------------------------------------------------------------------------

SharpDX在這個教程裡面,僅僅是充當數學庫的角色,我們完全可以不用它,感興趣的同學可以自己實現向量,矩陣的相關操作。強烈建議自己實現。

WinForm實現的版本:


後續如果時間允許,會繼續翻譯並完善這個引擎,可能也會用C++來實現。