实时渲染-01:概览与渲染管线
实时要求
实时渲染 Real-Time Rendering 在用户操作和场景变化的过程中,持续生成相机当前看到的图像。它既要快,也要及时反映输入:转动相机之后,下一帧就应表现新的观察方向,预先准备好的电影不能代替这一过程。
速度常用 FPS Frames per Second 表示,即每秒生成多少帧。课程用超过 30 FPS 作为实时的直观门槛,VR、AR 等应用往往需要更高刷新率,例如 90 FPS。换成每帧时间,30 FPS 约有 ,90 FPS 约有 。这些时间还要分给场景更新、几何处理、光照、后处理和提交显示,某个算法不能独占全部预算。
交互式渲染 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 把三维顶点与连接关系转换成最终像素。先明确每一步输入和输出,再理解哪些步骤需要编程,哪些步骤由固定功能处理。

图中右侧从点开始,连接为三角形,再形成覆盖样本和着色结果;最后黄色、蓝色三角形只留下可见部分。对应过程为:
- 顶点处理 Vertex Processing:变换位置,计算需要传给后续阶段的属性。
- 图元组装、裁剪和相应处理:按索引连接顶点,处理观察范围边界。
- 光栅化 Rasterization:判断投影三角形覆盖哪些离散位置,并准备插值属性。
- 片元处理 Fragment Processing:查询纹理、计算照明或输出其他数据。
- 帧缓冲操作:深度和模板测试、混合及写入,最终形成图像。
片元 Fragment 是图元覆盖某处后产生的待处理记录,同一像素可能收到多个片元,片元也可能被测试丢弃。它与最终像素不是一一对应;使用 MSAA 时,一个像素还可能包含多个覆盖与深度样本。
深度缓冲记录每个样本目前最近的可见深度,新片元通过比较决定是否更新。深度数值和比较方向取决于投影与缓冲约定,不能总是机械地取最小值。硬件可能执行提前深度测试,但其可用条件与着色器行为有关,不宜把“先着色还是先测深度”规定成所有管线唯一顺序。
数据与变换
模型先表示成顶点属性和索引。顶点缓冲 Vertex Buffer 保存位置、法线、UV 等数组;索引缓冲规定哪三个顶点组成一个三角形。VBO 是缓冲对象的一种使用方式,不是把完整 OBJ 文本直接交给 GPU,也不必把连接信息和全部属性装进同一个对象。
一个顶点的位置经过模型、视图和投影变换:
决定模型如何放到世界中, 将世界转换到相机坐标, 决定正交或透视投影。输出是裁剪坐标,之后还需要齐次除法和视口映射。视图与投影的详细来由可复习观察与投影。
应用或数学库负责构造这些矩阵,并将它们传给着色器。早期固定管线中的矩阵接口和 GLU 工具是历史示例,现代着色程序不会由驱动自动猜出相机、MVP 或场景坐标约定。
UV 将表面点对应到纹理位置,顶点 UV 通过插值传到片元。生成 UV 属于几何参数化问题,既要控制局部扭曲,也要处理接缝和面积分配。片元中的属性通常需要透视校正插值,不能用屏幕上的普通线性平均代替所有属性插值。
目标与分趟
设置好模型和观察方式后,还要决定结果写到哪里。帧缓冲对象 Framebuffer Object,FBO 组织颜色、深度和模板等输出附件;实际存储通常由纹理或 renderbuffer 提供。FBO 不负责定义相机,也不能把它与颜色纹理本身视为同一个对象。
渲染趟次 Rendering Pass 是为一个目标执行的一组渲染操作。每趟需要明确:
- 输入的模型和纹理;
- 相机、变换、材质与光源参数;
- 输出附件、视口、深度等状态;
- 执行的着色程序。
多渲染目标 Multiple Render Targets,MRT 允许一趟写多个颜色附件,例如同时输出法线、材质和颜色;具体支持、数量和输出语法由目标 API 与版本决定。深度还可能由硬件测试和深度附件记录,不必把每种数据都写成普通颜色。
一趟的输出纹理可以成为下一趟的输入。阴影贴图就是典型例子:先从光源观察场景,生成最近深度;再从相机观察,用前一趟的深度判断当前表面是否被光照到。
1 | 光源趟: |
模型和程序通常预先传到 GPU,再批量提交绘制任务。每个三角形都在 CPU 与 GPU 之间反复传递和提交,会增加通信与调度开销。接口中“绑定”某个对象,只表示后续操作使用这个对象;把它理解为明确选择当前输入或输出,比较容易理解状态设置的作用。
显示同步
完成一帧与显示一帧是不同动作。双缓冲 Double Buffering 用前缓冲供显示扫描,用后缓冲生成下一帧,完成后再交换。三缓冲 Triple Buffering 增加可用缓冲,使渲染和呈现有更多调度空间,但具体队列策略会影响延迟。
如果显示设备扫描一帧期间,所读取的图像被换成另一帧,上下区域可能来自不同帧,形成画面撕裂 Tearing。垂直同步 Vertical Synchronization,VSync 将呈现与刷新时机协调,控制这种撕裂;双缓冲本身并不自动保证无撕裂,也不能把所有离屏 FBO 都称为交换链缓冲。
现代渲染通常先在离屏目标完成光照和后处理,再把结果提交显示。理解“计算目标”“呈现缓冲”和“显示时机”的区别,有助于定位多趟渲染与显示同步的问题。
着色语言
着色器 Shader 定义每个顶点或片元怎样计算。以 GLSL 为例,源码近似 C 的表达方式,还直接提供向量、矩阵、点积和纹理查询。程序经过编译和链接,最后由图形实现转换为 GPU 可执行形式。
顶点和片元程序各自编译成功,不代表它们能够一起工作。链接还要核对阶段接口,例如传出的属性名称、类型和相应约定是否匹配。一个基本初始化流程为:
1 | 读取顶点和片元着色器源码 |
课程使用的 GLSL ES 风格中:
attribute表示由顶点数组提供的逐顶点输入;uniform表示在一次绘制期间共享、由应用设置的参数;varying连接顶点与片元阶段,在光栅化时按插值规则产生片元值;sampler2D指定纹理采样接口,结合 UV 查询纹理;gl_Position接收顶点裁剪坐标,gl_FragColor接收单颜色输出。
uniform 不等于所有着色器之间自动共享的全局内存;每个程序的值需要按接口设置。较新的 GLSL 版本使用 in、out 和显式片元输出等语法,不能把不同版本的接口直接混写。GLSL、HLSL、历史上的 Cg 与 GPU 汇编体现了不同编程接口,算法中的向量与光照运算仍然可以对应。
顶点程序
顶点程序主要完成位置变换,并为片元准备属性。下面统一用世界坐标计算照明,采用课程框架的接口风格;uNormalMatrix 由应用提供,表示模型线性部分的逆转置。
1 | attribute vec3 aPosition; |
点的齐次末位为 ,使平移能够作用于位置;方向末位为 ,不会接受平移。但法线还要保持与变换后的切向量垂直:含非均匀缩放时,直接乘模型矩阵不能满足这一要求,必须使用法线变换中的逆转置。
vWorldNormal 在插值后通常不再具有单位长度,片元阶段还要归一化。光源位置、相机位置和表面位置也必须在同一个空间;着色错误经常来自混用模型空间、世界空间和相机空间。
片元程序
片元阶段拿到插值结果后,计算当前表面的颜色。以经验性的 Blinn–Phong 模型 演示一个点光源:、 分别是表面指向光源、相机的单位方向,半程向量 。漫反射依赖 ,高光依赖 。
1 | precision highp float; |
这个示例假设片元阶段支持 highp,输入颜色尚未自动解码,输出也没有自动编码。它还假设光源和相机不与着色点重合;实际系统要处理退化输入。代码中的小下限只防止除零,不代表有限尺寸光源的正确近场模型。
几点需要与公式对应:
- 理想点光源的辐照度按 衰减,示例使用距离平方。经验演示中的其他衰减方式需要额外说明,不能与平方反比定律混称。
- 若高光写成反射方向与视线的 ,那是 Phong 高光;使用半程向量则是 Blinn–Phong,二者不要仅凭程序名混淆。
- 的环境项是人为的局部补光近似,不是已经求出了间接照明。
pow(c, 2.2)和pow(c, 1.0/2.2)分别近似颜色解码与显示编码。光照和混合应在线性空间计算,精确 sRGB 分段曲线可复习sRGB;若纹理或目标已自动转换,不要重复处理。
调试方法
编译失败时先看编译日志,接口不匹配时看链接日志;图像全黑还可能来自几何不在视野内、输出目标设置错误、纹理未绑定或计算中出现非法值,不能仅凭全黑判断具体原因。
一个直接的调试方法,是把中间数值编码成颜色输出。如果已知距离 ,可以输出灰度 ;若某分量在 ,可用 映射到 ;法线的三分量也可如此映射成 RGB。这样能够观察方向是否突变、深度是否连续、可见性是否在预期区域变化。
应同时注意编码与显示的区别:颜色拾取器读到的通常是显示编码后的值,调试输出若经过色调映射或伽马转换,不能直接乘回范围当作原始数据。投影深度也通常不是线性的相机距离,需要明确输出的是哪一种深度。
需要精确数值时,可以输出到适当精度的离屏纹理并读取像素,但 GPU 到 CPU 的读回和同步有成本,不适合在每个像素上反复进行。RenderDoc、Nsight 等工具提供更丰富的捕获和调试能力,API、平台与硬件支持需按实际版本核对;课堂时期的限制不作为当前使用说明。
渲染方程
局部着色程序容易计算,但要知道它省略了什么,需要回到渲染方程 Rendering Equation。一个表面点沿观察方向离开的光,由自身发光和其他方向入射光的反射组成:
是出射辐亮度 Radiance, 是发光项, 是实际到达该点的入射辐亮度;BRDF 描述光如何从入射方向分配到出射方向。余弦项使入射方向的辐亮度转为表面接收的辐照度贡献。逐方向累积,就得到这一观察方向上的反射结果。详细单位可复习辐射度量。
实时算法常把未考虑遮挡的光照与可见性拆开。例如用 表示远处环境发出的辐亮度,则其直接反射为
可见性 Visibility 在不透明表面场景中取 或 ,表示该方向是否被遮挡。若 已经表示实际到达的光,就不能再乘一次同一遮挡项。透明介质的透射率可以是连续量,需要与这里的二值可见性区分。
工程中有时把 和余弦项合称为着色响应。阅读公式和代码时仍应辨认约定,避免把余弦重复乘入或漏掉;本套笔记将二者分开写。
环境光照
环境光照 Environment Lighting 用方向函数描述远处各方向的光。对一个着色点,前方可能是亮窗,后方可能是暗墙,上方可能是天空;把这些方向统一成常数“环境颜色”,就丢失了方向结构。

左侧把六个方向展开成十字形 Cube Map,右侧是按球面方向展开的环境纹理。查询变量是方向,图片上的每个位置对应一个入射方向;不同展开中,等面积纹素不一定对应等立体角,积分和采样时需要相应权重。后面还会使用八面体展开及基函数表示。
环境光照的难点可以直接从积分看出:每个可见片元都要收集整个半球的贡献,同时考虑材质响应和可见性。直接查询所有环境纹素通常无法满足帧预算,后续的预过滤、积分近似和光照预计算就是为减少这些查询而设计的。
间接光照
某个方向到达表面的光,还可能是另一块表面反射出来的。因此渲染方程是递归关系,不能只考虑光源与当前表面的连接。
直接光照 Direct Illumination 的路径为光源→表面→相机;间接光照 Indirect Illumination 至少经过额外的非发光表面,例如光源→表面 A→表面 B→相机。全局光照 Global Illumination,GI 包含直接和间接部分,讨论“实时 GI”时,主要困难通常在间接部分。

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

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

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



