光线追踪学习:GPU端光线追踪学习

文章目录

  • 原文学习
  • 前言
  • 一、前置条件
    • 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环境贴图
  • 完整代码


原文学习

GPU加速光线追踪

前言

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

一、前置条件

1.内容

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

2.难点

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

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

1.画面渲染

  • 上下为[-1,1]的画面中:
    光线追踪学习:GPU端光线追踪学习_第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 分量有效。他们和内存数据的映射关系如下:
    光线追踪学习:GPU端光线追踪学习_第2张图片
    也可以使用 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 在三角形中
    光线追踪学习:GPU端光线追踪学习_第3张图片

    #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° 左右的视场角:
    光线追踪学习:GPU端光线追踪学习_第4张图片

    Ray ray;
    ray.startPoint = vec3(0, 0, 4);
    vec3 dir = vec3(pix.xy, 2) - ray.startPoint;
    ray.direction = normalize(dir);
    

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

1. 构建BVH

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

光线追踪学习:GPU端光线追踪学习_第5张图片

  • 原来的 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 如果满足等式,则说明命中:
    光线追踪学习:GPU端光线追踪学习_第6张图片

  • 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);
    
    ...
    
    

光线追踪学习:GPU端光线追踪学习_第7张图片

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
                    stack[sp++] = node.right;
                    stack[sp++] = node.left;
                } else {    // d2
                    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. 原理

渲染方程:
光线追踪学习:GPU端光线追踪学习_第8张图片
因为光路可逆,沿着 wi 方向 射入 p 点的光的能量,等于从 q 点出发,沿着 wi 方向 射出 的光的能量:
光线追踪学习:GPU端光线追踪学习_第9张图片
伪代码
光线追踪学习:GPU端光线追踪学习_第10张图片
每次递归的返回结果都乘以了 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 来保存上一帧的图像,同时为了对输出进行后处理(比如伽马矫正,色调映射),我们需要实现一个简单管线
    光线追踪学习:GPU端光线追踪学习_第11张图片
    这里封装一个 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亮度的注意的地方
光线追踪学习:GPU端光线追踪学习_第12张图片
然后将 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;
}


完整代码

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

你可能感兴趣的:(图形学,opengl学习,光线追踪学习,图形学)