Realistic Rendering

引言

这是一篇关于光线追踪的3D图形程序开发文章,手把手带你实现电影级的光线追踪技术,内容会从简单到复杂,带大家一点点的深入理解PBR渲染,在项目里用到了迪士尼光线追踪,解密电影级别的真实感渲染。

全局光照

光流量

光流量(Flux)定义就是单位时间内的能量微分:$\Phi=\lim_{\Delta t \to 0}\frac{\Delta Q}{\Delta t} = \frac{dQ}{dt}$。举个例子,一个灯在1小时溢出的能量 $\Phi$​ 为 200000J 的能量处以 3600s,最后得到 55.6w 就是我们需要的光流量。

能量就等于一段时间内光流量的积分:$Q=\int_{t_0}^{t_1}\Phi(t)dt$​​​​​,光流量根据这个公式很好解释,就是一段时间产生的能量。假如有一个光源,它不断射出能量射出光子,这个光子打到物体的表面会继续反弹,这就是为什么我们会持续看到物体的颜色,这些光子是持续不断地溢出的,所以人眼才会看到颜色是持续不变恒定的。

辐射照度

辐射照度(Irradiance)的定义就是单位面积下的光流量:$\frac{\Phi}{A}$​。它只与面积有关,知道了面积和这个面积上的光流量,也就知道了这块面积上的辐照度。

看上图的 $A_1$​ 和 $A_2$,除了点光源所有的光源都是面积光。$A_1$ 中一片面积为 $A$,里面的光流量为 $\Phi$,那么这一片面积光的照度等于 $\frac{\phi}{A}$,正对的地板由于平行关系照度相等,也为 $\frac{\Phi}{A}$ 。

当面积光如 $A_2$ 侧对地面的时候形成夹角 $\theta$,此时 $A_2$ 的照度根据 Lambert 定律为 $\frac{\Phi}{\frac{A}{\cos\theta}} = \frac{\Phi\cos\theta}{A}$。聚光灯为什么能照亮这么多面积?因为它是弧形的而不是 $A_1$​​ 那种平的,所以才会照亮成图中这个形状。

辐射强度 & 立体角

下面来说一下什么是立体角。我们看到上图有一块不规则的平面,立体角的定义就是把这个不规则平面投影到一个单位球上,投影得到的面积就是立体角的大小。在二维情况下,把平面投影到单位圆上得到一段圆弧 s,这条弧的长度就是角度;三维情况下这个角度大小就是投影在单位球上的面积,也可以理解为原始的不规则面与球心连接形成锥体,与球体表面相交的面积就是立体角。

这是立体角和投影面积的关系:$d\omega = \frac{dA\cos\theta}{r^2}$​​,其中 $dA$​ 就是投影到远处的一块面积,它跟距离 $r$​ 和夹角 $\theta$​​ 有关。当角度固定时,同一个立体角投影的越远,它的投影面积就越大。

那立体角究竟有什么用呢?它的作用有两个,一个是向外扩散/投影,另一个是向内投影。先说向外投影的情况,假设 p 为点光源,在立体角内会溢出一些光子,这些光子会朝着这个立体角的方向外扩散。根据立体角和面积的关系知道,它扩散的距离越远,那么投影面积就越大,而光子的数量不变,据此就可以算出投影的照度。

辐射强度(Intensity)定义为每单位方向上的光流量,通常用来表示点光源的。一个点光源没有面积,我们只能知道每个方向均匀溢出多少相等的光流量。

只要我们知道点光源总共的光流量是多少,那么就知道每个方向的光流量:$\frac{\Phi}{d\omega}$。由于点光源只是一个点,在光线追踪中很难追踪。

辐射亮度

辐射亮度(Radiance)定义为 $L(p,\omega)=\lim_{\Delta\omega\to0}\frac{\Delta E_{\omega}(p)}{\Delta \omega} = \frac{dE_{\omega}(p)}{d\omega}$,前面说过辐射照度只和面积有关没有方向,我们并不知道单位方向有多少照度,所以这里有单位方向 $d\omega$。这样亮度就是每单位立体角($d\omega$)每单位面积($dA$)有多少的光流量($d\Phi$)。

辐射亮度是非常常用的,图中的垂直符号表示把立体角方向上的面积投影到法线方向上,得到的就是垂直面积。

BRDF

BRDF 通俗地讲就是光线从一个方向射入打中一个点,再从某一个方向射出,用 BRDF 计算从这个方向射出的量是多少。

光源从入射方向投射到入射点,先计算它的辐照度 $dE(p, \omega_i)$:该方向的辐射亮度 $L_i(p, \omega_i)$ 乘以法线投影面积 $\cos\theta_id\omega_i$​。得到这一点的照度再跟射出的辐射亮度的比就是 BRDF 的定义:

BRDF 的定义就是射出的辐射亮度 $L_0(p, \omega_0)$ 的微分比上这一点的辐射照度 $E(p, \omega_i)$ 的微分,这里使微分是因为刚才的光路只贡献了一小部分,那如何才能完整找到光线呢?只有整个半球面所有的方向的辐射亮度的贡献加在一起,才是完整射出的辐射亮度。

辐射亮度 $L_0(p, \omega_0)$ 等于半球面的所有入射辐射亮度 $L_i(p, \omega_i)$​ 乘以 BRDF $f(p, \omega_0, \omega_i)$​ 乘以投影面积 $\cos\theta_i$ 的积分。

BRDF 有两个特性:

  1. 固定性:给定两个不同的方向,它们的 BRDF 的值是一样的,我们可以利用这个特性实现反向路径追踪

  2. 能量守恒:所有射出能量加起来等于射入的量

每种材质的 BRDF 是不一样的,但还是遵循上面的两个原则,后面遇到不同的材质会讲不同的 BRDF。

渲染方程

介绍

在前面的章节我们已经讲过了什么是全局光照,全局光照是怎么来的。这节课我们将更深入分析全局光照,了解程序是如何实现全局光照的。

回顾一下所谓全局光照,就是说光线从光源出发经过多次反弹以后打到物体表面上的一点,沿着此点反弹进入眼睛,眼睛就可以看到这一点的颜色。那么这个颜色究竟是多少呢?这就是全局光照渲染方程要干的事情,在数学上我们用一条渲染方程式求解答案。

渲染方程推导

首先入射点反射的颜色应该就是它的辐射亮度,这里的辐射亮度拆分为两个部分:自发光(自身发光发热所产生的光)和外界进入反弹的辐射亮度。

Light exiting the surface = Emitted + reflected incoming

我们把重点放在反射光上面,它遵循能量守恒,射入一定等于射出,如果射出的量为 1,那么所有射入量的总和也为 1。所以现在要找到所有的入射方向,每个方向携带多少的光。每个入射方向都会给眼睛看到的这个方向做出贡献,用 BRDF 定量它的贡献,只需要知道入射方向、出射方向与法线的角度,我们就知道了这个入射方向给出射方向贡献的辐射亮度是多少,也就有了这条方程式:

这就是大名鼎鼎的渲染方程:

$L_o(x, \vec\omega)=\underbrace{L_e(x, \vec\omega)}{emitted} + \underbrace{\int{\Omega} L_i(x, \vec\omega’)f_r(\vec\omega,x,\vec\omega’)\cos\theta d\vec\omega’}_{reflected \space incoming \space light}$

其中 $L_r$($L_o$)是辐射亮度,$L_i$ 是每个方向的辐射亮度,$f_r(\vec\omega,x,\vec\omega’)$​ 是 BRDF,$\cos\theta$ 是入射方向与法线的夹角作面积投影,积分空间是法线构成的整个半球面 $\Omega$​。

我们最开始提出的问题是射入眼睛的颜色等于多少,就等于这个方程左边的辐射亮度,只要我们能把右边解出来,那么这一点的颜色就被解出来了。剩下的问题就是这个渲染方程是否可解,在半球面空间中 $d\vec\omega’$ 可以通过枚举得到,而 BRDF 则可以通过材质得知,$\cos \theta$ 也是已知,唯一不确定的就是入射方向的辐射亮度 $L_i(x,\vec\omega’)$​,除非直接打中光源,但光线一般都是经过多次反射之后才进入这一点,所以这个 $L_i$ 一般都是不知道的,这个时候就是光线追踪登场的时刻了。

光线追踪

首先我们从眼睛出发看到物体上的一点,根据渲染方程在半球面每个方向逐一枚举找到所有入射方向的辐射亮度,选中一个方向以此类推重复该过程直至遇到光源:

这个过程看起来一棵多叉树,根结点就是求解从眼睛出发某一方向的辐射亮度,无限展开直到遇到光源才会终止一个节点。理论上这是可以求解的,但实际上又不行,有一个类似于“先有鸡还是先有蛋”的循环依赖问题,父节点向子节点要结果,子节点又向父节点要结果,形成了一种死循环,现实中也已经被证实是不可行的。

光线追踪的缺陷

  • 高维积分
  • 循环依赖
  • 渲染方程不可解

路径追踪

介绍

上一章节我们提出如何用光线追踪解决渲染方程,因为循环依赖的问题造成无解,是不可行的,要实现这个方程只能阉割掉一些特性。因此我们提出一个更好的方法:路径追踪,本节会讲解路径追踪的原理,改写渲染方程为基于路径的积分形式。

路径照度

这是某部卡通电影的截图,首先我们看见有几面窗,其中的白光可以用照度仪测出辐射照度(单位面积的光通量)。这个照度是可以投影的,从窗户这一片光投影到地板把地毯打亮,这时地毯也会有照度并且继续投影,打到了角色的脸上产生高光,以此类推。

从这个例子就可以看到面积光是可以通过投影的方式照亮周围的场景。

路径微分

通过第一灵感我们得到了路径追踪,现在我们来证明这个方法是可行的。对窗户面积进行面积微分,选取其中一点 $dA$​,连接窗户–地毯–人脸–摄像机,这样一条长度为 3 的路径就有了,路径追踪的算法也成型了:在场景里面找到一些点,把这些点连接成路径,我们只需要计算这条路径对这个相机方向的辐射亮度的贡献。

然而路径多种多样,问题就转化为了找到所有在这个方向做出贡献的路径,这也是路径追踪的核心思想。那么如何找到相机中的一点与光源连接的路径呢?我们可以拆分这个问题,路径是有长度的,它的长度可以是 2、3、4、5、6……我们可以分别找到长度为 2、3、4、5、6……的所有路径。

这个思路非常简单,那它为什么要比光线追踪好呢?它的好处在于你选择路径的时候路径永远是单向的,不会出现光线追踪中循环依赖的问题;同时它随机的主角也变了,在光线追踪中随机的是半球面的每一个方向,而现在随机的是场景中的某一个点。

路径追踪渲染方程

现在我们尝试改写路径追踪的渲染方程,已有的渲染方程是基于方向的立体角积分形式,下面我们来看看路径追踪。

先来看路径长度等于 2 的情况,其中 $p$​​ 为眼睛,看到的是 $p’$,$p’’$ 为光源。连接 $p’p’’$ 形成一条路径,将连接形成的向量带入渲染方程,可以发现 BRDF、辐射亮度 $L_i$ 和入射角 $\cos\theta$ 都不用改,真正需要修改的是 $\int_{\Omega}$ 和 $d\omega_i$。

积分的主角从方向变为了路径追踪中的点,基于立体角的微分也需要改成基于面积的微分。在前面的章节我们知道了立体角和面积的关系:

将 $d\omega$ 替换为面积 $A$ 再乘以可见性 $V(p’’,p)$ 得到路径追踪的渲染方程:

最后将可见性与面积替换两个部分合并成 $G(p, p’)$:

这样就得到了新的路径长度为 2 的渲染方程:这个方向射出的辐射亮度 $L(p’ \to p)$​​ 等于所有点空间 $\int_A$ 中路径的 BRDF $f_A(p’’\to p’ \to p)$ 乘上入射的辐射亮度 $L(p’’ \to p’)$ 乘上面积微分 $dA(p’’)$。

接着来看长度为 3 的路径追踪方程。

我们已经计算好了长度为 2 的路径的辐射亮度贡献,图中 $p_3$ 为光源。路径长度为 3 的辐射亮度贡献为 $p_3$ 到 $p_2$ 的辐射贡献亮度乘以 $\vec{p_3p_2}$、$\vec{p_2p_1}$ 这两个方向的 BRDF 和 $G$ 值乘以 $p_2$ 到 $p_1$ 的 BRDF……

n - 1 次反射我们可以找到长度为 n 的路径,渲染方程如下:

总结一下,路径追踪的核心思想就是基于面积的辐射亮度投影,可以假象为从光源出发拆分成一些点面积微分,这些点经过多次投影形成路径最后找到相机,渲染方程就能改写为路径形式的渲染方程。

核心

  • 基于面积的辐射亮度投影

    从光源面投影到某个面,再继续投影……这些点 $dA$ 形成路径

    路径是单向的不会死循环

  • 以路径的辐射亮度为贡献

    计算整条路径的光线传输(radiance)

  • 路径积分的渲染方程

    采样路径足够多,就能近似结果

随机

  • 场景的点 $dA$ 看作样本,随机选择 n 个点连接出路径
  • 计算这些路径的 radiance 贡献
  • 样本空间是场景的点,积累所以路径的贡献

引擎框架概览

编译

首先在网上下载一个最新版本的 CMake:下载地址

解压成为一个目录,进入 /bin,该目录就是 CMake程序的目录:

复制该目录的路径,这里我的路径为:

E:\Dev\CMake\cmake-3.21.1-windows-x86_64\cmake-3.21.1-windows-x86_64\bin

右键此电脑,找到高级设置添加路径:

接着进入我们的项目,测试 CMake 命令:

cd ...
cmake

点击 build_debug,弹出命令行自动构建项目:

这里我一开始遇到了小小的问题,CMake 无法找到我的 VisualStudio,所以我重新安装了一下就好了。最后设置 app 为启动项目,项目成功运行。

主循环

main.cpp 是整个程序的入口,在这里我们快速讲一下整个框架是如何初始化的。

头文件

先说头文件,分别为渲染界面的 GUI 系统、中间件目录下的相机控制器以及 Shader:

#include"Demo.hpp"
#include<gui/GuiSystem.hpp>
#include<middleware/camera/CameraControllerView.hpp>
#include<render/Shader.hpp>
初始化

进入主函数 main(),第一步是初始化整个图形引擎:

gfx::setup();

第二步是创建一个窗口,分辨率默认 720P,并创建一个输入宽高和 Title 的窗口实例:

int window_width = 1024;
int window_height = 768;
auto window = hw::Window{ hw::VideoMode(window_width, window_height), "Solar RenderingEngine" };

然后我们开始用之前的窗口创建程序,其中 App 的定义在 Demo.hpp 中:

struct App
{
hw::Window& window;
std::optional<hw::Event> event;
tutorial::gfx::Swapchain swapchain;
tutorial::graphics::Camera camera;
tutorial::ecs::Transform camera_trans;
};

整个程序包含了窗口 window、鼠标键盘的消息事件event、交换链 swapchain 和相机 camera

#define Demo(name) void name(App& app,  bool init)

Demo(___PathTracerSimple);
Demo(___PbrPathTracer);

我们将完成两个 Demo,分别是路径追踪渲染器 ___PathTracerSimple 和 基于物理的真实感渲染器 ___PbrPathTracer

完整的 Demo.hpp 如下:

#pragma once

#include<core/Types.hpp>
#include<hw/Window.hpp>
#include<hw/Event.hpp>
#include<gfx/Gfx.hpp>
#include<math/Math3d.hpp>
#include<../thirdparty/imgui/imgui.h>
#include<ecs/Transform.hpp>
#include<render/TextureLoader.hpp>
#include<render/Camera.hpp>
#include<optional>


struct App
{
hw::Window& window;
std::optional<hw::Event> event;
tutorial::gfx::Swapchain swapchain;
tutorial::graphics::Camera camera;
tutorial::ecs::Transform camera_trans;
};

#define Demo(name) void name(App& app, bool init)

Demo(___PathTracerSimple);
Demo(___PbrPathTracer);

回到 mian.cpp 的交换链这里,用图形引擎创建交换链 swapchain

App app{ window };
app.swapchain = gfx::create_swapchain(app.window.getSystemHandle(), window_width, window_height);
auto& swapchain = app.swapchain;

GUI 系统是在 gui 命名空间下的,这里我们用即时模式的 GUI:

gui::GuiSystem gui_sys{  };
if (!gui_sys.setup(window)) {
}
相机

接着我们来看相机,最常用的就是四个数据:

float _fovy;  // full angle
float _aspect_ratio;
float _znear;
float _zfar;

_fovy 就是相机的视角,_aspect_ratio 为宽高比例,_znear_zfar 分别是近、远平面,这几个参数构成了我们整个视锥。

首先我们引用 app 的相机,设定宽高比 _aspect_ratio 为之前的 720P分辨率、视角定为 $\frac{\pi}{3}$​ 60度、远平面随便设置为 1000 构成视锥:

// camera
graphics::Camera& camera = app.camera;
camera.set_aspect_ratio(float(window_width) / float(window_height));
camera.set_fov(math::PI / 3.f);
camera.set_zfar(1000.f);

接着我们来做相机的坐标变换,包括位置和旋转角度。相机位置放置在 (0, 0, -5)(我们使用左手系),set_rot() 设置三轴不发生旋转。接着我们定义一个相机控制器 camera_control,它会改变相机的位置、朝向和移动速度,这里的 __lookat_dist 是锁定的距离:

ecs::Transform& camera_trans = app.camera_trans;
camera_trans.set_pos({ 0, 0, -5 });
camera_trans.set_rot({ 0, 0, 0 });
render::CameraController camera_control{ camera, camera_trans, 1.f };
camera_control._lookat_dist = -5.0f;
camera_control._lookat_mode = true;

下面来看看每帧的循环,在 while(1) 循环中每帧都会从系统那里读取轮询消息,执行窗口关闭事件并将消息缓存到 app 里面,最后调用各大模块去处理这个消息:

while (1)
{
hw::Event event;
if (window.pollEvent(event))
{
if (event.type == hw::Event::Closed) {
window.close();
return false;
}
app.event = event;
gui_sys.handle_input(event);
camera_control.handle_input_events(event);
......
}

这里 F1 是隐藏/显示场景的UI:

if (event.type == hw::Event::EventType::KeyPressed)
{
if (hw::Keyboard::Key::F1 == event.key.code) {
gui_sys.show_gui ^= 1;
}
}

如果没有找到事件就把消息设置为空:

else
{
app.event = {};
}

然后就是 UI 系统和相机控制器的更新,以及图形引擎的刷新:

gui_sys.update();
camera_control.update(0.16f);

gfx::next_frame();

if (running)
{
demo_entry(app, init);
}
if (init) init = false;

最后我们会在 demo 里渲染出来我们要的结果并保存在后台缓存上,UI 会渲染在我们的结果之上:

auto backbuffer = swapchain.backbuffer();
gfx::bind_framebuffer(backbuffer);
gui_sys.render();
gfx::present(swapchain);

完整的 main.cpp 如下:

#include"Demo.hpp"
#include<gui/GuiSystem.hpp>
#include<middleware/camera/CameraControllerView.hpp>
#include<render/Shader.hpp>
auto demo_entry = ___PathTracerSimple;

int main()
{
using namespace tutorial;
gfx::setup();


int window_width = 1024;
int window_height = 768;
auto window = hw::Window{ hw::VideoMode(window_width, window_height), "Solar RenderingEngine" };
App app{ window };
app.swapchain = gfx::create_swapchain(app.window.getSystemHandle(), window_width, window_height);
auto& swapchain = app.swapchain;

gui::GuiSystem gui_sys{ };
if (!gui_sys.setup(window)) {
}


// camera
graphics::Camera& camera = app.camera;
camera.set_aspect_ratio(float(window_width) / float(window_height));
camera.set_fov(math::PI / 3.f);
camera.set_zfar(1000.f);
ecs::Transform& camera_trans = app.camera_trans;
camera_trans.set_pos({ 0, 0, -5 });
camera_trans.set_rot({ 0, 0, 0 });
render::CameraController camera_control{ camera, camera_trans, 1.f };
camera_control._lookat_dist = -5.0f;
camera_control._lookat_mode = true;

static bool init = true;
static bool running = true;
while (1)
{
hw::Event event;
if (window.pollEvent(event))
{
if (event.type == hw::Event::Closed) {
window.close();
return false;
}
app.event = event;
gui_sys.handle_input(event);
camera_control.handle_input_events(event);

if (event.type == hw::Event::EventType::KeyPressed)
{
if (hw::Keyboard::Key::F1 == event.key.code) {
gui_sys.show_gui ^= 1;
}
}
}
else
{
app.event = {};
}

gui_sys.update();
camera_control.update(0.16f);

gfx::next_frame();

if (running)
{
demo_entry(app, init);
}
if (init) init = false;



auto backbuffer = swapchain.backbuffer();
gfx::bind_framebuffer(backbuffer);
gui_sys.render();
gfx::present(swapchain);
}

gfx::shutdown();
}

开始demo

我们打开 SimplePathTracer.cpp,每一帧都要进入 ___PathTracerSimple()

void ___PathTracerSimple(App& app,  bool init)
{
if (init)
{
uniforms::setup();
}
}

现在我们来编写一个 Shader 并缓存输出到屏幕:

void ___PathTracerSimple(App& app,  bool init)
{
+ static Shader shader{"simple/PathTracerSimple"};
+ static auto main_fb = __frame_buffer_HDR(1024);

if (init)
{
uniforms::setup();
}
}

然后创建一个 1024*1024 帧缓存贴图,用来保存每个像素的渲染结果。

渲染相机

接下来是相机,引擎提供了一个 RenderCamera 的类,我们直接拿来用:

void ___PathTracerSimple(App& app,  bool init)
{
static Shader shader{"simple/PathTracerSimple"};
static auto main_fb = __frame_buffer_HDR(1024);

if (init)
{
uniforms::setup();
}

+ RenderCamera camera{ app.camera, app.camera_trans };
}

之前的相机 camera 仅仅定义了视锥,我们还需要相机相关的渲染数据,这里的 RenderCamera 就是用于构造相机相关的渲染数据。

视口

设置视口,包括摄像机 camera 和缓存 main_fb

void ___PathTracerSimple(App& app,  bool init)
{
static Shader shader{"simple/PathTracerSimple"};
static auto main_fb = __frame_buffer_HDR(1024);

if (init)
{
uniforms::setup();
}

RenderCamera camera{ app.camera, app.camera_trans };

+ GFX_BEGIN
+ Viewport vp1{camera, main_fb};
+ GFX_END
}

注意这里我们已经加入两个标志:GFX_BEGINGFX_END,在其中使用 Shader 绘制一些东西,包括 CPU 和 Shader 之间的一些交互。

光线追踪模块

入口架构

在我们编写简单的 Shader 之前,先来做一些预备工作。这个入口用于产生光线,会包括两个部分:

  • filter 绘制全屏的 quad 顶点 shader
  • 光线追踪的着色部分 shader
全屏shader

四边形投影的顶点 Shader 已经实现了,我们打开 Shader 目录中的 common 找到 PostProcessVs.fx

#ifdef VERTEX_SHADER

static const float4 g_pos[] =
{
float4(-1, -1, 0, 1),
float4(-1, 1, 0, 1),
float4(1, -1, 0, 1),
float4(1, 1, 0, 1),
};

static const float2 g_texcoord[] =
{
float2(0, 1),
float2(0, 0),
float2(1, 1),
float2(1, 0),
};

void main(uint vertexID : SV_VertexID,
out float4 ndc_pos : SV_Position,
out float2 texcoord : TEXTURE0
)
{
ndc_pos = g_pos[vertexID];
texcoord = g_texcoord[vertexID];
}

#endif

可以看到它的代码非常简洁,其中 main() 就是顶点入口函数。vertexID 就是顶点的 ID,我们会在 C++ 中输入四个顶点,通过顶点的 ID 找到它的坐标 g_pos[],在全局已经定义好这四个点的位置,纹理坐标 g_texcoord[] 也很简单,这样就分别输出四个顶点的坐标和纹理坐标。

我们再来看看引擎中是怎么实现的,打开 src/render/Filter.cpp,包含了引擎提供给我们的接口,用 Shader 渲染全屏的矩形:

auto Filter::draw(const gfx::RTV& dst, const gfx::Viewport& rect, ShaderVersion shader) -> void
{
gfx::bind_framebuffer(gfx::empty_v<gfx::DSV>, { dst });
gfx::set_viewport(rect);
draw(shader);
gfx::unbind_framebuffer(gfx::empty_v<gfx::DSV>, { dst });
}

参数包括 Shader 本身的 shader、视口大小 rect 以及渲染到目标的缓存 dst。完整的 Filter.cpp 如下:

#include<render/Filter.hpp>

namespace tutorial::graphics
{
auto Filter::draw(ShaderVersion shader) -> void
{
gfx::bind_shader(shader.fetch());
gfx::set_primitive_type(gfx::PrimType::PRIM_TRISTRIP);
gfx::set_blend_mode(gfx::BlendMode::Replace);
gfx::set_depth_test(false, false, gfx::DepthFunc::DSS_DEPTHFUNC_ALWAYS);
gfx::set_rasterize_mode(gfx::PolyCullMode::PCM_NONE, gfx::PolyFillMode::PFM_SOLID, true);
gfx::bind_vertex_buffer(nullptr, nullptr);
gfx::bind_index_buffer(nullptr);
gfx::draw(4, 0);
}

auto Filter::draw(const gfx::RTV& dst, const gfx::Viewport& rect, ShaderVersion shader) -> void
{
gfx::bind_framebuffer(gfx::empty_v<gfx::DSV>, { dst });
gfx::set_viewport(rect);
draw(shader);
gfx::unbind_framebuffer(gfx::empty_v<gfx::DSV>, { dst });
}

auto Filter::draw(const Viewport& dst, ShaderVersion shader) -> void
{
draw(dst.target, dst.rect, shader);
}
}
追踪器

app 中有一个 ScreenRayTracer.hpp 文件,打开发现只有数学库:

#pragma once
#include<math/Math3d.hpp>

首先包含一些必要的头文件:

#pragma once
#include<math/Math3d.hpp>
+#include<render/Filter.hpp>
+#include<render/Viewport.hpp>

接着我们定义一个 ScreenRayTracer 的类:

#pragma once
#include<math/Math3d.hpp>
#include<render/Filter.hpp>
#include<render/Viewport.hpp>

namespace tutorial::graphics
{
class ScreenRayTracer
{
private:
ScreenRayTracer() = delete;

public:
static void draw(Viewport& viewport, ShaderVersion shader);
};
}

接口包含一个视口 viewport 和一个 shader,用一个 Shader 渲染一个全屏的矩形,后面的光线追踪 Shader 就是在这里绘制全屏渲染。

接着打开 ScreenRayTracer.cpp

#include"ScreenRayTracer.hpp"
#include<core/Random.hpp>

我们在这里定义 RayTracer 模块的接口。由于整个程序只有一个模块,所以这里我们采用单例模式。

我们第一次用到的时候才会去初始化,定义一个辅助的结构体 SetupScreenRayTracer,它在第一次进入 draw() 时会初始化一个实例,调用构造函数。

完整的 ScreenRayTracer.cpp 如下:

#include"ScreenRayTracer.hpp"
#include<core/Random.hpp>

namespace tutorial::graphics
{
struct SetupScreenRayTracer
{
SetupScreenRayTracer() noexcept
{
static bool _init = false;
if (_init) return;
_init = true;
}
};

void ScreenRayTracer::draw(Viewport& viewport, ShaderVersion shader)
{
static SetupScreenRayTracer __done;
Filter::draw(viewport.target, viewport.rect, shader);
}
}

相机

这部分会讲解 3D 相机的原理,如何成像投影。

矩阵运算

在 GAMES101 里很详细的说明过:

空间变换

不过多介绍了,同样之前的 GAMES101 讲的很详细了:

全局 & 局部空间

全局空间又叫世界空间,也就是一个世界/场景所在的空间,一个场景只有一个全局空间。而局部空间是由某个物体以自己为原点、以自身为参考系所形成的空间。

至于什么时候使用全局空间,什么时候使用局部空间要看具体情况,哪一个更加方便,这两个空间都是可以作为参考的对象。在 3D 建模中是在局部空间建模,模型中的每个点都相对于局部空间;而在游戏里这个模型会移动的,移动时每一个顶点都要转变为全局空间。

相机空间

假设有一个世界坐标系,全局空间下有很多物体,这些物体又经过相机的拍摄形成一张图像。首先相机要捕捉这些看到的物体,它会把它看到的物体投影到屏幕上,为了方便起见才有了相机空间。

相机空间是以相机为原点形成的局部空间,我们做投影的时候一般是在相机空间做投影,这样子会比较方便,因为相机只需要拍摄它看得见的物体,相机后面的物体没有必要做投影和渲染。

从全局空间到相机空间涉及到了空间的变换,之前的课程我们已经学习了空间变换,实质上就是做矩阵乘法,我们只需要找到从世界空间到相机空间的变化矩阵就可以了。这个变化涉及到两个矩阵,一个是相机空间到世界空间(local to world matrix),还有一个是从世界空间到相机空间(view matrix),前者需要相机的旋转、平移,后者则是和前者操作相反:

$M = R \times T$

$V \times M = world_v \space \to \space V = world_v \times M^{-1}$​

图形渲染管线

图形流水线是图形渲染里很重要的东西,在这里你会知道 GPU 是怎么做渲染的。我们的程序输入数据给 GPU,经过很多步骤会在屏幕里画出一个个像素,而这个流程就是图形渲染管线。

其中 vertex shader 和 pixel shader 是可以自己改的,所以这两个地方也叫做 可编程管线。

首先 vertex shader 就是处理顶点的流程,你的程序会告诉 GPU 绘制什么顶点。比如说在一个 3D 空间里有一个立方体,这个 3D 模型数据由美术绘制好后输入给 GPU,GPU 经过顶点组装整合出顶点数据,包括顶点位置、纹理坐标和法线等各种数据,它会逐个顶点输入到顶点 shader。最常见的动画、地形和植物都是在顶点 shader 这里完成的,最后输出剪裁空间。

如果绘制的是 3D 几何体,那么还要加上相机的变换,最后还有 3D 的透视投影变换,从 3D 转回 2D;如果只是绘制 2D 的几何则不需要这两步,直接输出每个顶点就可以了。


GPU 这里还要做剪裁,在一个范围内(剪裁空间)如果一组顶点中有一些在范围之外,那么就需要把这些范围之外的顶点剪裁掉,剩下的这一部分才是要绘制的,剪裁剩下的三角形才会执行后面的操作。

剪裁完之后需要透视除法,而这两步在 GPU 中是黑盒子,你看不了也改不了。之前我们讲齐次坐标 $(x, y, z, w)$ 与 $(\frac{x}{w}, \frac{y}{w}, \frac{z}{w}, 1)$​​ 是一样的,透视除法就是在把一个四维坐标做除法,输出 ndc 空间(Normalized Device),这个空间下的 $x,y$​ 范围都是在 [-1, 1] 内,$z$ 的范围是在 [0, 1] 内。


经过透视除法,3D 的几何就会变为 2D 的几何,这时就会开始进行背面剔除,把背对相机的三角形剔除掉节省性能。同时还需要告诉 GPU 怎样才算三角形的正面和背面,假如我们约定好经过投影之后一个三角形顶点顺序顺时针为正(通过三角面的法线判断),那么 GPU 会把所有逆时针的三角形剔除掉。

接下来是光栅化。所谓光栅化就是扫描线,一个三角形经过投影之后得到一个 2D 的三角形,光栅化就是把这个三角形自上而下逐行扫描并细分为一个个像素,对每个点进行插值。三角形顶点本身是有坐标、纹理坐标和法线,像素数据就是经过这些顶点差值而来。


光栅化的每一个像素都会经过 pixel shader,这个地方就是自己定义着色器的地方,它跟渲染关联非常大。之后进入深度测试,避免重复写入,假如场景里有一座山,这座山前面有一些树,如果我先绘制树再绘制山,一部分山就会被树挡住,而被挡住这一部分是没必要再绘制的。

测试通过会进入 alpha 混合阶段,也是最终输出颜色的阶段。

透视原理

透视是物体成像的一种方式,规律是距离眼睛越近的物体成像体积越大,距离眼睛越远的东西就会变得越小。比如下面两条铁轨应该是平行的,但在人眼看来它是两条直线不断绘制,到无限远的地方趋向于一个点,这就是所谓近大远小。

这里复习 GAMES101 的 Transformation Cont 这一章节,详细讲了透视原理。

接口设计实现

Ray pixel_to_ray(
int const float2 pixel, // [0...width), [0...height)
int const unit2 resolution,
int const float3 ws_camera_pos,
int const Mat4f camera_to_world,
int const Mat4f project_matrix
)
{

}

pixel 为屏幕的像素,resolution 为屏幕的分辨率,ws_camera_pos 是相机的世界坐标,最后两个矩阵是相机空间到世界空间的矩阵 camera_to_world 和相机空间到投影空间的透视投影矩阵 project_matrix

首先把像素 pixelxy 拿出来,z 等于近平面:

float x = pixel.x;
float y = pixel.y;
float z = 1.f;

tan_fov 为相机视口,60度转化为弧度制,ratio 就是屏幕的宽度和高度的比:

float tan_fov = tan(60.f * PI / 180.0f * 0.5f);
float ratio = (float)resolution.x / (float)resolution.y;

之后从视口空间变换到相机空间:

x = (x / resolution.x * 2 - 1) * tan_fov * ratio;
y = (-(y / resolution.y * 2 - 1)) * tan_fov;

完整的 Camera.fx 如下:

Ray pixel_to_ray(
int const float2 pixel, // [0...width), [0...height)
int const unit2 resolution,
int const float3 ws_camera_pos,
int const Mat4f camera_to_world,
int const Mat4f project_matrix
)
{
float x = pixel.x;
float y = pixel.y;
float z = 1.f;
float tan_fov = tan(60.f * PI / 180.0f * 0.5f);
float ratio = (float)resolution.x / (float)resolution.y;
x = (x / resolution.x * 2 - 1) * tan_fov * ratio;
y = (-(y / resolution.y * 2 - 1)) * tan_fov;
float3 mouse3D = float(x, y, z);
float3 ray_dir = normalize(mul(mouse3D, (float3x3)camera_to_world));

Ray ray = {ws_camera_pos, normalize(ray_dir)};
return ray;
}

回到 ScreenRayTracer.cpp,这里的变量名称跟 Shader 定义一样:

_ws_camera_pos = gfx::UniformHash::get("_ws_camera_pos", gfx::UniformType::Var);
_camera_to_world = gfx::UniformHash::get("_camera_to_world", gfx::UniformType::Var);
_project_matrix = gfx::UniformHash::get("_project_matrix", gfx::UniformType::Var);
_resolution = gfx::UniformHash::get("_resolution", gfx::UniformType::Var);
_pt_seed = gfx::UniformHash::get("_pt_seed", gfx::UniformType::Var);

分别是相机世界空间位置、相机局部空间到世界空间的变换矩阵、投影矩阵、分辨率大小和一个随机种子,我们在 C++ 中把这些计算好然后传给 Shader:

void ScreenRayTracer::draw(Viewport& viewport, ShaderVersion shader) 
{
static SetupScreenRayTracer __done;
+ const auto& cam_trasform = viewport.camera.transform.world_matrix;
+ math::Vec3f world_cam_pos{ cam_trasform[3][0], cam_trasform[3][1], cam_trasform[3][2] };
+ std::array resolution = { viewport.rect.width, viewport.rect.height };
+ static Random random;
+ math::Vec2f rand_vec{ random.get(0.0f, 1.0f), random.get(0.0f, 1.0f) };
+ gfx::set_uniform(_resolution, resolution);
+ gfx::set_uniform(_ws_camera_pos, world_cam_pos);
+ gfx::set_uniform(_camera_to_world, cam_trasform);
+ gfx::set_uniform(_project_matrix, viewport.camera.projection_matrix);
+ gfx::set_uniform(_pt_seed, rand_vec);
Filter::draw(viewport.target, viewport.rect, shader);
}

完整的 ScreenRayTracer.cpp 如下:

#include"ScreenRayTracer.hpp"
#include<core/Random.hpp>

namespace tutorial::graphics
{
static gfx::UniformHandle _ws_camera_pos;
static gfx::UniformHandle _camera_to_world;
static gfx::UniformHandle _project_matrix;
static gfx::UniformHandle _resolution;
static gfx::UniformHandle _pt_seed;

struct SetupScreenRayTracer
{
SetupScreenRayTracer() noexcept
{
static bool _init = false;
if (_init) return;
_ws_camera_pos = gfx::UniformHash::get("_ws_camera_pos", gfx::UniformType::Var);
_camera_to_world = gfx::UniformHash::get("_camera_to_world", gfx::UniformType::Var);
_project_matrix = gfx::UniformHash::get("_project_matrix", gfx::UniformType::Var);
_resolution = gfx::UniformHash::get("_resolution", gfx::UniformType::Var);
_pt_seed = gfx::UniformHash::get("_pt_seed", gfx::UniformType::Var);
_init = true;
}
};

void ScreenRayTracer::draw(Viewport& viewport, ShaderVersion shader)
{
static SetupScreenRayTracer __done;
const auto& cam_trasform = viewport.camera.transform.world_matrix;
math::Vec3f world_cam_pos{ cam_trasform[3][0], cam_trasform[3][1], cam_trasform[3][2] };
std::array resolution = { viewport.rect.width, viewport.rect.height };
static Random random;
math::Vec2f rand_vec{ random.get(0.0f, 1.0f), random.get(0.0f, 1.0f) };
gfx::set_uniform(_resolution, resolution);
gfx::set_uniform(_ws_camera_pos, world_cam_pos);
gfx::set_uniform(_camera_to_world, cam_trasform);
gfx::set_uniform(_project_matrix, viewport.camera.projection_matrix);
gfx::set_uniform(_pt_seed, rand_vec);
Filter::draw(viewport.target, viewport.rect, shader);
}
}

场景

Entity

首先定义 Entity,由类型 type 和索引 index 构成:

struct Entity
{
int type;
int index;
};

继续定义球形 SphereMesh,由球心 position、半径 radius 和材质索引 material 组成:

struct SphereMesh
{
float3 position;
float radius;
int material;
};

接着定义四边形,包括起点positionuv 和材质索引 material

struct QuadMesh 
{
float3 position;
float3 u;
float3 v;
int material;
};

需要注意一点 Shader 是没有构造函数的,创建这样的结构体需要自己写一个构造函数,这里我直接写在全局:

SphereMesh ___SphereMesh(in float3 position, float radius, int material) {
SphereMesh ball = (SphereMesh)0;
ball.position = position;
ball.radius = radius;
ball.material = material;
return ball;
}

QuadMesh ___QuadMesh(in float3 position, float3 u, float3 v, int material) {
QuadMesh quad = (QuadMesh)0;
quad.position = position;
quad.u = u;
quad.v = v;
quad.material = material;
return quad;
}

这样我就实现完了几何体的定义和构造函数。完整的 Entity.fx 如下:

struct Entity
{
int type;
int index;
};

struct SphereMesh
{
float3 position;
float radius;
int material;
};

struct QuadMesh
{
float3 position;
float3 u;
float3 v;
int material;
};

SphereMesh ___SphereMesh(in float3 position, float radius, int material) {
SphereMesh ball = (SphereMesh)0;
ball.position = position;
ball.radius = radius;
ball.material = material;
return ball;
}

QuadMesh ___QuadMesh(in float3 position, float3 u, float3 v, int material) {
QuadMesh quad = (QuadMesh)0;
quad.position = position;
quad.u = u;
quad.v = v;
quad.material = material;
return quad;
}

Light

光源有两种类型:面积光和点光源:

static const int QuadLight = 0;
static const int SphereLight = 1;

前面我们只会用到四边形的面积光。

struct Light
{
float3 position;
float radius;
float3 energy;
float type; // 0 : quad, 1 : sphere
float3 u;
float area;
float3 v;
};

其中 position 为光源的位置,radius 为光源的半径,energy 为光源的辐射亮度,type 就是前面的光源的类型。

接着我们实现一个构造函数:

Light ___QuadLight(
in float3 position,
in float3 energy,
in float3 u,
in float3 v
)
{
Light light = (Light)0;
light.position = position;
light.energy = energy;
light.u = u;
light.v = v;
light.type = QuadLight;
return light;
}

这样就完成了四边形光源的定义和构造函数,完整的 Light.fx 如下:

static const int QuadLight = 0;
static const int SphereLight = 1;

struct Light
{
float3 position;
float radius;
float3 energy;
float type; // 0 : quad, 1 : sphere
float3 u;
float area;
float3 v;
};

Light ___QuadLight(
in float3 position,
in float3 energy,
in float3 u,
in float3 v
)
{
Light light = (Light)0;
light.position = position;
light.energy = energy;
light.u = u;
light.v = v;
light.type = QuadLight;
return light;
}

然后我们在 Scene.fx 定义场景的数据结构:

struct Scene
{
uint nball;
uint nquad;
uint nlight;
uint pad;
};

这个结构体记录了多少个球体 nball、四边形 nquad、光源nlight

我们要宏定义这几个几何体的灯光:

#define SphereMeshBuffer(xxx) SphereMesh xxx[1]
#define QuadMeshBuffer(xxx) QuadMesh xxx[6]
#define LightBuffer(xxx) Light xxx[1]

Material

我们还需要在 PathTracerSimple.fx 定义材质,这次只有一个基础的颜色:

struct Material { float3 baseColor; };
Material ___Material(float3 baseColor)
{
Material material;
material.baseColor = baseColor;
return material;
}

#define MaterialBuffer(xxx) Material xxx[4]

Surface

现在我们来实现相交测试。当一条射线打中某个物体的时候记录这一点的信息,包括表面信息 Surface 和相交信息 Hit

struct Surface
{
float3 position;
int material; // material
float3 normal;
float3 ffnormal;
};

其中 ffnormal 作为法线用于判断内外侧。

接着定义相交信息,当一条射线打中一个物体的时候我们需要知道这个射线到这个物体的距离 hit_dist、物体实例 entity 以及打中物体表面信息 surface

struct Hit
{
Entity entity;
float hit_dist;
bool hit;
Surface surface;
};

设置 Hit 构造函数为什么都没打中:

Hit NotHit()
{
static Surface surface = (Surface)0;
static Hit not_hit = { { 0, -1 }, INFINITY, false, surface };
return not_hit;
}

Geometry

打开 Geometry.fx,这个文件定义了常用的 3D 几何体和数学辅助函数:

#ifndef GEOMETRY
#define GEOMETRY
//static const float INFINITY = 1e9;
//static const float EPS = 0.01f;
//static const float PI = 3.14159265358979323f;
//static const float TWO_PI = 6.28318530717958648f;
struct Box { float3 min; float3 max; };
struct Ray { float3 origin; float3 direction; };
struct Sphere { float3 position; float radius; };

射线和球体的相交测试原理在 Ray Tracing in One Weekend 中有详细的数学推导,在此我们直接实现:

// return hit distance, or INFINITY if not intersect
float ray_sphere_intersect(float3 pos, float radius, in Ray r)
{
float3 op = pos - r.origin;
float eps = 0.001;
float b = dot(op, r.direction);
float det = b * b - dot(op, op) + radius * radius;
[branch]
if (det < 0.0)
return INFINITY;

det = sqrt(det);
float t1 = b - det;
[branch]
if (t1 > eps)
return t1;

float t2 = b + det;
[branch]
if (t2 > eps)
return t2;

return INFINITY;
}

该函数返回相交的距离,假如一条射线与球做相交,它会直接返回这一段距离 det;否则返回无限大 INFINITY

还有射线和多边形的相交测试实现:

// return hit distance, or INFINITY if not intersect
float ray_rect_intersect(in float3 pos, in float3 u, in float3 v, in float3 n, in Ray r)
{
float dt = dot(r.direction, n);
float w = dot(n, pos);
float t = (w - dot(n, r.origin)) / dt;
[branch]
if (t > EPS)
{
float3 p = r.origin + r.direction * t;
float3 op = p - pos;

// op project onto u,v, and its length <= |u| and |v|
// 0 <= dot(nomalize(u), op) <= length(u)
// ---> 0 <= dot(normalize(u)/length(u), op) <= 1
// ---> 0 <= dot(u/dot(u,u), op) <= 1
u = u / dot(u, u);
v = v / dot(v, v);
float a1 = dot(u, op);
[branch]
if (a1 >= 0 && a1 <= 1)
{
float a2 = dot(v, op);
[branch]
if (a2 >= 0 && a2 <= 1)
return t;
}
}

return INFINITY;
}

完整的 Geometry.fx 如下:

#ifndef GEOMETRY
#define GEOMETRY
//static const float INFINITY = 1e9;
//static const float EPS = 0.01f;
//static const float PI = 3.14159265358979323f;
//static const float TWO_PI = 6.28318530717958648f;
struct Box { float3 min; float3 max; };
struct Ray { float3 origin; float3 direction; };
struct Sphere { float3 position; float radius; };

// return hit distance, or INFINITY if not intersect
float ray_sphere_intersect(float3 pos, float radius, in Ray r)
{
float3 op = pos - r.origin;
float eps = 0.001;
float b = dot(op, r.direction);
float det = b * b - dot(op, op) + radius * radius;
[branch]
if (det < 0.0)
return INFINITY;

det = sqrt(det);
float t1 = b - det;
[branch]
if (t1 > eps)
return t1;

float t2 = b + det;
[branch]
if (t2 > eps)
return t2;

return INFINITY;
}

// return hit distance, or INFINITY if not intersect
float ray_rect_intersect(in float3 pos, in float3 u, in float3 v, in float3 n, in Ray r)
{
float dt = dot(r.direction, n);
float w = dot(n, pos);
float t = (w - dot(n, r.origin)) / dt;
[branch]
if (t > EPS)
{
float3 p = r.origin + r.direction * t;
float3 op = p - pos;

// op project onto u,v, and its length <= |u| and |v|
// 0 <= dot(nomalize(u), op) <= length(u)
// ---> 0 <= dot(normalize(u)/length(u), op) <= 1
// ---> 0 <= dot(u/dot(u,u), op) <= 1
u = u / dot(u, u);
v = v / dot(v, v);
float a1 = dot(u, op);
[branch]
if (a1 >= 0 && a1 <= 1)
{
float a2 = dot(v, op);
[branch]
if (a2 >= 0 && a2 <= 1)
return t;
}
}

return INFINITY;
}

#endif //GEOMETRY

Scene

回到 Scene.fx,所有的几何求交都会在这里实现。

首先从光源求交开始,定义一个 trace_light() 函数返回光源到交点距离:

float trace_light(in const Ray ray, in const Light light)
{
[branch]
switch (light.type)
{
case QuadLight:
{
float3 u = light.u;
float3 v = light.v;
float3 normal = normalize(cross(u, v));
[branch] if (dot(normal, ray.direction) >= 0)
return INFINITY;
return ray_rect_intersect(light.position, u, v, normal, ray);
}
default:
break;
}

return INFINITY;
}

uv 向量算出法线朝向,判断射线是否与法线同向:不同向才说明打中它的正面;同向直接返回无穷远。

定义一个 trace_scene_lights() 函数,输入整个场景 scene 和光源数组 lights,读写相交信息 hit

void trace_scene_lights(
in const Ray ray,
in Scene scene,
in LightBuffer(lights),
inout Hit hit
)
{
float t = hit.hit_dist;
uint light_count = scene.nlight;
[loop]
for (uint i = 0; i < light_count; ++i)
{
Light light = lights[i];
float d = trace_light(ray, light);
[branch]
if (d < t)
{
t = d;
hit.entity.index = i;
hit.entity.type = LIGHT_ENTITY;
hit.hit_dist = t;
hit.hit = true;
}
}
}

通过保留最短相交距离,我们完成了整个场景与光源的相交测试。最后我们要实现射线与场景球体的相交测试:

void trace_scene_lights(
in const Ray ray,
in Scene scene,
in LightBuffer(lights),
inout Hit hit
)
{
float t = hit.hit_dist;
uint light_count = scene.nlight;
[loop]
for (uint i = 0; i < light_count; ++i)
{
Light light = lights[i];
float d = trace_light(ray, light);
[branch]
if (d < t)
{
t = d;
hit.entity.index = i;
hit.entity.type = LIGHT_ENTITY;
hit.hit_dist = t;
hit.hit = true;
}
}
}

遍历整个光源,对于每个光源调用刚才的 trace_light() 函数算出射线到光源的距离。当相交距离小于 t 的时候才会把这个光源记录下来,同时也保留当前的相交距离作为最近的相交距离。

这样我们就完成了整个场景光源的相交测试,接下来我们来实现场景与球体的相交测试。

void trace_scene_sphere(
in const Ray ray,
in Scene scene,
in SphereMeshBuffer(balls),
inout Hit hit
)
{
float t = hit.hit_dist;
[loop]
for (uint i = 0; i < scene.nball; ++i)
{
SphereMesh mesh = balls[i];
float d = ray_sphere_intersect(mesh.position, mesh.radius, ray);
[branch] if (d < t)
{
t = d;
hit.entity.index = i;
hit.entity.type = SPHERE_ENTITY;
hit.surface.position = ray.origin + ray.direction * d;
hit.surface.normal = normalize(hit.surface.position - mesh.position);
hit.surface.ffnormal = dot(hit.surface.normal, ray.direction) <= 0.0 ? hit.surface.normal : hit.surface.normal * -1.0;
hit.surface.material = mesh.material;
}
}

[branch]
if (t < hit.hit_dist) {
hit.hit_dist = t;
hit.hit = true;
}
}

输入整个球形数组,读写 hit 信息。保留最近的相交信息,逐个遍历最近的球体,对于球体网格调用球体和射线的相交测试,算出相交距离 d:如果该距离小于最近距离,那么保存该 entity,同时记录最小相交距离 t = d

Mask

我们还需要设计场景的 entity 与射线做相交测试。

打开 simple-6-scene/Entity.fx,里面定义了 entity 的类型,通过位操作符算出二进制的掩码:

// enum EntityType
static const int MESH_ENTITY = 1;
static const int LIGHT_ENTITY = 2;
static const int SPHERE_ENTITY = 5;
static const int QUAD_ENTITY = 6;

static const int MASK_MESH = 1 << MESH_ENTITY;
static const int MASK_LIGHT = 1 << LIGHT_ENTITY;
static const int MASK_SPHERE = 1 << SPHERE_ENTITY;
static const int MASK_QUAD = 1 << QUAD_ENTITY;

Init Scene

回到 Shader.fx 文件,实现一条射线怎么和几何体的 entity 相交测试:

void trace_scene(
in const Ray ray,
in const float max_dist, // search distance
in Scene scene,
in SphereMeshBuffer(balls), in QuadMeshBuffer(quads),
in LightBuffer(lights),
in const uint mask,
out Hit hit
)
{
hit = NotHit();
hit.hit_dist = max_dist;

[branch] if ((mask & MASK_SPHERE) > 0) { trace_scene_sphere(ray, scene, balls, hit); }
[branch] if ((mask & MASK_QUAD) > 0) { trace_scene_quad(ray, scene, quads, hit); }
[branch] if ((mask & MASK_LIGHT) > 0) { trace_scene_lights(ray, scene, lights, hit); }
}

最后我们来搭建场景,首先进行初始化:

// init scene
Scene scene = (Scene)0;

定义材质:

MaterialBuffer(materials);
materials[0] = ___Material(float3(1, 1, 1));
materials[1] = ___Material(float3(1, 0, 0));
materials[2] = ___Material(float3(0, 0, 1));
materials[3] = ___Material(float3(0.5,0.5,0.5));
int white = 0;
int red = 1;
int blue = 2;
int grey = 3;

初始化几何体数组,包括一个球体和六面墙:

SphereMeshBuffer(balls);
balls[0] = ___SphereMesh(float3(0, -1.5, 0), 1, white);
balls[0].material = 0;
scene.nball = 1;

QuadMeshBuffer(quads);
quads[0] = ___QuadMesh(float3(-2.5f, 2.5f, 2.5f), float3(5, 0, 0), float3(0, -5, 0), grey); // front
quads[1] = ___QuadMesh(float3(-2.5f, 2.5f, -2.5f), float3(0, 0, 5), float3(0, -5, 0), red); // left
quads[2] = ___QuadMesh(float3(2.5f, 2.5f, 2.5f), float3(0, 0, -5), float3(0, -5, 0), blue); // right
quads[3] = ___QuadMesh(float3(-2.5f, -2.5f, 2.5f), float3(5, 0, 0), float3(0, 0, -5), grey); // down
quads[4] = ___QuadMesh(float3(-2.5f, 2.5f, -2.5f), float3(5, 0, 0), float3(0, 0, 5), grey); // up
quads[5] = ___QuadMesh(float3(2.5f, 2.5f, -2.5f), float3(-5, 0, 0), float3(0, 0, -5), grey); // back
scene.nquad = 6;

加入场景光源:

// lights
LightBuffer(lights);
lights[0] =
___QuadLight(
float3(-1.f, 1.5f, -2.5f), float3(1, 1, 1) * 3.14,
float3(2, 0, 0), float3(0, 0, 2)
);
scene.nlight = 1;

这样场景就初始化完毕了。

Output Scene

现在我们来测试一下场景。

我们已经知道从相机出发的射线,现在要和场景求交,调用 trace_scene() 函数把几何体和光源都放进去,掩码为 MASK_ALL

// first hit
Hit hit;
trace_scene(ray, INFINITY, scene, balls, quads, lights, MASK_ALL, hit);

如果没有打中,直接返回黑色:

if (hit.hit)
{
......
} else {
return float3(0, 0, 0);
}

如果打中,判断类型:

  • 如果是灯光,就返回其辐射亮度
  • 如果是几何体,读取表面材质,返回 baseColor
if (hit.hit)
{
if (hit.entity.type == LIGHT_ENTITY) {
int index = hit.entity.index;
return lights[index].energy;
}
else if (hit.entity.type == SPHERE_ENTITY || hit.entity.type == QUAD_ENTITY) {
Material material = materials[hit.surface.material];
return material.baseColor;
}
}

完整源码如下:

SamplerState _samp_point: register(s0);
cbuffer Frame
{
uint _frame_index;
float2 _seed;
}

struct Material { float3 baseColor; };
Material ___Material(float3 baseColor)
{
Material material;
material.baseColor = baseColor;
return material;
}

#define MaterialBuffer(xxx) Material xxx[4]


float3 pathtracing(in Ray ray, inout Random random)
{
// init scene
Scene scene = (Scene)0;

MaterialBuffer(materials);
materials[0] = ___Material(float3(1, 1, 1));
materials[1] = ___Material(float3(1, 0, 0));
materials[2] = ___Material(float3(0, 0, 1));
materials[3] = ___Material(float3(0.5,0.5,0.5));
int white = 0;
int red = 1;
int blue = 2;
int grey = 3;

SphereMeshBuffer(balls);
balls[0] = ___SphereMesh(float3(0, -1.5, 0), 1, white);
balls[0].material = 0;
scene.nball = 1;

QuadMeshBuffer(quads);
quads[0] = ___QuadMesh(float3(-2.5f, 2.5f, 2.5f), float3(5, 0, 0), float3(0, -5, 0), grey); // front
quads[1] = ___QuadMesh(float3(-2.5f, 2.5f, -2.5f), float3(0, 0, 5), float3(0, -5, 0), red); // left
quads[2] = ___QuadMesh(float3(2.5f, 2.5f, 2.5f), float3(0, 0, -5), float3(0, -5, 0), blue); // right
quads[3] = ___QuadMesh(float3(-2.5f, -2.5f, 2.5f), float3(5, 0, 0), float3(0, 0, -5), grey); // down
quads[4] = ___QuadMesh(float3(-2.5f, 2.5f, -2.5f), float3(5, 0, 0), float3(0, 0, 5), grey); // up
quads[5] = ___QuadMesh(float3(2.5f, 2.5f, -2.5f), float3(-5, 0, 0), float3(0, 0, -5), grey); // back
scene.nquad = 6;

// lights
LightBuffer(lights);
lights[0] =
___QuadLight(
float3(-1.f, 1.5f, -2.5f), float3(1, 1, 1) * 3.14,
float3(2, 0, 0), float3(0, 0, 2)
);
scene.nlight = 1;


// first hit
Hit hit;
trace_scene(ray, INFINITY, scene, balls, quads, lights, MASK_ALL, hit);
if (hit.hit)
{
if (hit.entity.type == LIGHT_ENTITY) {
int index = hit.entity.index;
return lights[index].energy;
}
else if (hit.entity.type == SPHERE_ENTITY || hit.entity.type == QUAD_ENTITY) {
Material material = materials[hit.surface.material];
return material.baseColor;
}
} else {
return float3(0, 0, 0);
}

return 0;
}


float4 pathtrace_loop(
in Ray ray,
in float2 uv,
in uint2 resolution
)
{
Random random = { uv, _seed };
float3 color = pathtracing(ray, random);
return float4(color, 1.0f);
}

最终效果如下:

蒙特卡洛积分

介绍

本节会介绍蒙特卡罗积分,详情请参见 GAMES101 不再赘述。

随机变量

随机变量可以拆开为 随机变量

之所以是变量,是因为每次随机出来的结果都不一样,它能变的范围(样本空间)是有限的。

期望

蒙特卡洛积分

蒙特卡罗提出一种随机采样方式,利用随机采样估算这个范围的积分,当 $N$ 足够大时就会接近这个积分。

高维积分

重要性采样

X1234
P0.20.50.10.2
C0.20.70.81.0

重要性采样就是一个带着权重的随机。

路径追踪基础框架

介绍

切线空间

路径追踪实现

重构

微表面

介绍

微表面

NDF

PDF 和 CDF

实现

迪士尼原则

介绍

迪士尼原则

直接光BRDF

双向追踪

路径追踪性能

原理

改写渲染方程

减枝函数

灯光估算

采样灯光优化