文章目录

  • 原文学习
  • 前言
  • 一、前置条件
  • 1.内容
  • 2.难点
  • 二、前置代码(sheder和三角形等设置)
  • 1.画面渲染
  • 2.Shader的使用
  • 3.材质信息
  • 4.在 shader 中进行三角形求交
  • 5.相机配置
  • 三、使用线性化的BVH树进行优化
  • 1. 构建BVH
  • 2. BVH 数据传送到 shader
  • 3. 和 AABB 盒子求交
  • 4. 非递归遍历 BVH 树
  • 四、开始光线追踪
  • 1. 原理
  • 2. 辅助函数
  • 3. pathTracing 的实现
  • 4. 多帧混合与后处理
  • 5. 增加HDR环境贴图
  • 完整代码



前言

  • 之前跟着上文作者的博客学习了蒙卡罗特路径追踪,在CPU端模拟实现光追效果图片。但是渲染消耗过大,如果想要实现的更好效果需要做到使用BVH加速遍历效果以及在GPU端实现光线追踪。
  • 大概思路就是将OPENGL中的片段着色器逐个像素的计算光追,然后将三角形信息以及BVH加速效果和光线投射技术实现到shader中。而shader中的color使用光线投射技术。
  • 接下来继学习作者的GPU端实现光线追踪的效果。

一、前置条件

1.内容

  • OpenGL
  • GLSL
  • 路径追踪:一个点的颜色是通过渲染方程进行积分求解。每次积分逐像素递归求解光路直到碰到光源为止。
  • BVH加速盒

2.难点

  • shader中的信息交流
  • BVH加速在shader中不能使用指针技术,所以只能用线性二叉树的方式来实现BVH

二、前置代码(sheder和三角形等设置)

1.画面渲染

  • 上下为[-1,1]的画面中:

2.Shader的使用

  • 我们的数据通常是以 数组 形式进行传送,比如三角形数组,BVH 二叉树数组,材质数组等等。这些数组都是一维的,以方便我们用 下标 指针进行访问和采样。
  • 这里使用的是Buffer Texture:它允许我们直接将内存中的二进制数据搬运到显存中,然后通过一种特殊的采样器,也就是 samplerBuffer 来访问。
    和一般的 sampler2D 不同,samplerBuffer 将纹理的内容(即显存中的原始数据)视为一维数组,可以通过 下标直接索引 数据,并且不会使用任何过滤器这刚好满足我们的需要!
    Buffer Texture 的使用方式如下(示例):
int n;	// 数组大小
float triangles[];
//创建一个缓冲区对象,叫做 texture buffer object,简称 tbo,这可以类比为显存中开辟
GLuint tbo;
glGenBuffers(1, &tbo);
glBindBuffer(GL_TEXTURE_BUFFER, tbo);
glBufferData(GL_TEXTURE_BUFFER,
	 n * sizeof(float), &your_data[0], GL_STATIC_DRAW);//然后将数据塞进缓冲区中:
	 
//随后创建一块纹理,注意这时的纹理类型应该为 GL_TEXTURE_BUFFER 这表示我们开辟的不是图像纹理而是数据缓冲区纹理:
GLuint tex;
glGenTextures(1, &tex);
glBindTexture(GL_TEXTURE_BUFFER, tex);

//用 glTexBuffer 将 tbo 中的数据关联到 texture buffer
//这里我们使用 GL_RGB32F 的格式,这样一次访问可以取出一个 vec3 向量的数据。
//采样器的返回值有 RGB 三个通道,每个通道都是 32 位的浮点数:
glTexBuffer(GL_TEXTURE_BUFFER, GL_RGB32F, tbo);
glActiveTexture(GL_TEXTURE0);//最后传送 0 号纹理到着色器:
glUniform1i(glGetUniformLocation(program, "triangles"), 0);

在着色器端使用 texelFetch 和一个整数下标 index 进行 samplerBuffer 类型的纹理的查询:

uniform samplerBuffer triangles;

...

int index = xxx
vec3 data = texelFetch(triangles, index).xyz;
  • 这里的数据格式 GL_RGB32F 指的是一个下标(一次采样)能读取到多少数据,即一格数据的单位。一个下标将会索引三个 32 位的浮点数,并且返回一个 vec4,但是仅有 rgb 分量有效。他们和内存数据的映射关系如下:

    也可以使用 GL_R32F 来每次读取一个 32 位浮点数,这样能够更加灵活的组织数据但是显然一次读取一个 vec3 效率更高

3.材质信息

  • 迪士尼材质原则
//迪士尼规范
// 物体表面材质定义
struct Material {
    vec3 emissive = vec3(0, 0, 0);  // 作为光源时的发光颜色
    vec3 baseColor = vec3(1, 1, 1);
    float subsurface = 0.0;
    float metallic = 0.0;
    float specular = 0.0;
    float specularTint = 0.0;
    float roughness = 0.0;
    float anisotropic = 0.0;
    float sheen = 0.0;
    float sheenTint = 0.0;
    float clearcoat = 0.0;
    float clearcoatGloss = 0.0;
    float IOR = 1.0;
    float transmission = 0.0;
};

// 三角形定义
struct Triangle {
    vec3 p1, p2, p3;    // 顶点坐标
    vec3 n1, n2, n3;    // 顶点法线
    Material material;  // 材质
};
  • 编码:
// 读取三角形
std::vector<Triangle> triangles;
readObj()
int nTriangles = triangles.size();

...

// 编码 三角形, 材质
std::vector<Triangle_encoded> triangles_encoded(nTriangles);
for (int i = 0; i < nTriangles; i++) {
    Triangle& t = triangles[i];
    Material& m = t.material;
    // 顶点位置
    triangles_encoded[i].p1 = t.p1;
    triangles_encoded[i].p2 = t.p2;
    triangles_encoded[i].p3 = t.p3;
    // 顶点法线
    triangles_encoded[i].n1 = t.n1;
    triangles_encoded[i].n2 = t.n2;
    triangles_encoded[i].n3 = t.n3;
    // 材质
    triangles_encoded[i].emissive = m.emissive;
    triangles_encoded[i].baseColor = m.baseColor;
    triangles_encoded[i].param1 = vec3(m.subsurface, m.metallic, m.specular);
    triangles_encoded[i].param2 = vec3(m.specularTint, m.roughness, m.anisotropic);
    triangles_encoded[i].param3 = vec3(m.sheen, m.sheenTint, m.clearcoat);
    triangles_encoded[i].param4 = vec3(m.clearcoatGloss, m.IOR, m.transmission);
}
  • 利用 texture buffer 传送到 shader 中,这里创建 texture buffer object,然后将数据导入 tbo,然后创建纹理,将 tbo 和纹理绑定:
GLuint trianglesTextureBuffer;//创建数据缓冲区纹理
GLuint tbo0;//缓冲区对象
glGenBuffers(1, &tbo0);
glBindBuffer(GL_TEXTURE_BUFFER, tbo0);//绑定缓冲区对象
glBufferData(GL_TEXTURE_BUFFER, triangles_encoded.size() * sizeof(Triangle_encoded),
	 &triangles_encoded[0], GL_STATIC_DRAW);//将数据放入缓冲区
glGenTextures(1, &trianglesTextureBuffer);
glBindTexture(GL_TEXTURE_BUFFER, trianglesTextureBuffer);//绑定缓冲区纹理
glTexBuffer(GL_TEXTURE_BUFFER, GL_RGB32F, tbo0);//用 glTexBuffer 将 tbo 中的数据关联到 texture buffer
  • 在shader中解码数据:
#define SIZE_TRIANGLE   12 //长度12

uniform samplerBuffer triangles;

...

// 获取第 i 下标的三角形
Triangle getTriangle(int i) {
    int offset = i * SIZE_TRIANGLE;
    Triangle t;

    // 顶点坐标
    t.p1 = texelFetch(triangles, offset + 0).xyz;
    t.p2 = texelFetch(triangles, offset + 1).xyz;
    t.p3 = texelFetch(triangles, offset + 2).xyz;
    // 法线
    t.n1 = texelFetch(triangles, offset + 3).xyz;
    t.n2 = texelFetch(triangles, offset + 4).xyz;
    t.n3 = texelFetch(triangles, offset + 5).xyz;

    return t;
}

// 获取第 i 下标的三角形的材质
Material getMaterial(int i) {
    Material m;

    int offset = i * SIZE_TRIANGLE;
    vec3 param1 = texelFetch(triangles, offset + 8).xyz;
    vec3 param2 = texelFetch(triangles, offset + 9).xyz;
    vec3 param3 = texelFetch(triangles, offset + 10).xyz;
    vec3 param4 = texelFetch(triangles, offset + 11).xyz;
    
    m.emissive = texelFetch(triangles, offset + 6).xyz;
    m.baseColor = texelFetch(triangles, offset + 7).xyz;
    m.subsurface = param1.x;
    m.metallic = param1.y;
    m.specular = param1.z;
    m.specularTint = param2.x;
    m.roughness = param2.y;
    m.anisotropic = param2.z;
    m.sheen = param3.x;
    m.sheenTint = param3.y;
    m.clearcoat = param3.z;
    m.clearcoatGloss = param4.x;
    m.IOR = param4.y;
    m.transmission = param4.z;

    return m;
}

4.在 shader 中进行三角形求交

  • 定义:
// 光线
struct Ray {
    vec3 startPoint;
    vec3 direction;
};
// 光线求交结果
struct HitResult {
    bool isHit;             // 是否命中
    bool isInside;          // 是否从内部命中
    float distance;         // 与交点的距离
    vec3 hitPoint;          // 光线命中点
    vec3 normal;            // 命中点法线
    vec3 viewDir;           // 击中该点的光线的方向
    Material material;      // 命中点的表面材质
};
  • 求交方式大体和之前的光线投射相似:首先是求解光线和三角形所在平面的距离 t,有了距离顺势求出交点 P。求出交点之后,判断交点是否在三角形内。这里通过叉乘的方向和法相是否同向来判断。如果三次叉乘都和 N 同向,说明 P 在三角形中
#define INF             114514.0

// 光线和三角形求交 
HitResult hitTriangle(Triangle triangle, Ray ray) {
    HitResult res;
    res.distance = INF;
    res.isHit = false;
    res.isInside = false;

    vec3 p1 = triangle.p1;
    vec3 p2 = triangle.p2;
    vec3 p3 = triangle.p3;

    vec3 S = ray.startPoint;    // 射线起点
    vec3 d = ray.direction;     // 射线方向
    vec3 N = normalize(cross(p2-p1, p3-p1));    // 法向量

    // 从三角形背后(模型内部)击中
    if (dot(N, d) > 0.0f) {
        N = -N;   
        res.isInside = true;
    }

    // 如果视线和三角形平行
    if (abs(dot(N, d)) < 0.00001f) return res;

    // 距离
    float t = (dot(N, p1) - dot(S, N)) / dot(d, N);
    if (t < 0.0005f) return res;    // 如果三角形在光线背面

    // 交点计算
    vec3 P = S + d * t;

    // 判断交点是否在三角形中
    vec3 c1 = cross(p2 - p1, P - p1);
    vec3 c2 = cross(p3 - p2, P - p2);
    vec3 c3 = cross(p1 - p3, P - p3);
    bool r1 = (dot(c1, N) > 0 && dot(c2, N) > 0 && dot(c3, N) > 0);
    bool r2 = (dot(c1, N) < 0 && dot(c2, N) < 0 && dot(c3, N) < 0);

    // 命中,封装返回结果
    if (r1 || r2) {
        res.isHit = true;
        res.hitPoint = P;
        res.distance = t;
        res.normal = N;
        res.viewDir = d;
        // 根据交点位置插值顶点法线
        float alpha = (-(P.x-p2.x)*(p3.y-p2.y) + (P.y-p2.y)*(p3.x-p2.x)) / (-(p1.x-p2.x-0.00005)*(p3.y-p2.y+0.00005) + (p1.y-p2.y+0.00005)*(p3.x-p2.x+0.00005));
        float beta  = (-(P.x-p3.x)*(p1.y-p3.y) + (P.y-p3.y)*(p1.x-p3.x)) / (-(p2.x-p3.x-0.00005)*(p1.y-p3.y+0.00005) + (p2.y-p3.y+0.00005)*(p1.x-p3.x+0.00005));
        float gama  = 1.0 - alpha - beta;
        vec3 Nsmooth = alpha * triangle.n1 + beta * triangle.n2 + gama * triangle.n3;
        Nsmooth = normalize(Nsmooth);
        res.normal = (res.isInside) ? (-Nsmooth) : (Nsmooth);
    }

    return res;
}

然后我们编写一个函数,暴力遍历三角形数组进行求交,返回最近的交点:

#define INF             114514.0

// 暴力遍历数组下标范围 [l, r] 求最近交点
HitResult hitArray(Ray ray, int l, int r) {
    HitResult res;
    res.isHit = false;
    res.distance = INF;
    for(int i=l; i<=r; i++) {
        Triangle triangle = getTriangle(i);
        HitResult r = hitTriangle(triangle, ray);
        if(r.isHit && r.distance<res.distance) {
            res = r;
            res.material = getMaterial(i);
        }
    }
    return res;
}

5.相机配置

  • 相机位于 vec3(0, 0, 4),看向 z 轴负方向,根据画布像素的 NDC 坐标来投射射线。这里投影平面长宽均为 2.0,而 zNear 为 2.0,这保证了 50° 左右的视场角:
Ray ray;
ray.startPoint = vec3(0, 0, 4);
vec3 dir = vec3(pix.xy, 2) - ray.startPoint;
ray.direction = normalize(dir);

三、使用线性化的BVH树进行优化

1. 构建BVH

虽然可以成功遍历三角形,但是我们需要更加高效的遍历,需要使用到。但是在 GLSL 中 没有指针 这一概念,我们需要将使用 指针 的树形结构改为使用 数组下标 作为指针的线性化二叉树。(计算下标来代替指针)

GPUImageView 设置光圈数 gpu光线追踪怎么关_图形学

  • 原来的 BVH 节点结构体,内容分为三部分,分别是左右孩子,AABB 碰撞盒,叶子节点信息,其中 AA 为极小点,BB 为极大点。因为不能使用指针 所以只能用数组下标。
// BVH 树节点
//这里还引入了一个小变化:一个叶子节点可以保存多个三角形
//n 表示该叶子节点的三角形数目,index 表示该节点第一个三角形
struct BVHNode {
    int left, right;    // 左右子树索引
    int n, index;       // 叶子节点信息               
    vec3 AA, BB;        // 碰撞盒
};

线性化二叉树也很简单,只需要每次创建节点的时候,将 new Node() 改为 push_back() 即插入数组,而下标的索引方式是照常的。

// 构建 BVH
int buildBVH(std::vector<Triangle>& triangles, std::vector<BVHNode>& nodes, int l, int r, int n) {
    if (l > r) return 0;

    // 注:
    // 此处不可通过指针,引用等方式操作,必须用 nodes[id] 来操作
    // 因为 std::vector<> 扩容时会拷贝到更大的内存,那么地址就改变了
    // 而指针,引用均指向原来的内存,所以会发生错误
    nodes.push_back(BVHNode());
    int id = nodes.size() - 1;   // 注意: 先保存索引
    
    nodes[id] 的属性初始化 ...

    // 计算 AABB
    for (int i = l; i <= r; i++) {
        ...		// 遍历三角形 计算 AABB
    }

    // 不多于 n 个三角形 返回叶子节点
    if ((r - l + 1) <= n) {
        nodes[id].n = r - l + 1;
        nodes[id].index = l;
        return id;
    }

    // 否则递归建树
    // 按 x,y,z 划分数组
    std::sort(...)

    // 递归
    int mid = (l + r) / 2;
    int left = buildBVH(triangles, nodes, l, mid, n);
    int right = buildBVH(triangles, nodes, mid + 1, r, n);

    nodes[id].left = left;
    nodes[id].right = right;

    return id;
}

2. BVH 数据传送到 shader

struct BVHNode_encoded {
    vec3 childs;        // (left, right, 保留)
    vec3 leafInfo;      // (n, index, 保留)
    vec3 AA, BB;        
};

shader 中解码 BVHNode 的代码

// 获取第 i 下标的 BVHNode 对象
BVHNode getBVHNode(int i) {
    BVHNode node;

    // 左右子树
    int offset = i * SIZE_BVHNODE;
    ivec3 childs = ivec3(texelFetch(nodes, offset + 0).xyz);
    ivec3 leafInfo = ivec3(texelFetch(nodes, offset + 1).xyz);
    node.left = int(childs.x);
    node.right = int(childs.y);
    node.n = int(leafInfo.x);
    node.index = int(leafInfo.y);

    // 包围盒
    node.AA = texelFetch(nodes, offset + 2).xyz;
    node.BB = texelFetch(nodes, offset + 3).xyz;

    return node;
}
投射光线 

...

for(int i=0; i<nNodes; i++) {
    BVHNode node = getBVHNode(i);
    if(node.n>0) {
        int L = node.index;
        int R = node.index + node.n - 1;
        HitResult res = hitArray(ray, L, R);
        if(res.isHit) fragColor = vec4(res.material.color, 1);
    }
}

3. 和 AABB 盒子求交

  • 对于轴对齐包围盒,光线穿入穿出 xoy,xoz,yoz 平面,会有三组穿入点穿出点。如果找到一组穿入点穿出点,使得光线起点距离穿入点的距离 小于 光线起点距离穿出点的距离,即 t0 < t1 则说明命中
    取 out 中最小的距离记作 t1,和 in 中最大的距离记作 t0,然后看是否 t1 > t0 如果满足等式,则说明命中:
  • GLSL求交代码(n 即近交点 near,也就是 in
    f 即远交点 far,也就是 out)
// 和 aabb 盒子求交,没有交点则返回 -1
//n 即近交点 near,也就是 in,f 即远交点 far,也就是 out
float hitAABB(Ray r, vec3 AA, vec3 BB) {
    vec3 invdir = 1.0 / r.direction;

    vec3 f = (BB - r.startPoint) * invdir;
    vec3 n = (AA - r.startPoint) * invdir;

    vec3 tmax = max(f, n);
    vec3 tmin = min(f, n);

    float t1 = min(tmax.x, min(tmax.y, tmax.z));
    float t0 = max(tmin.x, max(tmin.y, tmin.z));

    return (t1 >= t0) ? ((t0 > 0.0) ? (t0) : (t1)) : (-1);
}
  • 测试代码:对于 BVH 的根节点(1 号节点)我们分别和其左右子树求交,如果左子树命中则返回红色,右子树命中则返回绿色,两个都命中则返回黄色
...

BVHNode node = getBVHNode(1);
BVHNode left = getBVHNode(node.left);
BVHNode right = getBVHNode(node.right);

float r1 = hitAABB(ray, left.AA, left.BB);  
float r2 = hitAABB(ray, right.AA, right.BB);  

vec3 color;
if(r1>0) color = vec3(1, 0, 0);
if(r2>0) color = vec3(0, 1, 0);
if(r1>0 && r2>0) color = vec3(1, 1, 0);

...

GPUImageView 设置光圈数 gpu光线追踪怎么关_数组_02

4. 非递归遍历 BVH 树

  • 因为在GPU 上面没有栈的概念,也不能执行递归程序,所以要认为写出 BVH二叉树的遍历代码,自定义栈。
    对于 BVH 树,在和 根 节点求交 之后 ,我们总是查找它的左右子树,这相当于二叉树的 先序遍历
  • 通过维护一个栈来保存节点。首先将树根入栈,然后 while(!stack.empty()) 进行循环(注意 先访问的节点后入栈 ,因为栈的存取顺序是相反的,这样保证下一次取栈顶元素,一定是先被访问的节点。):
  1. 从栈中弹出节点 root
  2. 如果右树非空,将 root 的右子树压入栈中
  3. 如果左树非空,将 root 的左子树压入栈中
  • 通过使用数组与下标来模拟栈来完成BVH盒子求交操作。
    遍历 BVH 求交
// 遍历 BVH 求交
HitResult hitBVH(Ray ray) {
    HitResult res;
    res.isHit = false;
    res.distance = INF;

    // 栈
    int stack[256];
    int sp = 0;

    stack[sp++] = 1;
    while(sp>0) {
        int top = stack[--sp];
        BVHNode node = getBVHNode(top);
        
        // 是叶子节点,遍历三角形,求最近交点
        if(node.n>0) {
            int L = node.index;
            int R = node.index + node.n - 1;
            HitResult r = hitArray(ray, L, R);
            if(r.isHit && r.distance<res.distance) res = r;
            continue;
        }
        
        // 和左右盒子 AABB 求交
        float d1 = INF; // 左盒子距离
        float d2 = INF; // 右盒子距离
        if(node.left>0) {
            BVHNode leftNode = getBVHNode(node.left);
            d1 = hitAABB(ray, leftNode.AA, leftNode.BB);
        }
        if(node.right>0) {
            BVHNode rightNode = getBVHNode(node.right);
            d2 = hitAABB(ray, rightNode.AA, rightNode.BB);
        }

        // 在最近的盒子中搜索
        if(d1>0 && d2>0) {
            if(d1<d2) { // d1<d2, 左边先
                stack[sp++] = node.right;
                stack[sp++] = node.left;
            } else {    // d2<d1, 右边先
                stack[sp++] = node.left;
                stack[sp++] = node.right;
            }
        } else if(d1>0) {   // 仅命中左边
            stack[sp++] = node.left;
        } else if(d2>0) {   // 仅命中右边
            stack[sp++] = node.right;
        }
    }

    return res;
}

这里通过交点的距离判断,优先查找近的盒子,能够大大加速。将原来的暴力查找的 hitArray 换成新的 hitBVH 函数

四、开始光线追踪

1. 原理

渲染方程:

GPUImageView 设置光圈数 gpu光线追踪怎么关_图形学_03


因为光路可逆,沿着 wi 方向 射入 p 点的光的能量,等于从 q 点出发,沿着 wi 方向 射出 的光的能量:

GPUImageView 设置光圈数 gpu光线追踪怎么关_子节点_04


伪代码

GPUImageView 设置光圈数 gpu光线追踪怎么关_图形学_05


每次递归的返回结果都乘以了 f_r * cosine / pdf,但是对于 shader 中没有递归,可以用循环代替。变量 history 来记录每次递归,返回结果的累乘。

给定一个点 p 的表面信息,即 HitResult 结构体,一个入射光线方向 viewDir 和一个最大弹射次数,然后通过 pathTracing 函数求解 p 点的颜色:

投射光线

...

// primary hit
HitResult firstHit = hitBVH(ray);
vec3 color;

if(!firstHit.isHit) {
    color = vec3(0);
} else {
    vec3 Le = firstHit.material.emissive;
    int maxBounce = 2;
    vec3 Li = pathTracing(firstHit, maxBounce);
    color = Le + Li;
}

fragColor = vec4(color, 1.0);

2. 辅助函数

一共需要用到3个辅助函数

1. 0 ~ 1 **均匀分布的随机数**的函数
 2. 生成**半球均匀分布的随机向量**的函数
 3. 任意向量投影到 **法向半球** 的函数
  • 首先是 0 ~ 1 均匀分布的随机数:要一个 uniform uint 变量frameCounter帧计数器)做随机种子,同时还需要 width,height 和当前屏幕像素的 NDC 坐标pix 变量
uniform uint frameCounter;

uint seed = uint(
    uint((pix.x * 0.5 + 0.5) * width)  * uint(1973) + 
    uint((pix.y * 0.5 + 0.5) * height) * uint(9277) + 
    uint(frameCounter) * uint(26699)) | uint(1);

uint wang_hash(inout uint seed) {
    seed = uint(seed ^ uint(61)) ^ uint(seed >> uint(16));
    seed *= uint(9);
    seed = seed ^ (seed >> 4);
    seed *= uint(0x27d4eb2d);
    seed = seed ^ (seed >> 15);
    return seed;
}
 
float rand() {
    return float(wang_hash(seed)) / 4294967296.0;
}
  • 半球均匀分布代码引自 PBRT 13.6ξ 1 和ξ 2 是0-1分布的随机数
// 半球均匀采样
vec3 SampleHemisphere() {
    float z = rand();
    float r = max(0, sqrt(1.0 - z*z));
    float phi = 2.0 * PI * rand();
    return vec3(r * cos(phi), r * sin(phi), z);
}

这里半球的 “上方向” 是 z 轴,需要做一次投影来对应到法向半球的法线 N 方向。该部分的代码引自 GPU Path Tracing in Unity – Part 2

// 将向量 v 投影到 N 的法向半球
vec3 toNormalHemisphere(vec3 v, vec3 N) {
    vec3 helper = vec3(1, 0, 0);
    if(abs(N.x)>0.999) helper = vec3(0, 0, 1);
    vec3 tangent = normalize(cross(N, helper));
    vec3 bitangent = normalize(cross(N, tangent));
    return v.x * tangent + v.y * bitangent + v.z * N;
}

3. pathTracing 的实现

这里我们仅实现漫反射

半球面积为 2 π,这里我们取漫反射的概率密度函数 pdf 为 1 / 2 π ,此外关于 f_r (这里 f_r 实际上是 BRDF,即双向反射分布函数
函数 BRDF(p, wi, wo) 的值,描述了光从 wi 射入 p 点,散射后有多少光能从 wo 射出一个结论是漫反射的 BRDF 就是颜色值除以 pi)这里我们取表面颜色除以 π ,这里姑且看作一个常数

// 路径追踪
vec3 pathTracing(HitResult hit, int maxBounce) {

    vec3 Lo = vec3(0);      // 最终的颜色
    vec3 history = vec3(1); // 递归积累的颜色

    for(int bounce=0; bounce<maxBounce; bounce++) {
        // 随机出射方向 wi
        vec3 wi = toNormalHemisphere(SampleHemisphere(), hit.normal);

        // 漫反射: 随机发射光线
        Ray randomRay;
        randomRay.startPoint = hit.hitPoint;
        randomRay.direction = wi;
        HitResult newHit = hitBVH(randomRay);

        float pdf = 1.0 / (2.0 * PI);                                   // 半球均匀采样概率密度
        float cosine_o = max(0, dot(-hit.viewDir, hit.normal));         // 入射光和法线夹角余弦
        float cosine_i = max(0, dot(randomRay.direction, hit.normal));  // 出射光和法线夹角余弦
        vec3 f_r = hit.material.baseColor / PI;                         // 漫反射 BRDF

        // 未命中
        if(!newHit.isHit) {
            break;
        }
        
        // 命中光源积累颜色
        vec3 Le = newHit.material.emissive;
        Lo += history * Le * f_r * cosine_i / pdf;
        
        // 递归(步进)
        hit = newHit;
        history *= f_r * cosine_i / pdf;  // 累积颜色
    }
    
    return Lo;
}

运行后的结果非常嘈杂,这是因为我们要将每一帧的结果 累加 作为积分的值,而不是单独的取每一个离散的采样,为此需要混合多个帧的绘制结果

4. 多帧混合与后处理

  • 使用 defer render 延迟渲染管线
  • 需要维护一块纹理 lastFrame 来保存上一帧的图像,同时为了对输出进行后处理(比如伽马矫正,色调映射),我们需要实现一个简单管线

    这里封装一个 RenderPass 类,其中 colorAttachments 是要传入下一 pass 的纹理 id,这些纹理将作为帧缓冲的颜色附件。然后每个 pass 直接调用 draw 就行,其中 texPassArray 是 上一个 pass 的 colorAttachments
class RenderPass {
public:
    std::vector<GLuint> colorAttachments;
    // 其他属性 ...
    
    void bindData(bool finalPass = false) {
        
    }
    void draw(std::vector<GLuint> texPassArray = {}) {
        
    }
};

完成渲染管线后,在pass1的片元着色器增加多帧的混合效果

uniform sampler2D lastFrame;

...

// 和上一帧混合
vec3 lastColor = texture2D(lastFrame, pix.xy*0.5+0.5).rgb;
color = mix(lastColor, color, 1.0/float(frameCounter+1));

5. 增加HDR环境贴图

一般的图片亮度拉满也就 255,但是 HDR 亮度是整个浮点数范围,能够较好的表示现实中的光照,所以用来做环境贴图

  • 首先可以在 ploy heaven 上面下载到 HDR 贴图:
  • 然后我们需要读取 HDR 图片,SOIL 显然是读不了的(其实有伪 HDR,是通过 RGBE 或者 RGBdivA,RGBdivA2 来实现的,不过似乎有一个 A 通道始终为 128 的 BUG 所以无法使用
    这里我们选择一个轻量级的库:HDR Image Loader,它无需安装,只需要 include 一下就可用。它的代码在 这里
#include "lib/hdrloader.h"

...

// hdr 全景图
HDRLoaderResult hdrRes;
bool r = HDRLoader::load("./skybox/sunset.hdr", hdrRes);
GLuint hdrMap = 创建一张纹理()
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB32F, hdrRes.width, hdrRes.height, 0, GL_RGB, GL_FLOAT, hdrRes.cols);
  • 加载出现问题:
  1. 图像有点暗,那是因为没有伽马矫正
  2. 图像是反的,待会采样的时候 flip 一下 y 就行了
  3. 图像很扭曲:待会我们用 spherical coord 采样就正常了

我们给定一个向量 v,将其转为采样 HDR图的 纹理坐标 uv,代码参考 stack overflow

// 将三维向量 v 转为 HDR map 的纹理坐标 uv
vec2 SampleSphericalMap(vec3 v) {
    vec2 uv = vec2(atan(v.z, v.x), asin(v.y));
    uv /= vec2(2.0 * PI, PI);
    uv += 0.5;
    uv.y = 1.0 - uv.y;
    return uv;
}

然后采样HDR贴图

// 获取 HDR 环境颜色
vec3 sampleHdr(vec3 v) {
    vec2 uv = SampleSphericalMap(normalize(v));
    vec3 color = texture2D(hdrMap, uv).rgb;
    //color = min(color, vec3(10));
    return color;
}

原作者写的有关HDR亮度的注意的地方

GPUImageView 设置光圈数 gpu光线追踪怎么关_GPUImageView 设置光圈数_06


然后将 main 函数中,primary ray 的 miss 的处理中,color = vec3(0) 换为:

color = sampleHdr(ray.direction);

此外,pathTracing 中,ray miss 的时候也要处理:

// 未命中
if(!newHit.isHit) {
    vec3 skyColor = sampleHdr(randomRay.direction);
    Lo += history * skyColor * f_r * cosine_i / pdf;
    break;
}

完整代码

原文章中,接下来用自己的方式整理一下原文章作者的代码思路。然后就用这个框架来添加东西。