0. 前言

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

参考文章:

​如何开始用 C++ 写一个光栅化渲染器?​

​想用C++实现一个软件渲染器​

参考源码:

​tinyrenderer​

1. 创建一个win32窗口

这一步非常的简单。参照官方代码即可。

​创建win32窗口​

#include <windows.h>

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;
}

需要注意的是,项目属性,要设置为窗口

实现一个简单的软光栅_世界坐标系

直接编译运行:

实现一个简单的软光栅_世界坐标系_02

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

对于win32上的简单绘制,很多时候会使用gdi,这里为了更好的学习,采用直接取出渲染缓冲区的办法。

直接上代码

创建一个renderer.h文件

#pragma once
#include <memory>

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<float[]> 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;

编译运行

实现一个简单的软光栅_渲染器_03

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<vertex> 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;
}

来看下效果:

实现一个简单的软光栅_初始化_04

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
}

实现一个简单的软光栅_渲染器_05

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...​​​