实现一个简单的软光栅

0. 前言

网上都说入门图形学需要手写一个软光栅。
虽然我接触dx挺久了,去年也使用miniEngine实践了一下dx12的龙书。但总感觉还是什么都不会。
网上搜索了很多图形学的东西,于是决定写一个简单的软光栅,把零散的知识点串一下。

参考文章:
如何开始用 C++ 写一个光栅化渲染器?
想用C++实现一个软件渲染器

参考源码:
tinyrenderer

1. 创建一个win32窗口

这一步非常的简单。参照官方代码即可。
创建win32窗口

#include 

LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam);
int WINAPI WinMain(_In_ HINSTANCE hInstance, _In_opt_ HINSTANCE hPrevInstance,
    _In_ LPSTR lpCmdLine, _In_ int nShowCmd)
{
    // 1 create win32 application
    // "https://docs.microsoft.com/zh-cn/previous-versions/visualstudio/visual-studio-2008/bb384843(v%3dvs.90)"
    // 1.1 register class
    WNDCLASSEX wcex = { 0 };
    wcex.cbSize = sizeof(WNDCLASSEX);
    wcex.style = CS_HREDRAW | CS_VREDRAW;
    wcex.lpfnWndProc = WndProc;
    wcex.hInstance = hInstance;
    wcex.hIcon = LoadIcon(hInstance, MAKEINTRESOURCE(IDI_APPLICATION));
    wcex.hCursor = LoadCursor(NULL, IDC_ARROW);
    wcex.hbrBackground = (HBRUSH)(COLOR_WINDOW + 1);
    wcex.lpszClassName = L"simpleSoftRender";
    wcex.hIconSm = LoadIcon(wcex.hInstance, MAKEINTRESOURCE((WORD)IDI_APPLICATION));
    if (!RegisterClassEx(&wcex))
    {
        MessageBox(NULL,
            L"Call to RegisterClassEx failed!",
            L"Win32 Guided Tour",
            NULL);

        return 1;
    }

    // 1.2 create window
    HWND hWnd = CreateWindow(
        L"simpleSoftRender",
        L"simpleSoftRender",
        WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU,
        CW_USEDEFAULT, CW_USEDEFAULT,
        800, 600,
        NULL,
        NULL,
        hInstance,
        NULL
    );
    if (!hWnd)
    {
        MessageBox(NULL,
            L"Call to CreateWindow failed!",
            L"Win32 Guided Tour",
            NULL);

        return 1;
    }

    // 1.3 show window
    ShowWindow(hWnd, nShowCmd);
    UpdateWindow(hWnd);

    // 1.4 start message loop
    MSG msg = {};
    while (msg.message != WM_QUIT)
    {
        if (PeekMessage(&msg, nullptr, 0, 0, PM_REMOVE))
        {
            TranslateMessage(&msg);
            DispatchMessage(&msg);
        }
    }

    return (int)msg.wParam;
}

LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam)
{
    switch (message)
    {
    case WM_DESTROY:
        PostQuitMessage(0);
        break;
    default:
        return DefWindowProc(hWnd, message, wParam, lParam);
        break;
    }

    return 0;
}

需要注意的是,项目属性,要设置为窗口
实现一个简单的软光栅_第1张图片

直接编译运行:
实现一个简单的软光栅_第2张图片

2. 取出窗口缓冲 添加背景色

对于win32上的简单绘制,很多时候会使用gdi,这里为了更好的学习,采用直接取出渲染缓冲区的办法。
直接上代码
创建一个renderer.h文件

#pragma once
#include 

namespace SoftRender
{
    int g_width = 0;
    int g_height = 0;

    HDC g_tempDC = nullptr;
    HBITMAP g_tempBm = nullptr;
    HBITMAP g_oldBm = nullptr;
    unsigned int* g_frameBuff = nullptr;
    std::shared_ptr g_depthBuff = nullptr;

    unsigned int bgColor = ((123 << 16) | (195 << 8) | 221);

    // 初始化渲染器 屏幕长宽 屏幕缓冲
    void initRenderer(int w, int h, HWND hWnd);
    // 每帧绘制
    void update(HWND hWnd);
    // 清理屏幕缓冲
    void clearBuffer();
    void shutDown();
}

void SoftRender::initRenderer(int w, int h, HWND hWnd)
{
    g_width = w;
    g_height = h;

    // 1. 创建一个屏幕缓冲
    // 1.1 创建一个与当前设备兼容的DC
    HDC hDC = GetDC(hWnd);
    g_tempDC = CreateCompatibleDC(hDC);
    ReleaseDC(hWnd, hDC);
    // 1.2 创建该DC的bitmap缓冲  32位色
    BITMAPINFO bi = { { sizeof(BITMAPINFOHEADER), w, -h, 1, 32, BI_RGB,
        (DWORD)w * h * 4, 0, 0, 0, 0 } };
    g_tempBm = CreateDIBSection(g_tempDC, &bi, DIB_RGB_COLORS, (void**)&g_frameBuff, 0, 0);
    // 1.3 选择该bitmap到dc中
    g_oldBm = (HBITMAP)SelectObject(g_tempDC, g_tempBm);
    // 1.4 创建深度缓冲区
    g_depthBuff.reset(new float[w * h]);

    // 清理屏幕缓冲
    clearBuffer();
}

void SoftRender::update(HWND hWnd)
{
    // 1. clear frameBuffer
    clearBuffer();

    // present frameBuffer to screen
    HDC hDC = GetDC(hWnd);
    BitBlt(hDC, 0, 0, g_width, g_height, g_tempDC, 0, 0, SRCCOPY);
    ReleaseDC(hWnd, hDC);
}

void SoftRender::clearBuffer()
{
    for (int row = 0; row < g_height; ++row)
    {
        for (int col = 0; col < g_width; ++col)
        {
            int idx = row * g_width + col;
            // 默认背景色浅蓝 R123 G195 B221
            g_frameBuff[idx] = bgColor;
            // 深度缓冲区 1.0f
            g_depthBuff[idx] = 1.0f;
        }
    }
}

void SoftRender::shutDown()
{
    if (g_tempDC)
    {
        if (g_oldBm)
        {
            SelectObject(g_tempDC, g_oldBm);
            g_oldBm = nullptr;
        }
        DeleteDC(g_tempDC);
        g_tempDC = nullptr;
    }

    if (g_tempBm)
    {
        DeleteObject(g_tempBm);
        g_tempBm = nullptr;
    }
}

在main.cpp中添加代码

    // renderer init
    SoftRender::initRenderer(800, 600, hWnd);

    // 1.4 start message loop
    MSG msg = {};
    while (msg.message != WM_QUIT)
    {
        if (PeekMessage(&msg, nullptr, 0, 0, PM_REMOVE))
        {
            TranslateMessage(&msg);
            DispatchMessage(&msg);
        }
        else
        {
            SoftRender::update(hWnd);
        }
    }

    SoftRender::shutDown();
    return (int)msg.wParam;

编译运行
实现一个简单的软光栅_第3张图片

3. 建立世界坐标系

这里呢,实际上不需要代码。3维坐标系分为左手和右手坐标系。 dx默认是左手,opengl默认右手。我们这里采用左手坐标系

4. 建模并计算modelToWorld

每个模型都有自己的模型坐标系。毕竟我们制作模型时不会说直接在世界坐标系中做。
比如在建模软件中,我们使用模型的中心点作为原点,而一个模型包含很多的顶点。当我们把这个模型放到世界上时,需要把所有顶点都转换到世界坐标系中。
这就是modelToWorld转换矩阵。

参照dx12龙书155页。
modelToWorld = S * R * T
本例中,该模型原点就位于世界坐标系原点,也没有旋转缩放,所以该转换矩阵就是单位矩阵。

同时呢,我们采用齐次坐标来做变换。龙书58页

写一个极简的线性数学库,并以世界坐标系原点建一个立方体模型:

// 向量矩阵操作
namespace SoftRender
{
    // 点 向量
    struct Vector4
    {
        float x;
        float y;
        float z;
        float w;
    };

    // 矩阵
    struct Matrix
    {
        float m[4][4];
    };

    // 顶点
    struct vertex
    {
        Vector4 point;
        Vector4 color;
    };

    // 规范化
    Vector4 normallize(const Vector4& v)
    {
        float len = (float)sqrt(v.x * v.x + v.y * v.y + v.z * v.z);
        return { v.x / len, v.y / len, v.z / len, 0.0f };
    }

    // 叉积
    Vector4 cross(const Vector4& u, const Vector4& v)
    {
        return { u.y * v.z - u.z * v.y, u.z * v.x - u.x * v.z, u.x * v.y - u.y * v.x, 0.0f };
    }

    // 点积
    float dot(const Vector4& u, const Vector4& v)
    {
        return u.x * v.x + u.y * v.y + u.z * v.z;
    }

    // 矩阵乘法
    Matrix mul(const Matrix& a, const Matrix& b)
    {
        Matrix r;
        for (int i = 0; i < 4; ++i)
        {
            for (int j = 0; j < 4; ++j)
            {
                r.m[i][j] = a.m[i][0] * b.m[0][j]
                    + a.m[i][1] * b.m[1][j]
                    + a.m[i][2] * b.m[2][j]
                    + a.m[i][3] * b.m[3][j];
            }
        }
        return r;
    }

    // 点/向量转换
    Vector4 transform(const Vector4& v, const Matrix& m)
    {
        Vector4 r;
        r.x = v.x * m.m[0][0] + v.y * m.m[1][0] + v.z * m.m[2][0] + v.w * m.m[3][0];
        r.y = v.x * m.m[0][1] + v.y * m.m[1][1] + v.z * m.m[2][1] + v.w * m.m[3][1];
        r.z = v.x * m.m[0][2] + v.y * m.m[1][2] + v.z * m.m[2][2] + v.w * m.m[3][2];
        r.w = v.x * m.m[0][3] + v.y * m.m[1][3] + v.z * m.m[2][3] + v.w * m.m[3][3];
        return r;
    }
    
    // 目标立方体8个顶点 摄像机方向
    std::vector vertexes = {
        // 近相机面
        {{-1.0f, +1.0f, -1.0f, 1.0f}, {1.0f, 0.0f, 0.0f, 0.0f}},
        {{+1.0f, +1.0f, -1.0f, 1.0f}, {0.0f, 1.0f, 0.0f, 0.0f}},
        {{+1.0f, -1.0f, -1.0f, 1.0f}, {0.0f, 0.0f, 1.0f, 0.0f}},
        {{-1.0f, -1.0f, -1.0f, 1.0f}, {1.0f, 0.0f, 1.0f, 0.0f}},

        // 远相机面
        {{-1.0f, +1.0f, +1.0f, 1.0f}, {1.0f, 0.0f, 1.0f, 0.0f}},
        {{+1.0f, +1.0f, +1.0f, 1.0f}, {1.0f, 0.0f, 0.0f, 0.0f}},
        {{+1.0f, -1.0f, +1.0f, 1.0f}, {1.0f, 0.0f, 1.0f, 0.0f}},
        {{-1.0f, -1.0f, +1.0f, 1.0f}, {0.0f, 0.0f, 1.0f, 0.0f}}
    };
}

5. 实现摄像机并计算worldToProj

现在来考虑摄像机的实现。
一些模型放到的世界上,我们在某个位置假设一台虚拟摄像机。摄像机朝向一个方向。
我们需要判断出哪些模型能被摄像机看到。
龙书163页有最后的转换矩阵。

依照该矩阵编写摄像机类

    // 摄像机
    class Camera
    {
    public:
        Camera(Vector4 pos, Vector4 target, Vector4 up) :
            _pos(pos), _posTemp(pos), _target(target), _up(up) {}
        virtual ~Camera() noexcept {}

        void setPos(const Vector4& pos)
        {
            _pos = pos;
            calcMatrix();
        }

        void setPerspectiveForLH(float fov, float aspect, float nearZ, float farZ)
        {
            _fov = fov; _aspect = aspect; _nearZ = nearZ; _farZ = farZ;
            calcMatrix();
        }

    public:
        // 世界坐标系转到投影平面
        Matrix _worldToProjection;

    private:
        void calcMatrix()
        {
            Vector4 dir = { _target.x - _pos.x, _target.y - _pos.y, _target.z - _pos.z, 0.0f };
            Vector4 w = normallize(dir);
            Vector4 u = normallize(cross(_up, w));
            Vector4 v = cross(w, u);

            _worldToView = {
                u.x, v.x, w.x, 0.0f,
                u.y, v.y, w.y, 0.0f,
                u.z, v.z, w.z, 0.0f,
                -dot(_pos, u), -dot(_pos, v), -dot(_pos, w), 1.0
            };

            float f = 1.0f / (float)tan(_fov * 0.5f);
            _viewToProjection = {
                f / _aspect, 0.0f, 0.0f, 0.0f,
                0.0f, f, 0.0f, 0.0f,
                0.0f, 0.0f, _farZ / (_farZ - _nearZ), 1.0f,
                0.0f, 0.0f, -_nearZ * _farZ / (_farZ - _nearZ), 0.0f
            };

            _worldToProjection = mul(_worldToView, _viewToProjection);
        }

    private:
        Vector4 _pos;
        Vector4 _posTemp;
        Vector4 _target;
        Vector4 _up;
        float _fov;
        float _aspect;
        float _nearZ;
        float _farZ;

        // 世界坐标系转观察坐标系
        Matrix _worldToView;
        // 观察坐标系转投影坐标系
        Matrix _viewToProjection;
    };

创建一个摄像机器,并在intRender中初始化它

    // 初始化摄像机 
    Camera camera(
        { 5.0f, 5.0f, -5.0f, 1.0f },  // 位置
        { 0.0f, 0.0f, 0.0f, 1.0f },   // 朝向 原点
        { 0.0f, 1.0f, 0.0f, 0.0f }    // 摄像机上方向
    );

    // initRenderer函数中
    // 摄像机初始化
    camera.setPerspectiveForLH(
        3.1415926f * 0.25f,       // 上下45度视野
        (float)w / (float)h,    // 长宽比
        1.0f,
        200.0f
    );

6. 模型转换到透视投影坐标系

摄像机类已经做好了,并且初始化了一个camera变量。
我们可以把模型中的顶点,乘以camera中的_worldToProjection,就可以转换到透视投影坐标系了

7. 先画个简单线框

当然了,我们现在还无法看到任何图形,因为没有制作渲染部分。
我们知道绘制这件事情,最小的图元是三角形。
所以先简单编写几个函数,把模型绘制转变成三角形绘制
void SoftRender::drawCube()
{
    drawPlane(0, 1, 2, 3);  // 正面
    drawPlane(1, 5, 6, 2);  // 右面
    drawPlane(4, 0, 3, 7);  // 左面
    drawPlane(4, 5, 1, 0);  // 上面
    drawPlane(3, 2, 6, 7);  // 下面
    drawPlane(5, 4, 7, 6);  // 后面
}

void SoftRender::drawPlane(int lt, int rt, int rb, int lb)
{
    drawPrimitive(vertexes[lt], vertexes[rt], vertexes[rb]);
    drawPrimitive(vertexes[lt], vertexes[rb], vertexes[lb]);
}

void SoftRender::drawPrimitive(const vertex& a, const vertex& b, const vertex& c)
{
}

进入绘制三角形的部分

  • 7.1 所有三角形顶点转换到透视投影坐标系
  • 7.2 简单的cvv裁剪,只要有一个顶点不在cvv中整个图元都不绘制
  • 7.3 透视除法,把顶点坐标归一化到NDC
  • 7.4 顶点转换成屏幕坐标
  • 7.5 开始绘制线段。Bresenham快速画直线算法
void SoftRender::drawPrimitive(const vertex& a, const vertex& b, const vertex& c)
{
    // 1. 转换顶点到齐次剪裁空间
    // point * modelToWorld * worldToView * viewToProjection
    //
    // 1.1 计算modelToWorld矩阵 SRT
    // 简单做 该物理的坐标系与世界坐标系相同
    // 则modelToWorld就是单位矩阵
    // do nothing
    //
    // 1.2 计算worldToView viewToProjection
    // 这一部分在camera中计算了 直接读取出来
    //
    // 1.3 顺路做简单的cvv裁剪
    Matrix m = camera._worldToProjection;
    Vector4 p1 = transform(a.point, m); if (checkCvv(p1)) return;
    Vector4 p2 = transform(b.point, m); if (checkCvv(p2)) return;
    Vector4 p3 = transform(c.point, m); if (checkCvv(p3)) return;

    // 2. 透视除法 归一到NDC坐标系
    // x[-1, 1] y[-1, 1] z[near, far]
    perspectiveDivede(p1);
    perspectiveDivede(p2);
    perspectiveDivede(p3);

    // 3. 转换到屏幕坐标
    transformScreen(p1, g_width, g_height);
    transformScreen(p2, g_width, g_height);
    transformScreen(p3, g_width, g_height);

    // 4. 绘制线框
    if (g_renderMode == RenderMode::RENDER_WIREFRAME)
    {
        int x1 = (int)(p1.x + 0.5f), x2 = (int)(p2.x + 0.5f), x3 = (int)(p3.x + 0.5f);
        int y1 = (int)(p1.y + 0.5f), y2 = (int)(p2.y + 0.5f), y3 = (int)(p3.y + 0.5f);
        drawLine(x1, y1, x2, y2, greenColor);
        drawLine(x2, y2, x3, y3, greenColor);
        drawLine(x1, y1, x3, y3, greenColor);
    }
    else
    {
    }

}

void SoftRender::drawLine(int x1, int y1, int x2, int y2, unsigned int color)
{
    if (x1 == x2 && y1 == y2)
    {
        drawPixel(x1, y1, color);
    }
    else if (x1 == x2)
    {
        if (y1 > y2) std::swap(y1, y2);
        for (int y = y1; y <= y2; ++y)
            drawPixel(x1, y, color);
    }
    else if (y1 == y2)
    {
        if (x1 > x2) std::swap(x1, x2);
        for (int x = x1; x <= x2; ++x)
            drawPixel(x, y1, color);
    }
    else
    {
        // Bresenham
        int diff = 0;
        int dx = std::abs(x1 - x2);
        int dy = std::abs(y1 - y2);
        if (dx > dy)
        {
            if (x1 > x2) std::swap(x1, x2), std::swap(y1, y2);
            for (int x = x1, y = y1; x < x2; ++x)
            {
                drawPixel(x, y, color);
                diff += dy;
                if (diff >= dx)
                {
                    diff -= dx;
                    y += (y1 < y2) ? 1 : -1;
                }
            }
            drawPixel(x2, y2, color);
        }
        else
        {
            if (y1 > y2) std::swap(x1, x2), std::swap(y1, y2);
            for (int y = y1, x = x1; y < y2; ++y)
            {
                drawPixel(x, y, color);
                diff += dx;
                if (diff >= dy)
                {
                    diff -= dy;
                    x += (x1 < x2) ? 1 : -1;
                }
            }
            drawPixel(x2, y2, color);
        }
    }
}

void SoftRender::drawPixel(int x, int y, unsigned int color)
{
    if (x < 0 || x >= g_width || y < 0 || y >= g_height) return;

    int idx = y * g_width + x;
    g_frameBuff[idx] = color;
}

bool SoftRender::checkCvv(const Vector4& v)
{
    if (v.z < 0.0f) return true;
    if (v.z > v.w) return true;
    if (v.x < -v.w) return true;
    if (v.x > v.w) return true;
    if (v.y < -v.w) return true;
    if (v.y > v.w) return true;
    return false;
}

来看下效果:
实现一个简单的软光栅_第4张图片

8. 光栅化三角形

传统的光栅化三角形不管是实现还是编写代码都是挺恶心的。
这里采用的是重心光栅化的方法。
500行C++代码实现软件渲染器
tinyrenderer实现源码

void SoftRender::drawPrimitiveScanLine(const vertex& a, const vertex& b, const vertex& c)
{
    float xl = a.point.x; if (b.point.x < xl) xl = b.point.x; if (c.point.x < xl) xl = c.point.x;
    float xr = a.point.x; if (b.point.x > xr) xr = b.point.x; if (c.point.x > xr) xr = c.point.x;
    float yt = a.point.y; if (b.point.y < yt) yt = b.point.y; if (c.point.y < yt) yt = c.point.y;
    float yb = a.point.y; if (b.point.y > yb) yb = b.point.y; if (c.point.y > yb) yb = c.point.y;

    int xMin = (int)(xl + 0.5f), xMax = (int)(xr + 0.5f), yMin = (int)(yt + 0.5f), yMax = (int)(yb + 0.5f);
    for (int x = xMin; x <= xMax; ++x)
    {
        for (int y = yMin; y <= yMax; ++y)
        {
            // 计算是否在三角形内部
            Vector4 ret = barycentric(a.point, b.point, c.point, { (float)x, (float)y, 0.0f, 0.0f });
            if (ret.x < 0 || ret.y < 0 || ret.z < 0) continue;
            unsigned int colorR = (unsigned int)((a.color.x * ret.x + b.color.x * ret.y + c.color.x * ret.z) * 255);
            unsigned int colorG = (unsigned int)((a.color.y * ret.x + b.color.y * ret.y + c.color.y * ret.z) * 255);
            unsigned int colorB = (unsigned int)((a.color.z * ret.x + b.color.z * ret.y + c.color.z * ret.z) * 255);
            float depth = (a.point.z * ret.x + b.point.z * ret.y + c.point.z * ret.z);
            if (g_depthBuff[x + y * g_width] < depth)continue;
            g_depthBuff[x + y * g_width] = depth;
            drawPixel(x, y, (colorR << 16) | (colorG << 8) | colorB);
        }
    }
}

SoftRender::Vector4 SoftRender::barycentric(const Vector4& a, const Vector4& b, const Vector4& c, const Vector4& p)
{
    Vector4 v1 = { c.x - a.x, b.x - a.x, a.x - p.x };
    Vector4 v2 = { c.y - a.y, b.y - a.y, a.y - p.y };

    Vector4 u = cross(v1, v2);
    if (std::abs(u.z) > 1e-2) // dont forget that u[2] is integer. If it is zero then triangle ABC is degenerate
        return { 1.f - (u.x + u.y) / u.z, u.y / u.z, u.x / u.z };
    return { -1, 1, 1, 0 }; // in this case generate negative coordinates, it will be thrown away by the rasterizator
}

实现一个简单的软光栅_第5张图片

9. 摄像机的环绕,缩放

这个的实现就没有什么难度了。相应win消息,然后简单做一下就可以了
// camera类中
// 环绕
void circle(short xMove, short yMove)
{
    // 鼠标移动像素与弧度的比例固定
    float circleLen = 100.f;

    // 1 计算绕y轴的旋转
    float radY = xMove / circleLen;
    Matrix mScaleY = {
        (float)cos(radY), 0.0f, -(float)sin(radY), 0.0f,
        0.0f, 1.0f, 0.0f, 0.0f,
        (float)sin(radY), 0.0f, (float)cos(radY), 0.0f,
        0.0f, 0.0f, 0.0f, 1.0f
    };

    // 2 计算绕x轴 这里需要设定一个最大角度
    float radX = yMove / circleLen;
    float maxRad = 3.1415926f * 0.45f;
    _curXRand += radX;
    if (_curXRand < -maxRad)
    {
        _curXRand = -maxRad;
        radX = 0.0f;
    }
    if (_curXRand > maxRad)
    {
        _curXRand = maxRad;
        radX = 0.0f;
    }

    Matrix mScaleX = {
        1.0f, 0.0f, 0.0f, 0.0f,
        0.0f, (float)cos(radX), (float)sin(radX), 0.0f,
        0.0f, -(float)sin(radX), (float)cos(radX), 0.0f,
        0.0f, 0.0f, 0.0f, 1.0f
    };

    _pos = transform(_pos, mScaleX);
    _pos = transform(_pos, mScaleY);
    calcMatrix();
}

// 缩放
void zoom(short wParam)
{
    float t = 0.9f;
    if (wParam > 0) t = 1.1f;
    _pos.x *= t;
    _pos.y *= t;
    _pos.z *= t;
    calcMatrix();
}

总结

实现这个极简的光栅渲染器大概花了20多个小时。参照了很多的源码。
整体来说,难度并不高。因为复杂的部分我也没做,我也不会啊
比如

  1. 裁剪,根据cvv裁掉多余的部分,而不是粗暴的直接不绘制整个图元了
  2. 纹理绘制
  3. 透视纹理映射、透视颜色映射

以上还都是最基本的内容。当然了我摄像机的y方向移动也有些问题。不打算改了

可以说实现这么一次还是挺有意义的。可以让你彻底掌握坐标转换。搞清楚一个模型怎么最终映射到2维,光栅化是怎么确定每个像素颜色的,等等。

挺好,接下来会继续基于miniEngine鼓捣一些东西。

本项目github:
https://github.com/mversace/s...

你可能感兴趣的:(c++,directx)