NOTE阅读前,请确保你有一定的 OpenGL 基础,且已经跟着《Ray Tracing in Next Week》完成了一遍 CPU 路径追踪器搭建
为了节省空间,本文的代码均折叠了起来,需要你手动展开。同时,每一个代码块都已经写好了详细的注释,方便逐行理解。
由于我也是第一次接触 Comepute Shader,我会从最开始写起。为了回忆之前所学,我在一些基础的 OpenGL 内容后面也给了详细解释。
至于为什么要如此详细地给出完整代码,是为了给读者节约点自己架构的时间(懒人模式)
开始#
一张图厘清 Compute Shader 数据流#
Compute Shader 数据流
开始我们的第一个 “Hello.comp”#
下面是一个简单的 hello.comp 文件写法:
hello.comp 文件内容
1#version 430 core2
3layout(local_size_x = 16, local_size_y = 16) in;4layout(rgb32f, binding = 0) uniform image2D outputImage; // 声明一张可读写图像5
6void main()7{8 ivec2 pixelCoord = ivec2(gl_GlobalInvocationID.xy); // gl_GlobalInvocation 是 GLSL Compute Shader 的内置输入变量,类型是 uvec39 ivec2 imageSize = imageSize(outputImage); // GLSL内置函数,直接问图像多大,不用CPU传参数10
11 // 检查像素坐标是否越界12 if (pixelCoord.x >= imageSize.x || pixelCoord.y >= imageSize.y)13 return;14
15 vec2 uv = vec2(pixelCoord) / vec2(imageSize);16 imageStore(outputImage, pixelCoord, vec4(uv.x, uv.y, 0.2, 1.0)); // 3个参数:在什么上面画,画到哪个坐标,颜色RGBA17}18
19// 流程:20// compute shader 把结果写进一张纹理(用image2D写入)21// 一个全屏三角形/quad,配合fragment shader把这张纹理采样出来显示22
23// 我们在 CPU 端计算需要的线程采用的是向上取整,那么难免会因为尺寸不是整倍数而导致线程有多余24// 这些多余的线程在检查到自己负责的坐标超出图像范围时,会立即 return,结束执行。由于我的显示结果处理方式是全屏 quad + 纹理采样,所以下面用 quad.vert、quad.frag 画一个全屏三角形/quad:
quad.vert 文件内容
1#version 430 core2
3// 不需要顶点缓冲,用顶点ID技巧生成一个全屏三角形4out vec2 texCoord;5
6void main()7{8 vec2 pos = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2); // 生成屏幕坐标,三顶点依次为 (0,0), (2, 0), (0, 2), gl_VertexID 是 GLSL 的内置整型变量,表示当前顶点在绘制命令中的索引。9 // 当你用 glDrawArrays(GL_TRIANGLES, 0, 3) 绘制 3 个顶点时,gl_VertexID 的值依次为 0、1、2。10 texCoord = pos;11 gl_Position = vec4(pos * 2.0 - 1.0, 0.0, 1.0); // 转换到裁剪坐标。这里坐标计算是很经典的将三角形放大处理,使其占满范围是 [-1, 1] 的裁剪空间。12}13// 流程:14// 每个顶点独立计算自己的位置,完全并行,结果直接输入到光栅化器。TIP不用建实际的VAO/VBO传顶点数据,靠
gl_VertexID(0,1,2)直接在shader里算出一个覆盖全屏的大三角形。你只需要在CPU端调用glDrawArrays(GL_TRIANGLES, 0, 3),不用绑定任何顶点缓冲。
quad.frag 文件内容
1#version 430 core2
3in vec2 texCoord;4out vec4 fragColor;5
6uniform sampler2D screenTexture; // 声明一个 2D 纹理采样器 uniform,它是在应用程序中(CPU 端)传入的。7
8void main()9{10 fragColor = texture(screenTexture, texCoord); // 在 screenTexture 上,用纹理坐标 texCoord 进行采样,将得到的颜色值直接赋给输出 fragColor。11}接下来是 main.cpp 文件内容,里面包含了对着色器文件获取、编译、链接的自定义工具函数(后面再进行封装)。
main.cpp 文件内容
1#include <glad/glad.h>2#include <GLFW/glfw3.h>3
4#include <iostream>5#include <fstream>6#include <sstream>7#include <string>8
9const int SCR_WIDTH = 800;10const int SCR_HEIGHT = 600;11
12// ---------一个检查错误的小工具函数,在关键调用后面加上可以查看 OpenGL 的静默失败-------------13void checkGLError(const char* label)14{15 GLenum err;16 while ((err = glGetError()) != GL_NO_ERROR) {17 std::cerr << "[GL ERROR] " << label << ": 0x" << std::hex << err << std::dec << "\n";18 }19}20
21
22// ---- 工具函数:读取shader文件文本 ----23std::string readFile(const char* path)24{25 std::ifstream file(path);26 if (!file.is_open())27 {28 std::cerr << "Failed to open shader file: " << path << "\n";29 return "";30 }31 std::stringstream ss;32 ss << file.rdbuf(); // file.rdbuf() 返回文件流的底层缓冲区指针,ss << 把整个文件缓冲区的剩余内容一次性导入ss字符串流中33 return ss.str();34}35
36// ---- 工具函数:编译单个shader,返回着色器ID,带错误检查 ----37GLuint compileShader(GLenum type, const std::string& source)38{39 GLuint shader = glCreateShader(type); // 创建一个空的着色器对象,并返回它的句柄40 const char* src = source.c_str();41 glShaderSource(shader, 1, &src, nullptr); // 给着色器对象内部拷贝一份源码副本42 glCompileShader(shader); // 编译43
44 GLint success;45 glGetShaderiv(shader, GL_COMPILE_STATUS, &success);46 if (!success)47 {48 char infoLog[1024];49 glGetShaderInfoLog(shader, 1024, nullptr, infoLog);50 std::cerr << "Shader compile error:\n" << infoLog << "\n";51 }52 return shader;53}54
55// ---- 工具函数:链接program,返回GPU可执行程序,带错误检查 ----56GLuint linkProgram(std::initializer_list<GLuint> shaders)57{58 GLuint program = glCreateProgram();59 for (GLuint s : shaders) glAttachShader(program, s);60 glLinkProgram(program);61
62 GLint success;63 glGetProgramiv(program, GL_LINK_STATUS, &success);64 if (!success)65 {66 char infoLog[1024];67 glGetProgramInfoLog(program, 1024, nullptr, infoLog);68 std::cerr << "Program link error:\n" << infoLog << "\n";69 }70 for (GLuint s : shaders) glDeleteShader(s); // 链接完清空shaders71 return program;72}73
74int main()75{76 if (!glfwInit())77 {78 std::cerr << "GLFW init failed\n";79 return -1;80 }81
82 glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 4);83 glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);84 glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);85
86 GLFWwindow* window = glfwCreateWindow(800, 600, "Compute Shader Raytracer", nullptr, nullptr);87 if (!window)88 {89 std::cerr << "Window creation failed\n";90 glfwTerminate();91 return -1;92 }93 glfwMakeContextCurrent(window);94
95 if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress))96 {97 std::cerr << "GLAD init failed\n";98 return -1;99 }100
101
102 // ---- 没有绑定VAO的 glDrawArrays不会执行,所以我们仍要绑定一个 VAO,哪怕根本不用顶点属性----103 GLuint dummyVAO;104 glGenVertexArrays(1, &dummyVAO);105 glBindVertexArray(dummyVAO);106
107 // ----------------- 创建输出图像(compute shader写入的目标)-------------------------108 GLuint outputTexture;109 glGenTextures(1, &outputTexture); // 生成一个纹理对象ID,并保存到outputTexture110 glBindTexture(GL_TEXTURE_2D, outputTexture); // 绑定对象到 GL_TEXTURE_2D目标,因为 OpenGL 是状态机,所以后续对 GL_TEXTURE_2D的操作都会作用到这个纹理上111 glTexStorage2D(GL_TEXTURE_2D, 1, GL_RGBA32F, SCR_WIDTH, SCR_HEIGHT); // glTexStorage2D 而不是 glTexImage2D —— 这是创建"不可变存储"的纹理,compute shader写入这种纹理效率更好,也是目前推荐用法。112 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);113 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);114 glBindTexture(GL_TEXTURE_2D, 0); // 解绑 GL_TEXTURE_2D115
116 GLuint computeShader = compileShader(GL_COMPUTE_SHADER, readFile("src/shaders/hello.comp")); // 相对路径,相对于运行exe时的工作目录,117 GLuint computeProgram = linkProgram({computeShader});118
119 GLuint vertShader = compileShader(GL_VERTEX_SHADER, readFile("src/shaders/quad.vert"));120 GLuint fragShader = compileShader(GL_FRAGMENT_SHADER, readFile("src/shaders/quad.frag"));121 GLuint quadProgram = linkProgram({vertShader, fragShader});122
123
124 while (!glfwWindowShouldClose(window))125 {126 // 派发compute shader,把结果写进 outputTexture127 glUseProgram(computeProgram);128 glBindImageTexture(0, outputTexture, 0, GL_FALSE, 0, GL_WRITE_ONLY, GL_RGBA32F); // glBindImageTexture(0... 这里的 0 要和.comp里 binding = 0 对应上, 这是CPU和GPU之间的接口约定129
130 GLuint groupsX = (SCR_WIDTH + 15) / 16; // 整数向上取整131 GLuint groupsY = (SCR_HEIGHT + 15) / 16;132 glDispatchCompute(groupsX, groupsY, 1); // 派发 Compute Shader 工作:告诉 GPU 启动 groupsX × groupsY × 1 个工作组。(z=1)133 glMemoryBarrier(GL_SHADER_IMAGE_ACCESS_BARRIER_BIT); // 确保屏障之前的所有纹理图像写入(imageStore)对屏障之后的同一图像单元访问可见。134
135 // 把结果画到屏幕136 glClear(GL_COLOR_BUFFER_BIT);137 glUseProgram(quadProgram);138 glActiveTexture(GL_TEXTURE0); // 激活 0 号纹理139 glBindTexture(GL_TEXTURE_2D, outputTexture);140 glUniform1i(glGetUniformLocation(quadProgram, "screenTexture"), 0); // 设置着色器程序 quadProgram 中的 uniform 采样器 screenTexture 的值。glGetUniformLocation 查询 "screenTexture" 的位置。值 0 表示它从纹理单元 0 采样141 glDrawArrays(GL_TRIANGLES, 0, 3);142
143 glfwSwapBuffers(window);144 glfwPollEvents();145 }146
147 glfwTerminate();148 return 0;149
150}WARNING这里有一个极其隐蔽的坑! 在 OpenGL 3.2+ Core Profile 中,所有绘制命令必须在绑定了一个非零 VAO 的情况下才能执行,否则
glDrawArrays会直接失败(产生GL_INVALID_OPERATION错误)。所以即使你的全屏三角形用的无顶点缓冲技巧,仍然需要绑定一个 VAO,哪怕里面什么都没有。
如果一切顺利,你将得到如下这个彩色窗口
第一个窗口!
封装 Shader 类#
这里先不封装 Camera 类:CPU 路径追踪里Camera类存在的意义,是因为相机要在C++端生成射线(get_ray(u,v)这种方法调用)。但GPU版本不一样——射线生成这段逻辑要整个搬进GLSL里,C++端的Camera退化成只负责把几个vec3(origin、lower_left_corner、horizontal、vertical)通过uniform传给GPU,封装成类反而多一层不必要抽象。
Shader 类就很值得做:
Shader.h
1#ifndef SHADER_H2#define SHADER_H3
4#include "glad/glad.h"5#include <string>6#include <fstream>7#include <sstream>8#include <iostream>9#include <initializer_list>10
11class Shader12{13 public:14 GLuint program;15
16 // compute shader 专用17 static Shader computeShader(const std::string& path)18 {19 GLuint cs = compile(GL_COMPUTE_SHADER, readFile(path));20 return Shader(link({cs}));21 }22
23 // vertex+fragment组合24 static Shader graphicsShader(const std::string& vertPath, const std::string& fragPath)25 {26 GLuint vs = compile(GL_VERTEX_SHADER, readFile(vertPath));27 GLuint fs = compile(GL_FRAGMENT_SHADER, readFile(fragPath));28 return Shader(link({vs, fs}));29 }30
31 // 激活着色器可执行程序32 void use() const { glUseProgram(program); }33
34 void setInt(const std::string& name, int v) const35 {36 glUniform1i(glGetUniformLocation(program, name.c_str()), v);37 }38
39 void setFloat(const std::string& name, float v) const40 {41 glUniform1f(glGetUniformLocation(program, name.c_str()), v);42 }43
44 void setVec3(const std::string& name, float x, float y, float z) const45 {46 glUniform3f(glGetUniformLocation(program, name.c_str()), x, y, z);47 }48
49 private:50 Shader(GLuint prog) : program(prog) {}51
52 //-------------------- 读取、编译、链接三件套--------------------53
54 static std::string readFile(const std::string& path)55 {56 std::ifstream file(path);57 if (!file.is_open()) { std::cerr << "Failed to open: " << path << "\n"; return ""; }58 std::stringstream ss; ss << file.rdbuf();59 return ss.str();60 }61
62 static GLuint compile(GLenum type, const std::string& src)63 {64 GLuint shader = glCreateShader(type);65 const char* s = src.c_str();66 glShaderSource(shader, 1, &s, nullptr);67 glCompileShader(shader);68 GLint success;69 glGetShaderiv(shader, GL_COMPILE_STATUS, &success);70 if (!success) {71 char log[1024];72 glGetShaderInfoLog(shader, 1024, nullptr, log);73 std::cerr << "Shader compile error:\n" << log << "\n";74 }75 return shader;76 }77
78 static GLuint link(std::initializer_list<GLuint> shaders)79 {80 GLuint prog = glCreateProgram();81 for (GLuint s : shaders) glAttachShader(prog, s);82 glLinkProgram(prog);83 GLint success;84 glGetProgramiv(prog, GL_LINK_STATUS, &success);85 if (!success) {86 char log[1024];87 glGetProgramInfoLog(prog, 1024, nullptr, log);88 std::cerr << "Program link error:\n" << log << "\n";89 }90 for (GLuint s : shaders) glDeleteShader(s);91 return prog;92 }93};94
95#endif单球光追#
新建 src/shaders/raytrace.comp,把RTIOW的相机射线生成 + 球体求交移植过来:
raytrace.comp文件
1#version 430 core2
3layout(local_size_x = 16, local_size_y = 16) in;4layout(rgba32f, binding = 0) uniform image2D outputImage;5
6const float INF = uintBitsToFloat(0x7F800000u);7
8// 相机参数9uniform vec3 camOrigin;10uniform vec3 camLowerLeftCorner;11uniform vec3 camHorizontal;12uniform vec3 camVertical;13
14// 球体参数,暂时先写死一个球,后面再改成 SSBO 存放多个球15uniform vec3 sphereCenter;16uniform float sphereRadius;17
18// 光线与球体求交,返回命中距离,没命中返回-1.019float hitSphere(vec3 center, float radius, vec3 rayOrigin, vec3 rayDir, float t_min, float t_max)20{21 vec3 oc = rayOrigin - center;22 float a = dot(rayDir, rayDir);23 float halfB = dot(oc, rayDir);24 float c = dot(oc, oc) - radius * radius;25
26 float discriminant = halfB * halfB - a * c;27
28 if (discriminant < 0.0) return -1.0;29
30 float root = (-halfB - sqrt(discriminant)) / a;31 if (!(root > t_min && root < t_max))32 {33 root = (-halfB + sqrt(discriminant)) / a;34 if (!(root > t_min && root < t_max))35 return -1.0;36 }37 return root;38}39
40
41vec3 rayColor(vec3 rayOrigin, vec3 rayDir)42{43 float t = hitSphere(sphereCenter, sphereRadius, rayOrigin, rayDir, 0.0001, INF);44 if (t > 0.0)45 {46 vec3 normal = normalize(rayOrigin + rayDir * t - sphereCenter);47 return 0.5 * (normal + vec3(1.0));48 }49
50 vec3 unitDir = normalize(rayDir);51 float a = 0.5 * (unitDir.y + 1.0);52 return (1.0 - a) * vec3(1.0) + a * vec3(0.5, 0.7, 1.0);53}54
55void main()56{57 ivec2 pixelCoord = ivec2(gl_GlobalInvocationID.xy);58 ivec2 imageSize = imageSize(outputImage);59 if (pixelCoord.x >= imageSize.x || pixelCoord.y >= imageSize.y) return;60
61 float u = float(pixelCoord.x) / float(imageSize.x);62 float v = float(pixelCoord.y) / float(imageSize.y);63
64 vec3 rayDir = camLowerLeftCorner + u * camHorizontal + v * camVertical - camOrigin;65 vec3 color = rayColor(camOrigin, rayDir);66
67 imageStore(outputImage, pixelCoord, vec4(color, 1.0));68}同样在main.cpp里面增添相机的参数设置,传入 uniform
main.cpp 更新
1raytraceProgram.use();2glBindImageTexture(0, outputTexture, 0, GL_FALSE, 0, GL_WRITE_ONLY, GL_RGBA32F);3
4// 相机参数,和RTIOW里camera构造函数的计算逻辑一致5float aspectRatio = float(SCR_WIDTH) / float(SCR_HEIGHT);6float viewportHeight = 2.0f;7float viewportWidth = aspectRatio * viewportHeight;8float focalLength = 1.0f;9
10raytraceProgram.setVec3("camOrigin", 0, 0, 0);11raytraceProgram.setVec3("camHorizontal", viewportWidth, 0, 0);12raytraceProgram.setVec3("camVertical", 0, viewportHeight, 0);13raytraceProgram.setVec3("camLowerLeftCorner",14 -viewportWidth/2, -viewportHeight/2, -focalLength);15
16raytraceProgram.setVec3("sphereCenter", 0, 0, -1);17raytraceProgram.setFloat("sphereRadius", 0.5f);18
19GLuint groupsX = (SCR_WIDTH + 15) / 16;20GLuint groupsY = (SCR_HEIGHT + 15) / 16;21glDispatchCompute(groupsX, groupsY, 1);22glMemoryBarrier(GL_SHADER_IMAGE_ACCESS_BARRIER_BIT);这里我们用经典的办法让法线表示颜色,输出一个球。如果一切顺利你将得到下面的输出:
第一彩色球!
BVH#
注:下面真的就是面向我自己的笔记了,主要是代码量大,架构复杂(要把 RTINW 的部分代码移植过来),导致很难写清楚每一步。
大致流程#
我们都知道,在CPU上每个线程都有自己独立的、几MB大的调用栈,完全可以容纳递归,但是GPU就不同。
在GPU中,一个workgroup里成千上万个线程是”锁步”执行的(同一时刻尽量跑同一条指令),每个线程分到的栈空间极小(几百字节到几KB级别),而且大部分GLSL编译器直接不支持递归函数调用,因为编译器需要在编译时确定每个线程占用多少寄存器/栈空间,递归的深度在编译期是不确定的。
所以唯一的解决办法就是:把递归改成用一个显式数组模拟栈的循环,为什么是数组,因为GPU用不了指针。
首先来顺一遍流程,从程序开始,CPU和GPU做了什么。(重点放在bvh构建上)
初始化 OpenGL 环境#
创建GLFW窗口,加载函数指针,生成空VAO,创建一张2D纹理作为compute shader的输出目标
构建场景(C++ 传统 OOP 方式)#
用 shared_ptr<hittable> 构建一个场景世界。
CPU 端递归构建 BVH 树#
调用 bvh_node 构造函数,递归分割、每个节点计算好自己的 AABB,形成一棵树
拍平*#
将递归树翻译成 GPU 可用的连续数组,拍平过后得到两个 vector:(这里的变量名不代表最后的变量名,先理解即可)
flatNodes:vector<GPUBVHNode>,每个元素代表一个树节点(内部/叶子),通过整数索引引用。flatSpheres:vector<GPUSphere>,所有球体数据紧密排列,叶子节点的rightChild指向这里。
上传数据到 GPU#
创建两个 SSBO
-
BVH SSBO (binding = 1):
glBufferData将flatNodes二进制拷贝到显存。
-
Sphere SSBO (binding = 2):
glBufferData将flatSpheres二进制拷贝到显存。
-
设置 Uniform,绑定输出纹理
GPU 渲染每一帧#
派发线程,每个线程进行 BVH 求交,着色,写入纹理等等
对 CPU 端进行 BVH 重构#
BVH 重构可谓是相当有挑战的一部分,下面我们的工作核心是:将面向对象的递归场景树,彻底拍平为 GPU 可用的扁平数组,同时消除运行时多态。
下面只给出了核心函数与文件(有较为详细注释),具体的C++类的改写还得自己来。这一步暂时不管材质和动态模糊(我真的不喜欢动态模糊,所以我把抽象类的接口以及球的构造函数改成了无动态模糊的类型),可以直接从 RTINW 迁移过来的Cpp文件有:aabb.h, bvh.h, hittable_list.h, hittable.h, rtweekend.h, sphere.h, vec3.h
- vec3.h 还是先保留,我们在最后的bvh_h里面拍平树的时候再用glm就是,不是很影响。
- ray.h为何没有迁移?因为光线都是在GPU里面算好的,C++端基本不需要了,最多只是填参数而已,这里我选择把ray相关的函数参数都改成 rayOrigin和rayDir.
- sphere.h基本上就退化为了创建场景时有用,创建一个球的类型
- rtweekend.h基本没用,因为随机函数后面用 GPU随机数生成器
- hittable_list.h里面hittable_list类的hit函数直接架空,因为我们会在GPU端实现。
- aabb.h计算包围盒与光线交点的hit函数直接架空,我们在GPU里实现。
bvh.h完整文件
1#ifndef BVH_H2#define BVH_H3
4
5#include "hittable_list.h"6#include "sphere.h"7
8#include <algorithm>9#include <glm/glm.hpp>10
11// bvh_node 类是RTINW自带的,基本没做任何修改12class bvh_node : public hittable13{14 public:15 bvh_node() {}16
17 bvh_node(std::vector<shared_ptr<hittable>>& objects, size_t start, size_t end)18 {19 int axis = random_int(0, 2);20
21 auto comparator = (axis == 0) ? box_x_compare22 : (axis == 1) ? box_y_compare23 : box_z_compare;24
25
26 size_t object_span = end - start; // start 和 end 是区间下标 [start, end),object_span 即为这个节点应该处理的物体个数。27
28 if (object_span == 1)29 {30 left = right = objects[start];31 }32 else if (object_span == 2)33 {34 left = objects[start];35 right = objects[start + 1];36 }37 else38 {39 std::sort(objects.begin() + start, objects.begin() + end, comparator);40
41 auto mid = start + object_span / 2;42 // 递归构建树43 left = make_shared<bvh_node>(objects, start, mid);44 right = make_shared<bvh_node>(objects, mid, end);45 }46
47 aabb box_left, box_right;48
49 if ( !left->bounding_box (box_left) || !right->bounding_box(box_right))50 std::cerr << "No bounding box in bvh_node constructor.\n";51
52 bbox = surrounding_box(box_left, box_right);53 }54
55 // 遍历左右子树求交取最近的交点————现在在 GPU 实现56 bool hit(const vec3& ray_origin, const vec3& ray_dir, double t_min, double t_max, hit_record& rec) const override57 {58 /*59 if (!bbox.hit(ray_origin, ray_dir, t_min, t_max))60 return false;61 bool hit_left = left->hit(r, t_min, t_max, rec);62 bool hit_right = right->hit(r, t_min, hit_left ? rec.t : t_max, rec);63 return hit_left || hit_right;64 */65
66 return false;67 }68
69 bool bounding_box(aabb& output_box) const override70 {71 output_box = bbox;72 return true;73 }74
75 shared_ptr<hittable> left;76 shared_ptr<hittable> right;77 aabb bbox;78
79 static bool box_compare(const shared_ptr<hittable> a, const shared_ptr<hittable> b, int axis_index)80 {81 aabb box_a;82 aabb box_b;83
84 if (!a->bounding_box(box_a) || !b->bounding_box(box_b))85 std::cerr << "No bounding box in bvh_node constructor.\n";86
87 return box_a.min().e[axis_index] < box_b.min().e[axis_index];88 }89
90 static bool box_x_compare (const shared_ptr<hittable> a, const shared_ptr<hittable> b)91 {92 return box_compare(a, b, 0);93 }94
95 static bool box_y_compare (const shared_ptr<hittable> a, const shared_ptr<hittable> b)96 {97 return box_compare(a, b, 1);98 }99
100 static bool box_z_compare (const shared_ptr<hittable> a, const shared_ptr<hittable> b)101 {102 return box_compare(a, b, 2);103 }104};105
106//======================================================================107
108struct alignas(16) GPUBVHNode // 强制整个结构体的对齐方式为 16 字节边界109{110 glm::vec3 aabbMin; // 包围盒的最小顶点坐标111 int leftChild; // 内部节点填左孩子下标,叶子填-1112 // 这个int紧跟在vec3后面,会被塞进vec3的16字节对齐槽里,不会额外占空间113
114 glm::vec3 aabbMax;115 int rightChild; // 内部节点填左孩子下标,叶子填图元下标116
117 int isLeaf; // 1 叶子,0 内部节点118 float pad0, pad1, pad2; // 凑齐16字节,避免下一个node因为对齐产生偏移119};120// 所以最终一个 bvhnode占用48字节121// 为何在CPU端要这样设计节点?122// std430规则下vec3本身要按16字节对齐,正好剩一个4字节空当,编译器会自动把紧跟着的float塞进去,不会浪费也不会错位,这是个很常用的省内存技巧。123
124struct GPUSphere125{126 glm::vec3 center;127 float radius;128
129 int materialId; // Day26要用,先占位填0130 float pad0, pad1, pad2;131};132
133class BVHFlattener134{135 public:136 std::vector<GPUBVHNode> flatNodes;137 std::vector<GPUSphere> flatSpheres;138
139 // 入口:传入根节点,返回根节点在flatNodes里的下标140 int flatten(shared_ptr<hittable> root)141 {142 return flattenNode(root);143 }144
145 private:146 int flattenNode(shared_ptr<hittable> node)147 {148 auto asBVH = std::dynamic_pointer_cast<bvh_node>(node);149
150 if (asBVH)151 {152 // 如果是内部节点,先递归拍平左右孩子,得到他们在数组中的索引153 int leftIdx = flattenNode(asBVH->left);154 int rightIdx = flattenNode(asBVH->right);155
156 aabb box = asBVH->bbox;157 GPUBVHNode gpuNode;158 gpuNode.aabbMin = glm::vec3(box.min().x(), box.min().y(), box.min().z());159 gpuNode.aabbMax = glm::vec3(box.max().x(), box.max().y(), box.max().z());160 gpuNode.leftChild = leftIdx;161 gpuNode.rightChild = rightIdx;162 gpuNode.isLeaf = 0;163
164 flatNodes.push_back(gpuNode);165 return (int)flatNodes.size() - 1;166 }167 else168 {169 // 是叶子:node直接是一个球170 auto s = std::dynamic_pointer_cast<sphere>(node);171 GPUSphere gpuSphere;172 gpuSphere.center = glm::vec3(s->center.x(), s->center.y(), s->center.z());173 gpuSphere.radius = s->radius;174 gpuSphere.materialId = 0;175 flatSpheres.push_back(gpuSphere);176 int sphereIdx = (int)flatSpheres.size() - 1;177
178 aabb box;179 node->bounding_box(box);180 GPUBVHNode gpuNode;181 gpuNode.aabbMin = glm::vec3(box.min().x(), box.min().y(), box.min().z());182 gpuNode.aabbMax = glm::vec3(box.max().x(), box.max().y(), box.max().z());183 gpuNode.leftChild = -1;184 gpuNode.rightChild = sphereIdx; // leaf节点复用rightChild字段存图元下标185 gpuNode.isLeaf = 1;186
187 flatNodes.push_back(gpuNode);188 return (int)flatNodes.size() - 1;189 }190 }191};192
193#endif特别注意里面新类BVHFlattener,这个就是在 CPU端拍平树的关键! 还应注意的是C++里GPUSphere、BVHFlattener 数据结构的定义
raytrace.comp完整文件
1#version 430 core2
3layout(local_size_x = 16, local_size_y = 16) in;4layout(rgba32f, binding = 0) uniform image2D outputImage;5
6const float INF = uintBitsToFloat(0x7F800000u);7
8// 相机参数9uniform vec3 camOrigin;10uniform vec3 camLowerLeftCorner;11uniform vec3 camHorizontal;12uniform vec3 camVertical;13
14// bvh 根节点索引15uniform int bvhRootIndex;16
17struct BVHNode18{19 vec3 aabbMin;20 int leftChild;21 vec3 aabbMax;22 int rightChild;23 int isLeaf;24 float pad0, pad1, pad2;25};26
27struct Sphere28{29 vec3 center;30 float radius;31 int materialId;32 float pad0, pad1, pad2;33};34
35// 声明着色器存储缓冲区SSBO36layout(std430, binding = 1) readonly buffer BVHBuffer {BVHNode nodes[];};37layout(std430, binding = 2) readonly buffer SphereBuffer {Sphere spheres[];};38
39// 光线与球体求交,返回命中距离,没命中返回-1.040float hitSphere(vec3 center, float radius, vec3 rayOrigin, vec3 rayDir, float t_min, float t_max)41{42 vec3 oc = rayOrigin - center;43 float a = dot(rayDir, rayDir);44 float halfB = dot(oc, rayDir);45 float c = dot(oc, oc) - radius * radius;46
47 float discriminant = halfB * halfB - a * c;48
49 if (discriminant < 0.0) return -1.0;50
51 float root = (-halfB - sqrt(discriminant)) / a;52 if (!(root > t_min && root < t_max))53 {54 root = (-halfB + sqrt(discriminant)) / a;55 if (!(root > t_min && root < t_max))56 return -1.0;57 }58 return root;59}60
61// 包围盒求交,slab方法62bool aabbHit(vec3 boxMin, vec3 boxMax, vec3 rayOrigin, vec3 rayDir, float tMax)63{64 float tmin = 0.0; // 光线起点内部视为 065 float tmax = tMax;66
67 for (int i = 0; i < 3; ++i)68 {69 float invD = 1.0 / rayDir[i];70 float t0 = (boxMin[i] - rayOrigin[i]) * invD;71 float t1 = (boxMax[i] - rayOrigin[i]) * invD;72 if (invD < 0.0)73 { // 确保 t0 是近面,t1 是远面74 float tmp = t0; t0 = t1; t1 = tmp;75 }76 tmin = max(tmin, t0);77 tmax = min(tmax, t1);78 if (tmax <= tmin)79 return false;80 }81 return true;82}83
84// BVH遍历器,参数输出命中球的索引以及命中距离85bool traverseBVH(vec3 rayOrigin, vec3 rayDir, out int hitSphereIdx, out float hitT)86{87 int stack[32];88 int stackPtr = 0;89 stack[stackPtr++] = bvhRootIndex; // 根节点90
91 hitT = INF;92 bool hitAnything = false;93
94 while (stackPtr > 0)95 {96 int nodeIdx = stack[--stackPtr];97 BVHNode node = nodes[nodeIdx];98
99 if (!aabbHit(node.aabbMin, node.aabbMax, rayOrigin, rayDir, hitT))100 continue;101
102 if (node.isLeaf == 1)103 {104 int sIdx = node.rightChild; // leaf节点里rightChild存的是图元下标105 float t = hitSphere(spheres[sIdx].center, spheres[sIdx].radius, rayOrigin, rayDir, 0.0001, hitT);106 if (t != -1.0)107 {108 hitT = t;109 hitSphereIdx = sIdx;110 hitAnything = true;111 }112 }113 else114 {115 stack[stackPtr++] = node.leftChild;116 stack[stackPtr++] = node.rightChild;117 }118 }119 return hitAnything;120}121
122
123vec3 rayColor(vec3 rayOrigin, vec3 rayDir)124{125 int hitSphereIdx;126 float t;127 bool hit = traverseBVH(rayOrigin, rayDir, hitSphereIdx, t);128
129 if (hit)130 {131 Sphere s = spheres[hitSphereIdx];132 vec3 hitPoint = rayOrigin + rayDir * t;133 vec3 normal = normalize(hitPoint - s.center);134
135 return 0.5 * (normal + vec3(1.0));136 }137
138 vec3 unitDir = normalize(rayDir);139 float a = 0.5 * (unitDir.y + 1.0);140 return (1.0 - a) * vec3(1.0) + a * vec3(0.5, 0.7, 1.0);141}142
143
144
145void main()146{147 ivec2 pixelCoord = ivec2(gl_GlobalInvocationID.xy);148 ivec2 imageSize = imageSize(outputImage);149 if (pixelCoord.x >= imageSize.x || pixelCoord.y >= imageSize.y) return;150
151 float u = float(pixelCoord.x) / float(imageSize.x);152 float v = float(pixelCoord.y) / float(imageSize.y);153
154 vec3 rayDir = normalize(camLowerLeftCorner + u * camHorizontal + v * camVertical - camOrigin);155 vec3 color = rayColor(camOrigin, rayDir);156
157 imageStore(outputImage, pixelCoord, vec4(color, 1.0));158}我们在GPU里完成对bvh节点数组的遍历,对aabb包围盒的求交。
为了使main不臃肿,新建一个gpu_buffer.h,来创建 SSBO,然后再创建一个 scene.h 来构建场景。
gpu_buffer.h完整文件
1#ifndef GPU_BUFFER_H2#define GPU_BUFFER_H3
4#include <glad/glad.h>5#include "bvh.h"6#include <vector>7
8GLuint createBVHSSBO(const std::vector<GPUBVHNode>& nodes)9{10 GLuint ssbo;11 glGenBuffers(1, &ssbo);12 glBindBuffer(GL_SHADER_STORAGE_BUFFER, ssbo);13 glBufferData(GL_SHADER_STORAGE_BUFFER, nodes.size() * sizeof(GPUBVHNode), nodes.data(), GL_STATIC_DRAW);14 glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 1, ssbo);// 注意这里对应的binding序号15 glBindBuffer(GL_SHADER_STORAGE_BUFFER, 0);16
17 return ssbo;18}19
20GLuint createShereSSBO(const std::vector<GPUSphere>& spheres)21{22
23 GLuint ssbo;24 glGenBuffers(1, &ssbo);25 glBindBuffer(GL_SHADER_STORAGE_BUFFER, ssbo);26 glBufferData(GL_SHADER_STORAGE_BUFFER, spheres.size() * sizeof(GPUSphere), spheres.data(), GL_STATIC_DRAW);27 glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 2, ssbo);28 glBindBuffer(GL_SHADER_STORAGE_BUFFER, 0);29
30 return ssbo;31}32#endifscene.h完整文件
1#ifndef SCENE_H2#define SCENE_H3
4#include "bvh.h"5
6hittable_list createScene()7{8 hittable_list world;9
10
11 world.add(12 make_shared<sphere>(13 vec3(0,0,-1),14 0.5f15 )16 );17
18
19 world.add(20 make_shared<sphere>(21 vec3(1,0,-2),22 0.5f23 )24 );25
26
27 world.add(28 make_shared<sphere>(29 vec3(-1,0,-2),30 0.5f31 )32 );33
34
35 return world;36}37
38#endifmain.cpp
1// main函数里2 Shader raytraceProgram = Shader::computeShader("src/shaders/raytrace.comp");3 Shader quadProgram = Shader::graphicsShader("src/shaders/quad.vert", "src/shaders/quad.frag");4
5 //----------- 场景构建---------------6 hittable_list world = createScene();7
8 //----------- 构建BVH ---------------9 auto bvh = make_shared<bvh_node>(world.objects, 0, world.objects.size());10 //----------- flatten --------------11 BVHFlattener flattener;12 int rootIndex = flattener.flatten(bvh);13 //----------- 上传 GPU --------------14 static_assert(sizeof(GPUBVHNode) == 48, "GPUBVHNode must be 48 bytes");15 static_assert(sizeof(GPUSphere) == 32, "GPUSphere must be 32 bytes");16 createBVHSSBO(flattener.flatNodes);17 createShereSSBO(flattener.flatSpheres);正常渲染结果应该是屏幕中有三个球,都是紫色渐变。
TIP在GPU内编码少用else-if分支:线程束发散,GPU 以 warp(通常 32 个线程)为单位执行指令。当同一 warp 内的线程因 if-else 走入不同分支时,它们只能串行执行,这直接导致并行效率减半甚至更低,且分支内部计算越重,浪费越严重。
GPU 的随机数生成与材质系统移植#
GPU 随机数生成器:PCG哈希#
C++的std::mt19937是一个有状态的对象,每次调用会修改内部状态、下次调用产生不同的数——这个模型在GPU上完全不适用,因为几千个线程如果共享一个”状态对象”会产生数据竞争,而每个线程各自维护一个独立的mt19937实例又太重(占用寄存器多,性能差)。
GPU上标准做法是用哈希函数模拟随机数:给定一个整数种子,哈希出另一个”看起来随机”的整数,没有全局状态,纯函数。PCG是业界最常用的一种,速度快、分布质量好。
在 raytrace.comp 里加:
1// PCG哈希,输入一个整数种子,输出另一个"随机"整数2uint pcgHash(uint input_)3{4 uint state = input_ * 747796405u + 2891336453u; // 线性同余生成器公式,新状态 = 旧状态 × 大奇数 + 另一个大奇数,两个奇数是PCG 作者精心挑选的“魔法数字”5 uint word = ((state >> ((state >> 28u) + 4u)) ^ state) * 277803737u; // 旋转+乘法,用数据本身的几个高位去决定怎样搅动剩下的位6 return (word >> 22u) ^ word;7}8
9// 每个线程调用后,rngState会被更新,下次调用产生不同的数10float randFloat(inout uint rngState)11{12 rngState = pcgHash(rngState); // 更新状态13 return float(rngState) / 4294967296.0; // 除以 2^32,映射到 [0,1)14}15
16// 给每个线程发独一无二的种子17uint initRNG(ivec2 pixelCoord, ivec2 imgSize, uint frameCount)18{19 uint seed = uint(pixelCoord.x) + uint(pixelCoord.y) * uint(imgSize.x); // 将二维像素坐标展开成一维索引seed20 seed = seed * 719393u + frameCount * 96923u; // 用一个大奇数放大,免相邻种子在经过 pcgHash 后产生的序列仍有微弱关联。引入帧数,让同一像素在不同帧的种子完全不同21 return pcgHash(seed);22}为什么要结合frameCount:如果只用像素坐标做种子,同一个像素每一帧都会用完全一样的种子,产生一模一样的随机序列——这样多帧累积的时候,噪点不会被平均掉,因为每帧的”随机”抖动其实都相同。
再加两个基于randFloat的辅助函数,对应RTIOW里random_in_unit_sphere()和random_unit_vector():
两个重要的随机采样函数
1// 对应RTIOW的random_unit_vector(),用解析公式直接生成单位球面上均匀分布的点2vec3 randomUnitVector(inout uint rngState)3{4 float z = randFloat(rngState) * 2.0 - 1.0;5 float a = randFloat(rngState) * 2.0 * PI;6 float r = sqrt(max(0.0, 1.0 - z * z));7 return vec3(r * cos(a), r * sin(a), z);8}9
10// 对应RTIOW的random_in_unit_disk(),用于metal的fuzz模糊反射11vec2 randomInUnitDisk(inout uint rngState)12{13 float a = randFloat(rngState) * 2.0 * PI;14 float r = sqrt(randFloat(rngState));15 return vec2(r * cos(a), r * sin(a));16}材质系统的移植#
扩展BVHFlattener,拍平材质#
CPP的material.h包含lambertian/metal/dielectric三个类,都继承material基类,靠虚函数scatter()实现多态。GPU没有虚函数,处理方式只能是**打上类型标签,塞进一个统一的struct。**同时 scatter() 统一在 GPU 里完成
在bvh.h里添加(这里因为我的GPUShere和GPUBVHNode在这个文件,我也只好把材质的结构体也加在此处)
bvh.h
1// 和GLSL端严格对应,注意字段顺序影响std430对齐2struct GPUMaterial {3 glm::vec3 albedo; // lambertian/metal用,颜色4 float fuzz; // metal专用,紧跟在albedo后面,卡进vec3的对齐空当5 float ir; // dielectric专用,折射率6 int type; // 0=lambertian, 1=metal, 2=dielectric7 float pad0, pad1; // 凑齐32字节(16的倍数)8};9static_assert(sizeof(GPUMaterial) == 32, "GPUMaterial size mismatch, check alignment");10
11...12
13class BVHFlattener {14public:15 std::vector<GPUBVHNode> flatNodes;16 std::vector<GPUSphere> flatSpheres;17 std::vector<GPUMaterial> flatMaterials; // 新增18
19 int flatten(shared_ptr<hittable> root) {20 return flattenNode(root);21 }22
23private:24 // 材质去重:同一个material对象可能被多个球共用,避免重复存储25 std::unordered_map<material*, int> materialCache;26
27 int flattenMaterial(shared_ptr<material> mat) {28 auto it = materialCache.find(mat.get());29 if (it != materialCache.end()) return it->second; // 已经拍平过,直接复用下标30
31 GPUMaterial gpuMat{};32 if (auto lamb = std::dynamic_pointer_cast<lambertian>(mat)) {33 gpuMat.type = 0;34 gpuMat.albedo = glm::vec3(lamb->get_albedo().x(), lamb->get_albedo().y(), lamb->get_albedo().z());35 } else if (auto met = std::dynamic_pointer_cast<metal>(mat)) {36 gpuMat.type = 1;37 gpuMat.albedo = glm::vec3(met->get_albedo().x(), met->get_albedo().y(), met->get_albedo().z());38 gpuMat.fuzz = (float)met->get_fuzz();39 } else if (auto diel = std::dynamic_pointer_cast<dielectric>(mat)) {40 gpuMat.type = 2;41 gpuMat.ir = (float)diel->get_ir();42 }43
44 flatMaterials.push_back(gpuMat);45 int idx = (int)flatMaterials.size() - 1;46 materialCache[mat.get()] = idx;47 return idx;48 }49
50 int flattenNode(shared_ptr<hittable> node) {51 auto asBVH = std::dynamic_pointer_cast<bvh_node>(node);52
53 if (asBVH) {54 int leftIdx = flattenNode(asBVH->get_left());55 int rightIdx = flattenNode(asBVH->get_right());56
57 aabb box = asBVH->get_box();58 GPUBVHNode gpuNode{};59 gpuNode.aabbMin = glm::vec3(box.min().x(), box.min().y(), box.min().z());60 gpuNode.aabbMax = glm::vec3(box.max().x(), box.max().y(), box.max().z());61 gpuNode.leftChild = leftIdx;62 gpuNode.rightChild = rightIdx;63 gpuNode.isLeaf = 0;64
65 flatNodes.push_back(gpuNode);66 return (int)flatNodes.size() - 1;67 } else {68 auto s = std::dynamic_pointer_cast<sphere>(node);69
70 GPUSphere gpuSphere{};71 gpuSphere.center = glm::vec3(s->center.x(), s->center.y(), s->center.z());72 gpuSphere.radius = (float)s->radius;73 gpuSphere.materialId = flattenMaterial(s->get_material()); // 关键:把材质也拍平,记录下标74
75 flatSpheres.push_back(gpuSphere);76 int sphereIdx = (int)flatSpheres.size() - 1;77
78 aabb box;79 node->bounding_box(0, 0, box);80 GPUBVHNode gpuNode{};81 gpuNode.aabbMin = glm::vec3(box.min().x(), box.min().y(), box.min().z());82 gpuNode.aabbMax = glm::vec3(box.max().x(), box.max().y(), box.max().z());83 gpuNode.leftChild = -1;84 gpuNode.rightChild = sphereIdx;85 gpuNode.isLeaf = 1;86
87 flatNodes.push_back(gpuNode);88 return (int)flatNodes.size() - 1;89 }90 }91};然后在 gpu_buffer.h 里传 SSBO 即可,这里注意是 binding = 3
GLSL 端完善 hit record#
之前的hitSphere只返回一个距离t,现在散射需要交点坐标、法线方向、材质id,得升级成完整的”hit record”,traverseBVH也得跟着更新
raytrace.comp
1struct HitRecord2{3 vec3 point;4 vec3 normal;5 float t;6 int materialId;7 bool frontFace; // 折射时用到,光线从内部还是外部打到球8};9
10struct Material11{12 glm::vec3 albedo; // lambertian/metal 用颜色13 float fuzz; // metal专用,紧跟在albedo后面,卡进vec3的对齐空当14 float ir; // dielectric专用,折射率15 int type; // 0=lambertian, 1=metal, 2=dielectric16 float pad0, pad1;17};18
19layout(std430, binding = 3) readonly buffer MaterialBuffer {Material materials[];};20
21// ------------------- 核心计算:光线求交----------------------22// 光线与球体求交23bool hitSphere(Sphere s, vec3 rayOrigin, vec3 rayDir, float t_min, float t_max, out HitRecord rec)24{25 vec3 oc = rayOrigin - s.center;26 float a = dot(rayDir, rayDir);27 float halfB = dot(oc, rayDir);28 float c = dot(oc, oc) - s.radius * s.radius;29 float discriminant = halfB * halfB - a * c;30 if (discriminant < 0.0) return false;31
32 float root = (-halfB - sqrt(discriminant)) / a;33 if (!(root > t_min && root < t_max))34 {35 root = (-halfB + sqrt(discriminant)) / a;36 if (!(root > t_min && root < t_max))37 return false;38 }39
40 rec.t = root;41 rec.point = rayOrigin + root * rayDir;42 vec3 outwardNormal = (rec.point - s.center) / s.radius;43 rec.frontFace = dot(rayDir, outwardNormal) < 0.0;44 rec.normal = rec.frontFace ? outwardNormal : -outwardNormal;45 rec.materialId = s.materialId;46 return true;47}48
49// BVH遍历器,参数输出命中球的索引以及命中距离50bool traverseBVH(vec3 rayOrigin, vec3 rayDir, float tMin, float tMax, out HitRecord rec)51{52 int stack[32];53 int stackPtr = 0;54 stack[stackPtr++] = bvhRootIndex;55
56 HitRecord tempRec;57 float closestSoFar = tMax;58 bool hitAnything = false;59
60 while (stackPtr > 0)61 {62 int nodeIdx = stack[--stackPtr];63 BVHNode node = nodes[nodeIdx];64
65 if (!aabbHit(node.aabbMin, node.aabbMax, rayOrigin, rayDir, closestSoFar))66 continue;67
68 if (node.isLeaf == 1)69 {70 int sIdx = node.rightChild;71 if (hitSphere(spheres[sIdx], rayOrigin, rayDir, tMin, closestSoFar, tempRec))72 {73 closestSoFar = tempRec.t;74 rec = tempRec;75 hitAnything = true;76 }77 }78 else79 {80 stack[stackPtr++] = node.leftChild;81 stack[stackPtr++] = node.rightChild;82 }83 }84 return hitAnything;85}材质散射函数#
在 raytrace.comp 添加三种材质的散射函数
scatter函数
1// ------------------------- 散射 --------------------------------2// 返回值:是否发生散射(dielectric/lambertian/metal理论上总会散射,3// 但metal在极端角度下反射方向可能指向物体内部,这时候算作被吸收)4// scatteredDir: 散射后的新射线方向5// attenuation: 这次散射的反射率,为什么是albedo?还剩下的颜色可以理解为还剩的能量,满能量就是白光vec3(1.0)6bool scatter(HitRecord rec, vec3 rayDir, inout uint rngState, out vec3 scatteredDir, out vec3 attenuation)7{8 Material mat = materials[rec.materialId];9
10 if (mat.type == 0)11 {12 // ---- Lambertian ----13 vec3 scatterDir = rec.normal + randomUnitVector(rngState);14 // 退化情况处理:如果随机方向正好和法线反向抵消,方向会变成接近0的向量15 if (length(scatterDir) < 1e-4) scatterDir = rec.normal;16 scatteredDir = normalize(scatterDir);17 attenuation = mat.albedo;18 return true;19 }20 else if (mat.type == 1)21 {22 // ---- Metal ----23 vec3 reflected = reflect(normalize(rayDir), rec.normal);24 vec2 fuzzOffset = randomInUnitDisk(rngState) * mat.fuzz;25 scatteredDir = normalize(reflected + vec3(fuzzOffset, 0.0));26 attenuation = mat.albedo;27 return dot(scatteredDir, rec.normal) > 0.0; // 模糊后如果方向钻进物体内部,视为被吸收28 }29 else30 {31 // ---- Dielectric(玻璃) ----32 attenuation = vec3(1.0); // 玻璃不吸收颜色33 float refractionRatio = rec.frontFace ? (1.0 / mat.ir) : mat.ir;34
35 vec3 unitDir = normalize(rayDir);36 float cosTheta = min(dot(-unitDir, rec.normal), 1.0);37 float sinTheta = sqrt(1.0 - cosTheta * cosTheta);38
39 bool cannotRefract = refractionRatio * sinTheta > 1.0;40
41 // Schlick近似,对应RTIOW的reflectance()函数42 float r0 = (1.0 - refractionRatio) / (1.0 + refractionRatio);43 r0 = r0 * r0;44 float reflectance = r0 + (1.0 - r0) * pow(1.0 - cosTheta, 5.0);45
46 if (cannotRefract || reflectance > randFloat(rngState))47 {48 scatteredDir = reflect(unitDir, rec.normal);49 } else50 {51 scatteredDir = refract(unitDir, rec.normal, refractionRatio);52 }53 return true;54 }55}主循环:多次反弹的迭代路径追踪#
ray_color()改成固定次数的for循环,一旦没命中或者达到最大深度就提前跳出:
1// ----------------------- Draw! ---------------------------2vec3 rayColor(vec3 rayOrigin, vec3 rayDir, inout uint rngState)3{4 vec3 attenuation = vec3(1.0);5 int maxDepth = 16;6
7 for (int depth = 0; depth < maxDepth; depth++)8 {9 HitRecord rec;10 if (traverseBVH(rayOrigin, rayDir, 0.0001, INF, rec))11 {12 vec3 scatteredDir;13 vec3 matAttenuation;14 if (scatter(rec, rayDir, rngState, scatteredDir, matAttenuation))15 {16 attenuation *= matAttenuation;17 rayOrigin = rec.point;18 rayDir = scatteredDir;19 }20 else21 {22 return vec3(0.0);23 }24 }25 else26 {27 vec3 unitDir = normalize(rayDir);28 float a = 0.5 * (unitDir.y + 1.0);29 vec3 skyColor = (1.0 - a) * vec3(1.0) + a * vec3(0.5, 0.7, 1.0);30 return attenuation * skyColor;31 }32 }33 return vec3(0.0); // 超过最大深度还没跳出,视为完全吸收34}35
36
37//-------------------- main ----------------------38void main()39{40 ivec2 pixelCoord = ivec2(gl_GlobalInvocationID.xy);41 ivec2 imageSize = imageSize(outputImage);42 if (pixelCoord.x >= imageSize.x || pixelCoord.y >= imageSize.y) return;43
44 uint rngState = initRNG(pixelCoord, imageSize, frameCount);45
46 float u = (float(pixelCoord.x) + randFloat(rngState)) / float(imageSize.x); // 随机偏移做抗锯齿47 float v = (float(pixelCoord.y) + randFloat(rngState)) / float(imageSize.y);48
49 vec3 rayDir = normalize(camLowerLeftCorner + u * camHorizontal + v * camVertical - camOrigin);50 vec3 color = rayColor(camOrigin, rayDir, rngState);51
52 imageStore(outputImage, pixelCoord, vec4(color, 1.0));53}最后在CPU端main.cpp主循环里加一个frameCount计数器:
1uint32_t frameCount = 0;2// ...主循环内,dispatch之前3raytraceProgram.setInt("frameCount", (int)frameCount);4frameCount++;渐进式累积渲染#
outputImage 不再保存当前帧颜色,而是保存所有历史样本的累积和。显示时再除以采样次数得到平均颜色,并进行 Gamma 校正。
为 Shader 类添加 setUint#
在 src/Shader.h 的 Shader 类 public 区域,添加:
Shader.h
1void setUint(const std::string& name, unsigned int v) const {2 glUniform1ui(glGetUniformLocation(program, name.c_str()), v);3}修改 raytrace.comp#
(1)允许 Compute Shader 读写纹理#
main.cpp
1glBindImageTexture(0, outputTexture, 0, GL_FALSE, 0, GL_WRITE_ONLY, GL_RGBA32F);main.cpp
1glBindImageTexture(0, outputTexture, 0, GL_FALSE, 0, GL_READ_WRITE, GL_RGBA32F);(2)累积样本#
将 main() 替换为:
raytrace.comp
1void main() {2 ivec2 pixelCoord = ivec2(gl_GlobalInvocationID.xy);3 ivec2 imgSize = imageSize(outputImage);4 if (pixelCoord.x >= imgSize.x || pixelCoord.y >= imgSize.y) return;5
6 uint rngState = initRNG(pixelCoord, imgSize, frameCount);7
8 float u = (float(pixelCoord.x) + randFloat(rngState)) / float(imgSize.x);9 float v = (float(pixelCoord.y) + randFloat(rngState)) / float(imgSize.y);10
11 vec3 rayDir = camLowerLeftCorner + u * camHorizontal + v * camVertical - camOrigin;12 vec3 newSample = rayColor(camOrigin, rayDir, rngState);13
14 // 读取之前累积的样本和;第0帧时outputImage里是未初始化的垃圾数据,15 // 所以第0帧不读,直接把newSample当作起始值,避免脏数据污染结果16 vec3 previousSum = vec3(0.0);17 if (frameCount > 0u) {18 previousSum = imageLoad(outputImage, pixelCoord).rgb;19 }20
21 vec3 newSum = previousSum + newSample;22 imageStore(outputImage, pixelCoord, vec4(newSum, 1.0));23}
frameCount == 0时不能读取纹理,否则会把未初始化的数据累积进去。
修改 quad.frag#
负责计算平均颜色,并补上 RTIOW 的 Gamma 校正。
quad.frag
1#version 430 core2
3in vec2 texCoord;4out vec4 fragColor;5
6uniform sampler2D screenTexture;7uniform int sampleCount; // 目前累积了多少帧,用来求平均值8
9void main() {10 vec3 sum = texture(screenTexture, texCoord).rgb;11 vec3 color = sum / float(sampleCount);12
13 // gamma校正,对应RTIOW的write_color()里那句sqrt(color)14 // 之前GPU版本一直漏了这一步,画面会比CPU版本偏暗,现在补上对齐15 color = sqrt(max(color, vec3(0.0)));16
17 fragColor = vec4(color, 1.0);18}修改 main.cpp#
增加帧计数,并将样本数传给 Compute Shader 和 Fragment Shader。
main.cpp
1uint32_t frameCount = 0;2while (!glfwWindowShouldClose(window)) {3 raytraceProgram.use();4 glBindImageTexture(0, outputTexture, 0, GL_FALSE, 0, GL_READ_WRITE, GL_RGBA32F); // 改动:WRITE_ONLY -> READ_WRITE5
6 // 相机、球体等uniform设置保持不变,这里省略(你原来怎么写的不用动)7
8 raytraceProgram.setUint("frameCount", frameCount); // 改动:setInt -> setUint,修复类型不匹配的bug9
10 GLuint groupsX = (SCR_WIDTH + 15) / 16;11 GLuint groupsY = (SCR_HEIGHT + 15) / 16;12 glDispatchCompute(groupsX, groupsY, 1);13 glMemoryBarrier(GL_SHADER_IMAGE_ACCESS_BARRIER_BIT);14
15 glClear(GL_COLOR_BUFFER_BIT);16 quadProgram.use();17 glActiveTexture(GL_TEXTURE0);18 glBindTexture(GL_TEXTURE_2D, outputTexture);19 quadProgram.setInt("screenTexture", 0);20 quadProgram.setInt("sampleCount", (int)(frameCount + 1)); // 新增:告诉fragment shader目前累积了几帧21
22 glDrawArrays(GL_TRIANGLES, 0, 3);23
24 glfwSwapBuffers(window);25 glfwPollEvents();26
27 frameCount++; // 放在循环最后,累加帧数28}
sampleCount使用frameCount + 1,因为frameCount从 0 开始计数,而第 0 帧已经产生了 1 个有效样本。这样计算平均值时不会出现偏亮或偏暗的问题。
MIS#
ONB#
在处理漫反射方向采样时,我们需要”围绕法线建一个局部坐标系”,才能把”局部空间里的采样方向”转换成”世界空间方向”。这就是ONB(orthonormal basis,正交基)的作用——想象法线是这个局部坐标系的z轴,我们随便找两个跟它垂直的向量当x轴、y轴,就能把任意局部方向转换到世界空间。
1// ------------------- ONB 工具函数 --------------------------2struct ONB3{4 vec3 u, v, w;5};6
7// 围绕normal构建一个正交基,w轴对齐normal方向8ONB buildONB(vec3 normal)9{10 ONB basis;11 basis.w = normalize(normal);12 // 选一个不平行w的向量a,来计算叉乘求出另两个分量13 // 如果w的x分量接近1,那a取(0,1,0),否则就取(1,0,0)14 vec3 a = (abs(basis.w.x) > 0.9) ? vec3(0.0, 1.0, 0.0) : vec3(1.0, 0.0, 0.0);15 basis.v = normalize(cross(basis.w, a));16 basis.u = cross(basis.w, basis.v);17 return basis;18}19
20// 把局部坐标p转换到这个正交基所在的世界空间方向21vec3 onbLocal(ONB basis, vec3 p)22{23 return p.x * basis.u + p.y * basis.v + p.z * basis.w;24}精确的余弦分布采样#
1/ 在ONB局部空间里,按余弦加权分布采样一个方向(概率正比于cos(theta))2// 这是Malley's Method:先在圆盘上均匀采样,再投影到半球3vec3 randomCosineDirection(inout uint rngState)4{5 float r1 = randFloat(rngState);6 float r2 = randFloat(rngState);7 float phi = 2.0 * PI * r1;8 float x = cos(phi) * sqrt(r2);9 float y = sin(phi) * sqrt(r2);10 float z = sqrt(1.0 - r2);11 return vec3(x, y, z);12}13
14// 给定一个已经采样出来的方向,反过来算它在余弦分布下的概率密度15// 公式:p(direction) = cos(theta) / pi16float cosinePdf(vec3 direction, vec3 normal)17{18 float cosTheta = dot(normalize(direction), normal);19 return max(cosTheta, 0.0) / PI;20}光源锥体采样+多光源平均pdf#
1// 在着色点处生成一个指向球体光源的随机方向,方向被限制在一个锥体内,锥体大小正好覆盖远处光源球2// distanceSquared:当前着色点与光源球球心的距离平方3// radius:光源球的半径4vec3 randomToShpere(float radius, float distanceSquared, inout uint rngState)5{6 float r1 = randFloat(rngState);7 float r2 = randFloat(rngState);8
9 float z = 1.0 + r2 * (sqrt(max(0.0, 1.0 - radius*radius/distanceSquared)) - 1.0); // z=cosTheta,z 的方向:大致指向光源球心10 float phi = 2.0 * 3.14159265 * r1;11 float sinTheta = sqrt(max(0.0, 1.0 - z * z));12 float x = cos(phi) * sinTheta;13 float y = sin(phi) * sinTheta;14 return vec3(x, y, z);15}16
17// 从origin生成一个指向该光源表面的随机方向,用于直接光采样。18vec3 sampleLightDirection(Sphere light, vec3 origin, inout uint rngState)19{20 vec3 direction = light.center - origin;21 float disSq = dot(direction, direction);22 ONB basis = buildONB(direction); // 构建局部正交基 (ONB)23 vec3 localDir = randomToShpere(light.radius, disSq, rngState); // 在锥体内生成本地方向24 return onbLocal(basis, localDir); // 将局部方向变换到世界空间25}26
27// 计算"从origin朝direction方向,恰好能打中这个光源球"这件事的立体角概率密度28// 需要先做一次真实的求交测试,如果这个方向压根打不中球,pdf是029float lightPdfValue(Sphere light, vec3 origin, vec3 direction)30{31 HitRecord tempRec;32 if (!hitSphere(light, origin, direction, 0.0001, INF, tempRec))33 return 0;34
35 vec3 toCenterVec = light.center - origin;36 float disSq = dot(toCenterVec, toCenterVec);37 float cosThetaMax = sqrt(max(0.0, 1.0 - light.radius * light.radius / disSq)); // cosThetaMax:光源球半顶角的余弦值38 float solidAngle = 2.0 * PI * (1.0 - cosThetaMax); // 球冠立体角公式:Ω = 2π (1 - cosθ)39 return 1.0 / solidAngle; // 我们在光源立体角内部采样是均匀采样方向40}41
42// 多光源平均 pdf43// 这里假设"从N个光源里均匀随机选一个"来采样,所以对应的pdf也必须是"对所有光源的pdf取平均"44float lightsPdfValue(vec3 origin, vec3 direction)45{46 if (lightCount == 0) return 0.0;47 float sum = 0.0;48 for (int i = 0; i < lightCount; i++)49 {50 Sphere light = spheres[lightIndices[i]];51 sum += lightPdfValue(light, origin, direction);52 }53 return sum / float(lightCount);54}55
56vec3 sampleRandomLightDirection(vec3 origin, inout uint rngState)57{58 int idx = int(randFloat(rngState) * float(lightCount)); // 随机选择一个光源索引59 idx = min(idx, lightCount - 1); // 防止randFloat刚好采到1.0时下标越界60 Sphere light = spheres[lightIndices[idx]];61 return sampleLightDirection(light, origin, rngState);62}如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时
