Skip to content

TinyRenderer 学习笔记04:着色器(Shader)

代码重构与解析

我们的渲染器已经能完成一定的任务了,现在让我们对其进行一定的重构。

GL的拆离

我们的渲染相关函数部分已经可以从main.cpp中剥离到单独的.cpp/.h文件。这就是所谓的GL - Graphics Library

simple_gl

cpp
#ifndef __SIMPLE_GL_H__
#define __SIMPLE_GL_H__
#include "tgaimage.h"
#include "geometry.h"

extern Matrix ModelView;
extern Matrix Viewport;
extern Matrix Projection;
const float depth = 2000.f;

void viewport(int x, int y, int w, int h);
void projection(float coeff=0.f); // coeff = -1/c
void lookat(Vec3f eye, Vec3f center, Vec3f up);

struct IShader {
    virtual ~IShader();
    virtual Vec4f vertex(int iface, int nthvert) = 0;
    virtual bool fragment(Vec3f bar, TGAColor &color) = 0;
};
void triangle(Vec4f *pts, IShader &shader, TGAImage &image, TGAImage &zbuffer);
#endif

simple_gl.cpp

c++
#include <cmath>
#include <limits>
#include <cstdlib>
#include "simple_gl.h"

Matrix ModelView;
Matrix Viewport;
Matrix Projection;

IShader::~IShader() {}

// 画线算法
void line(Vec2i p0, Vec2i p1, TGAImage &image, TGAColor color)
{
    // 是否转置(记得转回去)
    bool steep = false;
    // 如果图像“陡峭”,对图像进行转置
    if (std::abs(p0.x - p1.x) < std::abs(p0.y - p1.y))
    {
        std::swap(p0.x, p0.y);
        std::swap(p1.x, p1.y);
        steep = true;
    }
    // 当 x0 > x1,对二者进行交换
    if (p0.x > p1.x)
    {
        std::swap(p0, p1);
    }
    // 从循环中取出。注意这里用的是int类型,然后通过一个derror和error来实现浮点数。
    int dx = p1.x - p0.x;
    int dy = p1.y - p0.y;
    int derror2 = std::abs(dy) * 2;
    int error2 = 0;
    int y = p0.y;
    // 一样的遍历算法,不多赘述
    for (int x = p0.x; x <= p1.x; x++)
    {
        if (steep)
        {
            // 如果转置了,记得转置回来(其实就是x和y反着set)
            image.set(y, x, color);
        }
        else
        {
            image.set(x, y, color);
        }
        error2 += derror2;
        if (error2 > dx)
        {
            y += (p1.y > p0.y ? 1 : -1);
            error2 -= dx * 2;
        }
    }
}

// 视口矩阵
void viewport(int x, int y, int w, int h)
{
    Viewport = Matrix::identity();
    Viewport[0][3] = x + w / 2.f;
    Viewport[1][3] = y + h / 2.f;
    Viewport[2][3] = 255.f / 2.f;
    Viewport[0][0] = w / 2.f;
    Viewport[1][1] = h / 2.f;
    Viewport[2][2] = 255.f / 2.f;
}

void projection(float coeff)
{
    Projection = Matrix::identity();
    Projection[3][2] = coeff;
}

// lookat函数
void lookat(Vec3f eye, Vec3f center, Vec3f up)
{
    Vec3f z = (eye - center).normalize();
    Vec3f x = cross(up, z).normalize();
    Vec3f y = cross(z, x).normalize();
    ModelView = Matrix::identity();
    for (int i = 0; i < 3; i++)
    {
        ModelView[0][i] = x[i];
        ModelView[1][i] = y[i];
        ModelView[2][i] = z[i];
        ModelView[i][3] = -center[i];
    }
}

// 重心坐标
Vec3f barycentric(Vec2f A, Vec2f B, Vec2f C, Vec2f P)
{
    // 封装向量
    Vec3f s[2];
    for (int i = 2; i--;)
    {
        s[i][0] = C[i] - A[i];
        s[i][1] = B[i] - A[i];
        s[i][2] = A[i] - P[i];
    }
    // 计算叉积
    Vec3f u = cross(s[0], s[1]);
    // `pts`和`P`有整数值作为坐标,所以`abs(u[2])`<1意味着`u[2]`是0,这意味着三角形是退化的,在这种情况下,返回负坐标的东西。
    if (std::abs(u[2]) > 1e-2)
        return Vec3f(1.f - (u.x + u.y) / u.z, u.y / u.z, u.x / u.z);
    // 在这种情况下产生负坐标,它将被光栅化器扔掉。
    return Vec3f(-1, 1, 1);
}

// 三角形光栅化(重心坐标)
void triangle(Vec4f *pts, IShader &shader, TGAImage &image, TGAImage &zbuffer)
{
    Vec2f bboxmin(std::numeric_limits<float>::max(), std::numeric_limits<float>::max());
    Vec2f bboxmax(-std::numeric_limits<float>::max(), -std::numeric_limits<float>::max());
    // 迭代三角形,寻找最小/最大坐标
    for (int i = 0; i < 3; i++)
    {
        for (int j = 0; j < 2; j++)
        {
            bboxmin[j] = std::min(bboxmin[j], pts[i][j] / pts[i][3]);
            bboxmax[j] = std::max(bboxmax[j], pts[i][j] / pts[i][3]);
        }
    }
    Vec2i P;
    TGAColor color;
    // 遍历,找出三角形内的点。
    // 遍历x
    for (P.x = bboxmin.x; P.x <= bboxmax.x; P.x++)
    {
        // 遍历y
        for (P.y = bboxmin.y; P.y <= bboxmax.y; P.y++)
        {
            // 重心坐标
            Vec3f c = barycentric(proj<2>(pts[0] / pts[0][3]), proj<2>(pts[1] / pts[1][3]), proj<2>(pts[2] / pts[2][3]), proj<2>(P));
            float z = pts[0][2] * c.x + pts[1][2] * c.y + pts[2][2] * c.z;
            float w = pts[0][3] * c.x + pts[1][3] * c.y + pts[2][3] * c.z;
            int frag_depth = std::max(0, std::min(255, int(z / w + .5)));
            // 是否在边界内
            if (c.x < 0 || c.y < 0 || c.z < 0 || zbuffer.get(P.x, P.y)[0] > frag_depth)
                continue;
            bool discard = shader.fragment(c, color);
            if (!discard)
            {
                // 绘制像素
                zbuffer.set(P.x, P.y, TGAColor(frag_depth));
                image.set(P.x, P.y, color);
            }
        }
    }
}

main.cpp的剩余部分

此时main.cpp就剩下一个Shader (之后会介绍) 和main函数了。

c++
#include <vector>
#include <iostream>
#include <cmath>
#include <limits>

#include "tgaimage.h"
#include "model.h"
#include "geometry.h"
#include "simple_gl.h"

// 定义颜色常量
const TGAColor white = TGAColor(255, 255, 255, 255);
const TGAColor red = TGAColor(255, 0, 0, 255);
const TGAColor green = TGAColor(0, 255, 0, 255);
const TGAColor blue = TGAColor(0, 0, 255, 255);
// 设定模型
Model *model = NULL;
// 设定zbuffer
int *zbuffer = NULL;
// 设定宽高深
const int width = 2048;
const int height = 2048;
// 光照方向
Vec3f light_dir(1, 1, 1);
// 摄像机方向
Vec3f eye(3, 1, 5);
Vec3f center(0, 0, 0);
Vec3f up(0, 1, 0);

struct GouraudShader : public IShader
{
	// 由顶点着色器写入,由片段着色器读取
	Vec3f varying_intensity;

	virtual Vec4f vertex(int iface, int nthvert)
	{
		// 从.obj文件中读取顶点
		Vec4f gl_Vertex = embed<4>(model->vert(iface, nthvert));
		// 将其转换为屏幕坐标
		gl_Vertex = Viewport * Projection * ModelView * gl_Vertex;
		// 获得漫反射照明强度
		varying_intensity[nthvert] = std::max(0.f, model->normal(iface, nthvert) * light_dir);
		return gl_Vertex;
	}

	virtual bool fragment(Vec3f bar, TGAColor &color)
	{
		// 对当前像素进行强度插值
		float intensity = (varying_intensity * bar) + .05;
		color = TGAColor(255, 255, 255) * intensity;
		// 我们不丢弃这个像素
		return false;
	}
};

// 主函数
int main(int argc, char **argv)
{
	if (2 == argc)
	{
		model = new Model(argv[1]);
	}
	else
	{
		model = new Model("obj/bun_zipper/bun_zipper.obj");
	}

	// 初始化矩阵
	lookat(eye, center, up);
	viewport(width / 8, height / 8, width * 3 / 4, height * 3 / 4);
	projection(-1.f / (eye - center).norm());

	// 标准化光向量
	light_dir.normalize();

	TGAImage image(width, height, TGAImage::RGB);
	TGAImage zbuffer(width, height, TGAImage::GRAYSCALE);

	GouraudShader shader;
	for (int i = 0; i < model->nfaces(); i++)
	{
		Vec4f screen_coords[3];
		for (int j = 0; j < 3; j++)
		{
			screen_coords[j] = shader.vertex(i, j);
		}
		triangle(screen_coords, shader, image, zbuffer);
	}

	image.flip_vertically();
	zbuffer.flip_vertically();
	image.write_tga_file("output.tga");
	zbuffer.write_tga_file("zbuffer.tga");

	delete model;
	return 0;
}

让我们来看看main.cpp是怎么工作的。跳过头文件,我们声明了一些全局变量(屏幕尺寸、相机位置之类的),然后是GouraudShader,这个后面再说。

接下来就是main函数的部分,它的工作:

  • 解析OBJ文件;
  • ModelView (lookat)、ViewPort和Projection的初始化;
  • 遍历模型的所有三角形并栅格化每个三角形。
    • 外循环遍历所有三角形。内循环循环访问当前三角形的所有顶点,并为每个顶点调用**顶点着色器 (Vertex Shader) **。

顶点着色器 (Vertex Shader) :

  • 主要目标:变换顶点的坐标。
  • 次要目标:为片段着色器 (Fragment Shader) 准备数据。

在这之后要做的,就是所谓的光栅化。因为我们自己手写了GL,所以光栅化的具体实现我们是知道的(也就是barycentric()triangle()函数所做的。但是如果使用其他的GL,那么光栅化的具体实现就是一个黑箱(除非我们去阅读其他GL的源码)。

但是即便我们不清楚GL的内部实现,有一点也是明确的:光栅化会为每个像素调用我们自己的回调函数,即片段着色器

片段着色器 (Fragment Shader) :

  • 主要目标:确定当前像素颜色。
  • 次要目标:可以通过返回true来丢弃当前像素。

下面是OpenGL2的渲染管线,其实对于较新的版本来说,他们或多或少是相同的。

我们的简单Renderer不需要特别复杂的管线,所以我们只参考OpenGL2的管线。但其实在更新的版本以及其他GL中,存在很多其他着色器。

在上图中:

  • 蓝色:所有我们无法触及的部分(其实就是GL的内部实现);

  • 橙色:我们的回调函数。

更详细的说:

  • Vertex Data - 我们从OBJ中读取到的数据;
  • Primitive Processing - 我们的main()函数;
  • Vertex Shader;
  • Primitive Assembly - 我们这里并没有这个,因为我们只绘制简单三角形。在我们的代码里这一步与Primitive Processing合并了。
  • Rasterizer - 即barycentric()triangle()函数所做的;
  • Fragment Shader;
  • Depth - Z-buffer;
  • Frame Buffer - 输出图像。

绘制一个我们自己Renderer的管线:

它比OpenGL2的还要简单很多。

Gouraud Shader

关于理论部分,可以参考Gouraud shading - Wikipedia

如果之后有机会重修笔记,我会加上这一部分。

我们上面的main.cpp中出现的Shader正是Gouraud Shader。

c++
struct GouraudShader : public IShader
{
	// 由顶点着色器写入,由片段着色器读取
	Vec3f varying_intensity;

	virtual Vec4f vertex(int iface, int nthvert)
	{
		// 从.obj文件中读取顶点
		Vec4f gl_Vertex = embed<4>(model->vert(iface, nthvert));
		// 将其转换为屏幕坐标
		gl_Vertex = Viewport * Projection * ModelView * gl_Vertex;
		// 获得漫反射照明强度
		varying_intensity[nthvert] = std::max(0.f, model->normal(iface, nthvert) * light_dir);
		return gl_Vertex;
	}

	virtual bool fragment(Vec3f bar, TGAColor &color)
	{
		// 对当前像素进行强度插值
		float intensity = (varying_intensity * bar) + .05;
		color = TGAColor(255, 255, 255) * intensity;
		// 我们不丢弃这个像素
		return false;
	}
};

Varying是GLSL语言中的保留关键字,这里使用varying_intensity以显示对应关系。在Varing变量中,我们存储了要在三角形内插值的数据,片段着色器会获得这些插值(针对当前像素)。

结果:

我们在绘制三角形内的每个像素时都会调用片段着色器,它接收用于插值varying_intensity的重心坐标数据。因此,插值的强度可以计算为:

varying_intensity[0]*bar[0]+varying_intensity[1]*bar[1]+varying_intensity[2]*bar[2]

或者简单的两个向量之间的点积:

varying_intensity*bar

在真正的GLSL中,片段着色器会接收准备好的插值。

这里的片段着色器返回一个布尔值,在我们的GL内部有这样的代码:

c++
bool discard = shader.fragment(c, color);
if (!discard)
{
    // 绘制像素
    zbuffer.set(P.x, P.y, TGAColor(frag_depth));
    image.set(P.x, P.y, color);
}

不难理解它的作用。片段着色器可以丢弃像素,光栅化器会跳过被丢弃的像素。

如果我们想创建二元掩膜或任何想要的东西,这会很方便。

当然,光栅化器无法想象可编程的所有东西,因此无法使用着色器进行预编译(无法将Shader完全放到GL当中)。在这里,我们使用抽象类IShader作为两者之间的中间体。

对Shader的一个小修改

这里说个题外话,虽然提到了很多次Shader,但并没有对其进行更深一步的介绍。

其实可以参考Shader - Wikipedia

Shader,或者说着色器的特点在于其可编程性。因此我们可以通过修改Shader来做出一些很有意思的东西。

我们修改一下片段着色器,让其强度只允许6个值:

c++
virtual bool fragment(Vec3f bar, TGAColor &color)
{
    float intensity = varying_intensity * bar;
    if (intensity > .85)
        intensity = 1;
    else if (intensity > .60)
        intensity = .80;
    else if (intensity > .45)
        intensity = .60;
    else if (intensity > .30)
        intensity = .45;
    else if (intensity > .15)
        intensity = .30;
    else
        intensity = 0;
    color = TGAColor(255, 155, 0) * intensity;
    return false;
}

现在我们得到了一个分层渲染的结果:

纹理映射

在OBJ文件中,存在这样的行:

f 205/192/205 209/200/209 210/193/210

每一组数的第一个数字我们知道它的含义,而第二个数字就是顶点的纹理坐标。在三角形内插值,乘以纹理图像的宽度高度,将获得要渲染的颜色。

c++
struct Shader : public IShader
{
	// 由顶点着色器写入,由片段着色器读取
	Vec3f varying_intensity;
	mat<2, 3, float> varying_uv;

	virtual Vec4f vertex(int iface, int nthvert)
	{
		varying_uv.set_col(nthvert, model->uv(iface, nthvert));
		// 获得漫反射照明强度
		varying_intensity[nthvert] = std::max(0.f, model->normal(iface, nthvert) * light_dir);
		// 从.obj文件中读取顶点
		Vec4f gl_Vertex = embed<4>(model->vert(iface, nthvert));
		// 将其转换为屏幕坐标
		return Viewport * Projection * ModelView * gl_Vertex;
	}

	virtual bool fragment(Vec3f bar, TGAColor &color)
	{
		// 对当前像素进行强度插值
		float intensity = varying_intensity * bar;
		// 为当前像素插值uv
		Vec2f uv = varying_uv * bar;
		color = model->diffuse(uv) * intensity;
		// 我们不丢弃这个像素
		return false;
	}
};

结果:

法线映射

贴图中存储的不一定是纹理。事实上几乎任何东西都可以存储在贴图中,我们只需要在调用的时候把对应关系找好即可。

例如我们将RGB三个值对应到xyz方向,那么我们的贴图就提供了渲染每个像素的法向矢量,而不仅是之前那样的每个顶点。

Shader的编写:

c++
struct Shader : public IShader
{
	// 由顶点着色器写入,由片段着色器读取
	Vec3f varying_intensity;
	mat<2, 3, float> varying_uv;
	// Projection*ModelView
	mat<4, 4, float> uniform_M;
	// (Projection*ModelView).invert_transpose()
	mat<4, 4, float> uniform_MIT;

	virtual Vec4f vertex(int iface, int nthvert)
	{
		varying_uv.set_col(nthvert, model->uv(iface, nthvert));
		// 获得漫反射照明强度
		varying_intensity[nthvert] = std::max(0.f, model->normal(iface, nthvert) * light_dir);
		// 从.obj文件中读取顶点
		Vec4f gl_Vertex = embed<4>(model->vert(iface, nthvert));
		// 将其转换为屏幕坐标
		return Viewport * Projection * ModelView * gl_Vertex;
	}

	virtual bool fragment(Vec3f bar, TGAColor &color)
	{
		// 为当前像素插值uv
		Vec2f uv = varying_uv * bar;
		Vec3f n = proj<3>(uniform_MIT * embed<4>(model->normal(uv))).normalize();
		Vec3f l = proj<3>(uniform_M * embed<4>(light_dir)).normalize();
		// 对当前像素进行强度插值
		// float intensity = varying_intensity * bar;
		float intensity = std::max(0.f, n*l);
		color = model->diffuse(uv) * intensity;
		// 我们不丢弃这个像素
		return false;
	}
};

// main函数内
Shader shader;
shader.uniform_M = Projection * ModelView;
shader.uniform_MIT = (Projection * ModelView).invert_transpose();
for (int i = 0; i < model->nfaces(); i++)
{
    Vec4f screen_coords[3];
    for (int j = 0; j < 3; j++)
    {
        screen_coords[j] = shader.vertex(i, j);
    }
    triangle(screen_coords, shader, image, zbuffer);
}

Uniform是GLSL语言中的保留关键字,它允许将常量传递给Shader。

在这里,矩阵Projection*ModelView及其倒转置转换为法向量。因此,照明强度的计算与以前相同,只不过我们从法线贴图中检索信息,而不是插值法线矢量(不要忘记转换光矢量和法线矢量)。

结果:

镜面映射

我们现在来改进照明,这里会用到Phong反射模型。它将最终照明视为三种光强度的加权和:

  • 环境照明(每个场景恒定)
  • 漫射照明(我们目前为止还在使用的简单照明)
  • 镜面照明(高光部分)

我们目前所使用的漫射照明的漫射光方向为法向量和光向量之间角度的余弦。即:假设光均匀的反射到各个方向。

而对于光滑的表面,在极限状况下(镜子),当且仅当我们能看到这个像素所反射的光源时,这个像素才被照亮。

对于漫射照明,我们计算了向量nl之间的余弦;对于反射照明,我们对向量rv之间的余弦更感兴趣。

对于给定向量nl,计算r。如果nl都被归一化,则有:

r=2n<n,l>l

这里的<n,l>是指nl的向量内积。

对于光泽的表面,它在某一个方向上的反射要比其他方向多得多。

在漫反射里我们简单的取余弦值,那么对于镜面反射,如果我们取余弦的10次方、1000次方会怎样?

对所有小于1的数应用幂都会让它变得更小,因此100次方的光强会小得多。

这些东西被存储在一个特殊的贴图中(镜面贴图),它会告知每个点是否应该有光泽。

c++
struct Shader : public IShader
{
	// 由顶点着色器写入,由片段着色器读取
	Vec3f varying_intensity;
	mat<2, 3, float> varying_uv;
	// Projection*ModelView
	mat<4, 4, float> uniform_M;
	// (Projection*ModelView).invert_transpose()
	mat<4, 4, float> uniform_MIT;

	virtual Vec4f vertex(int iface, int nthvert)
	{
		varying_uv.set_col(nthvert, model->uv(iface, nthvert));
		// 获得漫反射照明强度
		varying_intensity[nthvert] = std::max(0.f, model->normal(iface, nthvert) * light_dir);
		// 从.obj文件中读取顶点
		Vec4f gl_Vertex = embed<4>(model->vert(iface, nthvert));
		// 将其转换为屏幕坐标
		return Viewport * Projection * ModelView * gl_Vertex;
	}

	virtual bool fragment(Vec3f bar, TGAColor &color)
	{
		// 为当前像素插值uv
		Vec2f uv = varying_uv * bar;
		// 法向
		Vec3f n = proj<3>(uniform_MIT * embed<4>(model->normal(uv))).normalize();
		// 光向
		Vec3f l = proj<3>(uniform_M * embed<4>(light_dir)).normalize();
		// 反射光向
		Vec3f r = (n * (n * l * 2.f) - l).normalize();
		float spec = pow(std::max(r.z, 0.0f), model->specular(uv));
		float diff = std::max(0.f, n * l);
		TGAColor c = model->diffuse(uv);
		color = c;
		for (int i = 0; i < 3; i++)
		{
			color[i] = std::min<int>(5 + c[i]*(1 * diff + 2 * spec), 255);
		}
		// 我们不丢弃这个像素
		return false;
	}
};

// main函数内
Shader shader;
shader.uniform_M = Projection * ModelView;
shader.uniform_MIT = (Projection * ModelView).invert_transpose();
for (int i = 0; i < model->nfaces(); i++)
{
    Vec4f screen_coords[3];
    for (int j = 0; j < 3; j++)
    {
        screen_coords[j] = shader.vertex(i, j);
    }
    triangle(screen_coords, shader, image, zbuffer);
}

其中最重要的一行:

c++
color[i] = std::min<int>(5 + c[i]*(1 * diff + 2 * spec), 255);

这里面最重要的就是三个系数。他们是三种反射的叠加权重。

通常系数和应为1,但有时候为了创建夸张的反射,会打破这个规则。

结果: