实时要求

实时渲染 Real-Time Rendering 在用户操作和场景变化的过程中,持续生成相机当前看到的图像。它既要快,也要及时反映输入:转动相机之后,下一帧就应表现新的观察方向,预先准备好的电影不能代替这一过程。

速度常用 FPS Frames per Second 表示,即每秒生成多少帧。课程用超过 30 FPS 作为实时的直观门槛,VR、AR 等应用往往需要更高刷新率,例如 90 FPS。换成每帧时间,30 FPS 约有 33.3 ms33.3\,\mathrm{ms},90 FPS 约有 11.1 ms11.1\,\mathrm{ms}。这些时间还要分给场景更新、几何处理、光照、后处理和提交显示,某个算法不能独占全部预算。

交互式渲染 Interactive Rendering 可以在操作后较快给出反馈,但不一定达到实时帧率;每秒几帧的预览也可能具有交互价值。帧率门槛依应用而变,操作到画面更新的延迟、偶发慢帧与持续帧率同样重要。

“高质量”还包含可靠性 Dependability。近似可以存在,但需要知道它在什么场景下成立、误差怎样表现。单张图中一个异常像素或许不明显,连续播放时随机跳动的像素、忽明忽暗的阴影和反射断裂却很容易被发现。实时算法要面对用户任意转动相机和移动物体,不能只挑选少量成功视角展示结果。

离线汽车渲染与实时游戏画面的对照

左侧汽车是离线渲染,右侧是《Final Fantasy XV》的实时画面。它们没有使用相同模型和照明,所以这张图不能用于定量比较画质;它说明两种工作方式的目标:离线方法可以用较长时间计算细节,实时方法需要在严格帧预算内呈现几何、材质和光照。

发展脉络

早期实时三维图形主要处理几何投影、纹理与简单阴影。1997 年的《Final Fantasy VII》、1999 年的《Counter-Strike》已经能实时显示三维场景,但几何数量、纹理过滤和光照表达都受计算能力限制。

2000 年前后,可编程着色器 Programmable Shader 让开发者能够定义顶点和片元怎样计算。物体不再只能使用一组固定的硬件光照操作,环境光照、不同材质与软阴影可以逐渐进入实时管线。

间接光不足与高光模型不合适造成的外观问题

左图是《Assassin’s Creed II》:天空和受光区域较亮,阴影中的街道却明显偏暗,可以用来观察间接照明不足的问题。右图是《Resident Evil 5》:衣物与皮肤上的高光具有相近的强烈反光,提示材质响应可能缺少足够区分。判断质量应结合场景原本的照明与材料,不能把“更亮”或“高光更弱”作为通用正确标准。

后续发展的几个方向相互配合:

  • 预计算 Precomputation:把复杂光照的一部分提前算好,运行时查表或组合系数;代价是存储和对场景变化的限制。
  • 低采样光追与降噪:先在 GPU 上产生少量光线样本,再利用图像和历史帧重建,减少每帧必须计算的光路。
  • 系统化管线:把阴影、间接光、材质和显示组织为多个相互依赖的阶段,控制带宽、显存与调度成本。

课程中的 CUDA、OptiX 交互预览、Unreal 场景和 DLSS 2.0 等演示属于相应年代的技术背景。理解它们时,应关注解决了什么计算问题,不把当时的硬件速度和功能限制当作当前统一结论。

计算分工

实时渲染可以近似描述为快速的光传输近似与系统工程相结合。理论决定哪些项可以近似、误差来自哪里;实现决定数据怎样组织、访问和并行执行。理解一个公式,并不意味着已经解决了引擎中的带宽、同步和场景管理问题。

主要问题包括阴影与环境光、全局光照、基于物理的材质和实时光线追踪。参与介质、屏幕空间效果、非真实感渲染、抗锯齿与重建也会与这些问题发生联系。它们按专题组织,所共享的基础是渲染方程、采样和图形管线。

CPU 通常负责准备场景、组织绘制任务和设置参数,GPU 执行大量顶点、片元及其他并行计算。GPU 的优势主要来自许多计算单元同时工作,以及针对图形处理的硬件支持,不能简单理解为每个计算单元都比 CPU 更快。

OpenGL、DirectX、Vulkan 等是应用与图形系统之间的接口;GLSL、HLSL 则用于描述着色程序。CPU 端调用接口所用的语言,与 GPU 上执行的着色语言需要分开理解。课程框架用 JavaScript 和 WebGL 管理任务,主要算法在 GLSL 中实现;原理并不依赖这一套前端。

渲染管线

渲染管线 Graphics Pipeline 把三维顶点与连接关系转换成最终像素。先明确每一步输入和输出,再理解哪些步骤需要编程,哪些步骤由固定功能处理。

顶点、三角形、片元与最终像素之间的数据流

图中右侧从点开始,连接为三角形,再形成覆盖样本和着色结果;最后黄色、蓝色三角形只留下可见部分。对应过程为:

  1. 顶点处理 Vertex Processing:变换位置,计算需要传给后续阶段的属性。
  2. 图元组装、裁剪和相应处理:按索引连接顶点,处理观察范围边界。
  3. 光栅化 Rasterization:判断投影三角形覆盖哪些离散位置,并准备插值属性。
  4. 片元处理 Fragment Processing:查询纹理、计算照明或输出其他数据。
  5. 帧缓冲操作:深度和模板测试、混合及写入,最终形成图像。

片元 Fragment 是图元覆盖某处后产生的待处理记录,同一像素可能收到多个片元,片元也可能被测试丢弃。它与最终像素不是一一对应;使用 MSAA 时,一个像素还可能包含多个覆盖与深度样本。

深度缓冲记录每个样本目前最近的可见深度,新片元通过比较决定是否更新。深度数值和比较方向取决于投影与缓冲约定,不能总是机械地取最小值。硬件可能执行提前深度测试,但其可用条件与着色器行为有关,不宜把“先着色还是先测深度”规定成所有管线唯一顺序。

数据与变换

模型先表示成顶点属性和索引。顶点缓冲 Vertex Buffer 保存位置、法线、UV 等数组;索引缓冲规定哪三个顶点组成一个三角形。VBO 是缓冲对象的一种使用方式,不是把完整 OBJ 文本直接交给 GPU,也不必把连接信息和全部属性装进同一个对象。

一个顶点的位置经过模型、视图和投影变换:

pclip=PVMplocal.\mathbf p_{\rm clip}=PVM\mathbf p_{\rm local}.

MM 决定模型如何放到世界中,VV 将世界转换到相机坐标,PP 决定正交或透视投影。输出是裁剪坐标,之后还需要齐次除法和视口映射。视图与投影的详细来由可复习观察与投影。

应用或数学库负责构造这些矩阵,并将它们传给着色器。早期固定管线中的矩阵接口和 GLU 工具是历史示例,现代着色程序不会由驱动自动猜出相机、MVP 或场景坐标约定。

UV 将表面点对应到纹理位置,顶点 UV 通过插值传到片元。生成 UV 属于几何参数化问题,既要控制局部扭曲,也要处理接缝和面积分配。片元中的属性通常需要透视校正插值,不能用屏幕上的普通线性平均代替所有属性插值。

目标与分趟

设置好模型和观察方式后,还要决定结果写到哪里。帧缓冲对象 Framebuffer Object,FBO 组织颜色、深度和模板等输出附件;实际存储通常由纹理或 renderbuffer 提供。FBO 不负责定义相机,也不能把它与颜色纹理本身视为同一个对象。

渲染趟次 Rendering Pass 是为一个目标执行的一组渲染操作。每趟需要明确:

  • 输入的模型和纹理;
  • 相机、变换、材质与光源参数;
  • 输出附件、视口、深度等状态;
  • 执行的着色程序。

多渲染目标 Multiple Render Targets,MRT 允许一趟写多个颜色附件,例如同时输出法线、材质和颜色;具体支持、数量和输出语法由目标 API 与版本决定。深度还可能由硬件测试和深度附件记录,不必把每种数据都写成普通颜色。

一趟的输出纹理可以成为下一趟的输入。阴影贴图就是典型例子:先从光源观察场景,生成最近深度;再从相机观察,用前一趟的深度判断当前表面是否被光照到。

1
2
3
4
5
6
7
光源趟:
设置光源观察矩阵、投影矩阵和深度输出
绘制场景,保存光源看到的最近表面
相机趟:
设置相机参数、颜色和深度输出
输入阴影深度纹理
对可见片元计算照明及阴影,写入图像

模型和程序通常预先传到 GPU,再批量提交绘制任务。每个三角形都在 CPU 与 GPU 之间反复传递和提交,会增加通信与调度开销。接口中“绑定”某个对象,只表示后续操作使用这个对象;把它理解为明确选择当前输入或输出,比较容易理解状态设置的作用。

显示同步

完成一帧与显示一帧是不同动作。双缓冲 Double Buffering 用前缓冲供显示扫描,用后缓冲生成下一帧,完成后再交换。三缓冲 Triple Buffering 增加可用缓冲,使渲染和呈现有更多调度空间,但具体队列策略会影响延迟。

如果显示设备扫描一帧期间,所读取的图像被换成另一帧,上下区域可能来自不同帧,形成画面撕裂 Tearing。垂直同步 Vertical Synchronization,VSync 将呈现与刷新时机协调,控制这种撕裂;双缓冲本身并不自动保证无撕裂,也不能把所有离屏 FBO 都称为交换链缓冲。

现代渲染通常先在离屏目标完成光照和后处理,再把结果提交显示。理解“计算目标”“呈现缓冲”和“显示时机”的区别,有助于定位多趟渲染与显示同步的问题。

着色语言

着色器 Shader 定义每个顶点或片元怎样计算。以 GLSL 为例,源码近似 C 的表达方式,还直接提供向量、矩阵、点积和纹理查询。程序经过编译和链接,最后由图形实现转换为 GPU 可执行形式。

顶点和片元程序各自编译成功,不代表它们能够一起工作。链接还要核对阶段接口,例如传出的属性名称、类型和相应约定是否匹配。一个基本初始化流程为:

1
2
3
4
5
6
读取顶点和片元着色器源码
分别创建着色器、提交源码并编译
检查编译状态,失败时读取日志
创建程序,附加已编译阶段,执行链接
检查链接状态,失败时读取日志
选择程序,设置输入与共享参数,再提交绘制

课程使用的 GLSL ES 风格中:

  • attribute 表示由顶点数组提供的逐顶点输入;
  • uniform 表示在一次绘制期间共享、由应用设置的参数;
  • varying 连接顶点与片元阶段,在光栅化时按插值规则产生片元值;
  • sampler2D 指定纹理采样接口,结合 UV 查询纹理;
  • gl_Position 接收顶点裁剪坐标,gl_FragColor 接收单颜色输出。

uniform 不等于所有着色器之间自动共享的全局内存;每个程序的值需要按接口设置。较新的 GLSL 版本使用 in、out 和显式片元输出等语法,不能把不同版本的接口直接混写。GLSL、HLSL、历史上的 Cg 与 GPU 汇编体现了不同编程接口,算法中的向量与光照运算仍然可以对应。

顶点程序

顶点程序主要完成位置变换,并为片元准备属性。下面统一用世界坐标计算照明,采用课程框架的接口风格;uNormalMatrix 由应用提供,表示模型线性部分的逆转置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
attribute vec3 aPosition;
attribute vec3 aNormal;
attribute vec2 aUV;

uniform mat4 uModel;
uniform mat4 uView;
uniform mat4 uProjection;
uniform mat3 uNormalMatrix;

varying highp vec3 vWorldPosition;
varying highp vec3 vWorldNormal;
varying highp vec2 vUV;

// 将位置、法线送到同一坐标空间,UV 交给后续插值。
void main() {
vec4 worldPosition = uModel * vec4(aPosition, 1.0);
vWorldPosition = worldPosition.xyz;
vWorldNormal = uNormalMatrix * aNormal;
vUV = aUV;
gl_Position = uProjection * uView * worldPosition;
}

点的齐次末位为 11,使平移能够作用于位置;方向末位为 00,不会接受平移。但法线还要保持与变换后的切向量垂直:含非均匀缩放时,直接乘模型矩阵不能满足这一要求,必须使用法线变换中的逆转置。

vWorldNormal 在插值后通常不再具有单位长度,片元阶段还要归一化。光源位置、相机位置和表面位置也必须在同一个空间;着色错误经常来自混用模型空间、世界空间和相机空间。

片元程序

片元阶段拿到插值结果后,计算当前表面的颜色。以经验性的 Blinn–Phong 模型 演示一个点光源:l\mathbf l、v\mathbf v 分别是表面指向光源、相机的单位方向,半程向量 h=(l+v)/∥l+v∥\mathbf h=(\mathbf l+\mathbf v)/\|\mathbf l+\mathbf v\|。漫反射依赖 max⁡(0,n⋅l)\max(0,\mathbf n\cdot\mathbf l),高光依赖 max⁡(0,n⋅h)s\max(0,\mathbf n\cdot\mathbf h)^s。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
precision highp float;

uniform sampler2D uBaseColor;
uniform vec3 uLightPosition;
uniform vec3 uCameraPosition;
uniform vec3 uLightIntensity;
uniform vec3 uSpecularColor;
uniform float uShininess;

varying highp vec3 vWorldPosition;
varying highp vec3 vWorldNormal;
varying highp vec2 vUV;

// 在世界坐标中演示局部照明;这里省略阴影和完整 PBR 材质。
void main() {
vec3 encodedColor = texture2D(uBaseColor, vUV).rgb;
vec3 baseColor = pow(encodedColor, vec3(2.2));
vec3 toLight = uLightPosition - vWorldPosition;
float distanceSquared = max(dot(toLight, toLight), 1e-6);
vec3 l = normalize(toLight);
vec3 v = normalize(uCameraPosition - vWorldPosition);
vec3 n = normalize(vWorldNormal);

vec3 hSum = l + v;
float hLengthSquared = dot(hSum, hSum);
float specular = 0.0;
if (hLengthSquared > 1e-6 && dot(n, l) > 0.0) {
vec3 h = hSum * inversesqrt(hLengthSquared);
specular = pow(max(dot(n, h), 0.0), uShininess);
}

vec3 incident = uLightIntensity / distanceSquared;
vec3 diffuse = baseColor * max(dot(n, l), 0.0);
vec3 ambient = 0.05 * baseColor;
vec3 linearColor = ambient + incident *
(diffuse + uSpecularColor * specular);
vec3 displayColor = pow(max(linearColor, vec3(0.0)), vec3(1.0 / 2.2));
gl_FragColor = vec4(displayColor, 1.0);
}

这个示例假设片元阶段支持 highp,输入颜色尚未自动解码,输出也没有自动编码。它还假设光源和相机不与着色点重合;实际系统要处理退化输入。代码中的小下限只防止除零,不代表有限尺寸光源的正确近场模型。

几点需要与公式对应:

  • 理想点光源的辐照度按 1/r21/r^2 衰减,示例使用距离平方。经验演示中的其他衰减方式需要额外说明,不能与平方反比定律混称。
  • 若高光写成反射方向与视线的 max⁡(0,r⋅v)s\max(0,\mathbf r\cdot\mathbf v)^s,那是 Phong 高光;使用半程向量则是 Blinn–Phong,二者不要仅凭程序名混淆。
  • 0.050.05 的环境项是人为的局部补光近似,不是已经求出了间接照明。
  • pow(c, 2.2) 和 pow(c, 1.0/2.2) 分别近似颜色解码与显示编码。光照和混合应在线性空间计算,精确 sRGB 分段曲线可复习sRGB;若纹理或目标已自动转换,不要重复处理。

调试方法

编译失败时先看编译日志,接口不匹配时看链接日志;图像全黑还可能来自几何不在视野内、输出目标设置错误、纹理未绑定或计算中出现非法值,不能仅凭全黑判断具体原因。

一个直接的调试方法,是把中间数值编码成颜色输出。如果已知距离 d∈[0,200]d\in[0,200],可以输出灰度 d/200d/200;若某分量在 [−1,1][-1,1],可用 (x+1)/2(x+1)/2 映射到 [0,1][0,1];法线的三分量也可如此映射成 RGB。这样能够观察方向是否突变、深度是否连续、可见性是否在预期区域变化。

应同时注意编码与显示的区别:颜色拾取器读到的通常是显示编码后的值,调试输出若经过色调映射或伽马转换,不能直接乘回范围当作原始数据。投影深度也通常不是线性的相机距离,需要明确输出的是哪一种深度。

需要精确数值时,可以输出到适当精度的离屏纹理并读取像素,但 GPU 到 CPU 的读回和同步有成本,不适合在每个像素上反复进行。RenderDoc、Nsight 等工具提供更丰富的捕获和调试能力,API、平台与硬件支持需按实际版本核对;课堂时期的限制不作为当前使用说明。

渲染方程

局部着色程序容易计算,但要知道它省略了什么,需要回到渲染方程 Rendering Equation。一个表面点沿观察方向离开的光,由自身发光和其他方向入射光的反射组成:

Lo(p,ωo)=Le(p,ωo)+∫Ω+fr(p,ωi,ωo)Li(p,ωi)max⁡(0,n⋅ωi) dωi.L_o(\mathbf p,\boldsymbol\omega_o) =L_e(\mathbf p,\boldsymbol\omega_o) +\int_{\Omega^+} f_r(\mathbf p,\boldsymbol\omega_i,\boldsymbol\omega_o) L_i(\mathbf p,\boldsymbol\omega_i) \max(0,\mathbf n\cdot\boldsymbol\omega_i)\,\mathrm d\omega_i.

LoL_o 是出射辐亮度 Radiance,LeL_e 是发光项,LiL_i 是实际到达该点的入射辐亮度;BRDF frf_r 描述光如何从入射方向分配到出射方向。余弦项使入射方向的辐亮度转为表面接收的辐照度贡献。逐方向累积,就得到这一观察方向上的反射结果。详细单位可复习辐射度量。

实时算法常把未考虑遮挡的光照与可见性拆开。例如用 Lenv(ωi)L_{\rm env}(\boldsymbol\omega_i) 表示远处环境发出的辐亮度,则其直接反射为

Lo,env(p,ωo)=∫Ω+Lenv(ωi)fr(p,ωi,ωo)max⁡(0,n⋅ωi)V(p,ωi) dωi.L_{o,\rm env}(\mathbf p,\boldsymbol\omega_o) =\int_{\Omega^+}L_{\rm env}(\boldsymbol\omega_i) f_r(\mathbf p,\boldsymbol\omega_i,\boldsymbol\omega_o) \max(0,\mathbf n\cdot\boldsymbol\omega_i) V(\mathbf p,\boldsymbol\omega_i)\,\mathrm d\omega_i.

可见性 Visibility VV 在不透明表面场景中取 00 或 11,表示该方向是否被遮挡。若 LiL_i 已经表示实际到达的光,就不能再乘一次同一遮挡项。透明介质的透射率可以是连续量,需要与这里的二值可见性区分。

工程中有时把 frf_r 和余弦项合称为着色响应。阅读公式和代码时仍应辨认约定,避免把余弦重复乘入或漏掉;本套笔记将二者分开写。

环境光照

环境光照 Environment Lighting 用方向函数描述远处各方向的光。对一个着色点,前方可能是亮窗,后方可能是暗墙,上方可能是天空;把这些方向统一成常数“环境颜色”,就丢失了方向结构。

立方体贴图与经纬展开的环境图

左侧把六个方向展开成十字形 Cube Map,右侧是按球面方向展开的环境纹理。查询变量是方向,图片上的每个位置对应一个入射方向;不同展开中,等面积纹素不一定对应等立体角,积分和采样时需要相应权重。后面还会使用八面体展开及基函数表示。

环境光照的难点可以直接从积分看出:每个可见片元都要收集整个半球的贡献,同时考虑材质响应和可见性。直接查询所有环境纹素通常无法满足帧预算,后续的预过滤、积分近似和光照预计算就是为减少这些查询而设计的。

间接光照

某个方向到达表面的光,还可能是另一块表面反射出来的。因此渲染方程是递归关系,不能只考虑光源与当前表面的连接。

直接光照 Direct Illumination 的路径为光源→表面→相机;间接光照 Indirect Illumination 至少经过额外的非发光表面,例如光源→表面 A→表面 B→相机。全局光照 Global Illumination,GI 包含直接和间接部分,讨论“实时 GI”时,主要困难通常在间接部分。

只有直接照明时,门廊内的 p 点较暗

光源能照到庭院,但阴影内的 pp 点缺少直接照明,附近墙面也偏暗。这不是给所有阴影乘一个任意灰色系数就能准确解决的问题:需要知道其他受光表面怎样把光带进来。

加入一次间接传播后,门廊与墙面得到反射光

加入光源→A→B→相机的路径后,庭院受光表面将光反射到门廊,pp 点和相邻墙面变亮。这里的“一次间接弹射”指从着色点追溯时额外访问一次非发光表面,不是把这条路径中的所有表面交互只数成一次。

再增加一次间接传播时,整体变化相对较小

在这个例子中,从直接照明到一次间接照明的变化较大,再增加一次则较小。这给实时近似提供了动机:先解决视觉贡献较大的第一层间接光。但这不是普遍保证,封闭空间、高反照率材料、焦散或多次镜面反射仍可能依赖更长光路。有限弹射近似需要结合具体场景判断。