GAMES 202 作业代码框架剖析
《GAMES202:高质量实时渲染》是由闫令琪老师开设的计算机图形学进阶课程。相比 GAMES 101,GAMES 202 不仅作业内容的难度更高,其代码框架也更难以理解。由于对代码框架、尤其是 WebGL API 的理解直接影响到完成作业的思路,并且 GAMES 202 代码框架也是使用 WebGL 封装简易图形渲染引擎的一次很好的实践,因此本文将对 GAMES 202 使用了 WebGL 的部分作业代码框架进行剖析,尝试解释代码框架中每一个文件、每一行代码做了什么,从而为更好更深入地理解和完成作业内容打下坚实基础。
WebGL 使用方法回顾
WebGL 是 OpenGL 的一个子集,是一套光栅化 API。浏览器在底层实现了 OpenGL 标准接口并在以 JavaScript 封装的方式提供给开发者使用,开发者首先通过 canvas
元素获取 WebGL 上下文对象,通过操作该对象上的 WebGL 接口并输出到画布上,从而实现各类渲染功能。
要在 WebGL 中实现渲染物体,比如渲染一个三角形,需要向着色器提供以下几类数据:
Attributes
和Buffer
:Buffer 通常是一个类似数组的东西,里面按顺序存储了物体的顶点、法线、纹理坐标、颜色等信息。将一组 Buffer 提供给着色器,通过Attributes
来告诉着色器如何理解这些数据(如多少个数表示第一个顶点,一次渲染中这一组数据在着色器中对应的变量名称是什么等)Uniforms
:可以认为是渲染一帧过程中,着色器程序每次运行过程中都不变的常量,如摄像机的参数、光源的位置等Textures
:纹理数据在渲染过程中的数据不必多说,WebGL 提供Textures
数据类型及其相关方法专门用于传递纹理数据,当然,这也只是一种数据类型而已,开发者也可以向其中存储任意类型的数据,并以自己理解的方式进行读取和使用,前提是使用Textures
数据类型提供的数据操作方法
WebGL 在运行过程中,首先将顶点数据输入顶点着色器(Vertex Shader),以由 Attributes
和 Buffer
提供的顶点数据为单位将顶点坐标变换到裁剪空间,接着根据顶点进行光栅化和插值,以插值得到的每一个结果作为片元着色器(Fragment Shader)的输入,运行该着色器以获得一个像素的值。该过程中渲染所需的其他参数,如变换规则、摄像机的位置、光源位置等,需通过 Uniforms
和 Textures
得到。
更多关于 WebGL 使用方法的内容,请参考 WebGL Fundamentals
将官方代码框架引入现代前端开发流的说明
GAMES202 课程作业大都基于 WebGL 实现。得益于 WebGL 简单易用的特点,这免去了我们手动配置 OpenGL 的麻烦。
然而原代码框架使用传统 Web 前端技术与原生 JavaScript 进行编写,缺乏代码提示与静态检查,使得完成作业、调试 bug 较为困难。
因此笔者针对 GAMES202 官方作业代码框架进行了简单修改,将原代码框架迁移至 TypeScript,使用 Vite 进行打包,并引入 ES Module 模块化,同时实现类型声明与静态检查,期望能提升开发体验。项目链接:GAMES202 Assignment Framework with TypeScript Support
本文针对代码框架的剖析都将基于迁移到 TypeScript 的版本进行。
作业 0 代码框架详解
GAMES 202 作业代码框架体系结构
作业 0 的代码框架目录结构如下:
1 | + |
index.html
:描述网页的布局文件,这里只需要一个充满屏幕的canvas
元素即可,用来获取 WebGL 的上下文public
文件夹:存放各类公共资源,如网页 logo,要渲染的模型等,在 JavaScript 中引入本地静态资源时使用的相对路径可以被认为是以此为根目录的相对路径。assets
:这里一般存放要渲染的模型资产,如要在 JavaScript 中访问marry
文件夹,使用assets/marry
进行访问即可
src
:这是存放各类代码的地方,包括 TypeScript 文件和 GLSL 文件main.ts
:程序的入口,从这里调用来自engine.ts
的GAMES202Main
来初始化 WebGL 配置、加载并渲染模型等engine.ts
:定义了GAMES202Main
函数和摄像机的初始位置,在该函数中,首先从 DOM 树中获取在index.html
定义的canvas
元素,创建一个用于操作 WebGL 的上下文;接着使用THREE.js
创建摄像机对象用于生成可以在 Shader 中使用的各类摄像机参数;然后创建光源数据,和一个自定义的、使用 WebGL 接口封装的 Mesh Renderer,用以渲染从资产文件中加载出来的包含顶点、纹理坐标、法线数据的模型;最后进入消息循环,监听镜头移动并持续刷新渲染场景。renders
:通过传入摄像机数据、光源位置,和定义了顶点、纹理坐标、法线等信息的 Mesh 来向engine.ts
提供两种渲染功能WebGLRenderer.ts
:总的渲染器,通过保存若干光源、Mesh 和摄像机的信息,调用内置 Shader 的 MeshRender 实例的draw
方法、传入摄像机、光源参数,以实现渲染整个场景中渲染所有 Mesh 和光源的功能MeshRenderer.ts
:提供渲染一个 Mesh 的功能,通过保存一个模型的所有顶点、法线、材质、纹理坐标、缓冲等数据,在draw
函数中根据传入的 Transform、摄像机参数等实现在场景中渲染单个 Mesh
objects
:Mesh.ts
:直接存储一个 Mesh 的各类数据,包括所有顶点坐标、顶点对应的法线方向、顶点对应的纹理坐标、每个三角形对应三个顶点的索引
materials
:定义了各种项目所需的 Material,在作业 0 中只有 Blinn-Phong 模型的 Material 及其 ShaderMaterials.ts
:保存了对赋予了该材质的表面进行渲染/着色的过程中所使用的着色器的源码,以及渲染管线中需要从外部传入的Uniforms
数据的名称,将要输入顶点着色器的各类Attributes
在顶点着色器中的变量名等
shaders
:保存了项目所需的各类着色器的源码,以及从 WebGL 封装的着色器类Shader.ts
:通过传入顶点着色器与片元着色器的源码、该源码需要关注的Attributes
与Uniforms
名称列表,编译着色器,并整理出所有需要关注的Attributes
的顶点着色器中的索引和Uniforms
在整个渲染管线中的WebGLUniformLocation
值,以便为Material
提供编译着色器与快速设置Attributes
和Uniforms
值的服务
textures
:Texture.ts
:向OBJ Loader
和Material
模块提供将HTMLImageElement
转换为WebGLTexture
的功能
loads
:几种外部资源加载器loadOBJ.ts
:提供从本地加载模型资产的功能,通过传入WebGLRenderer
和文件路径,借助THREE.js
提供的MTLLoader
来从本地加载模型资产、纹理等数据,自动为MeshRender
创建Mesh
,Material
,Texture
数据并添加到WebGLRenderer
中loadShader.ts
:允许用户从本地加载着色器源码,但因为我们使用 Vite 进行打包,可以在项目编译期直接以字符串形式在 JavaScript 代码中引入着色器源码,故loadShader
功能可弃用
GAMES 202 Engine
这部分主要讲述程序入口处调用的第一个,也是唯一一个函数 GAMES202Main
与包含这个函数的 engine.ts
的功能。
总体功能
先总体说一下 GAMES202Main
的功能:当浏览器打开本项目的页面时,Chrome 自动加载一同随 Web 应用打包的 JavaScript 文件,接着就会调用 GAMES202Main
函数,在调用前,由 index.html
定义的页面所对应的 DOM 树就已经生成。因此在 GAMES202Main
中可以直接获取到页面中定义的 canvas
元素,进而获取对应的 WebGLRenderingContext
以便操作该 canvas
的渲染。
获取完 WebGLRenderingContext
实例后就要准备渲染所需要的一系列参数了,GAMES202Main
首先准备的是摄像机的参数。我们知道,不论是光栅化还是光线追踪渲染,定义摄像机实际上就是在定义摄像机位置、屏幕长宽比、场视角(FOV)、屏幕到摄像机的距离等,这些数据将用于定义光栅化渲染中的 MVP 矩阵或光线追踪中发射光线的范围限制,总之这些数据最终会转换为 GLSL 中某些 Uniforms
参数。GAMES202Main
使用 THREE.js
的 PerspectiveCamera
来构造摄像机实例,PerspectiveCamera
可以仅通过 FOV、长宽比、最近最远距离来定义摄像机,进而计算 MVP 矩阵;此外程序还构造了一个同样由 THREE.js
提供的 OrbitControls
摄像机控制组件实例,可以通过修改该实例来控制摄像机的运动、朝向等,从而无需手动通过修改摄像机参数的方式来处理摄像机的运动。
除了摄像机,GAMES202Main
还会创建一个 WebGLRenderer
对象,如前文所述,该对象是自定义的,可以为其指定 WebGLRenderingContext
和由 THREE.js
创建的摄像机对象,并向其中添加 MeshRender
对象、光源对象等来实现渲染场景中的多个物体和光源。
接着程序创建好场景所需的一个光源、得到一个 PointLight
,再从 loadOBJ
加载要渲染的模型、获得一个 MeshRender
,并将它们都添加到 WebGLRenderer
的渲染列表中。
然后使用 THREE.js
提供的 dat.gui
库来快速向页面上添加一个基于 HTML
元素的 GUI。
最后,GAMES202Main
陷入循环,在每一轮循环首先通过 cameraControls
来更新摄像机参数,再使用 WebGLRenderer
来更新 canvas
元素中的渲染内容。
engine.ts
代码注释
1 | // 导入依赖项 |
Renderer
要想渲染一个场景,首先需要一个对象用来管理整个场景中的所有物体,如摄像机、可渲染的 Mesh、灯光等。在本例中,除了场景本身的表示,我们还需要一种方式来表示场景中可以被渲染的每一个 Mesh。
总体功能
GAMES 202 的代码框架提供了 WebGLRenderer
类来存储和管理整个场景中的摄像机、灯光和 Mesh 等数据,在 engine.ts
的 GAMES202Main
函数中,我们曾通过调用 WebGLRenderer
函数来重新渲染整个场景,其内部实际上是先清空整块画布,接着在有光源的情况下使用两重循环来遍历场景中的所有光源,针对某一个光源再遍历场景中的所有物体,在每个物体渲染前为 Shader 通过 Uniforms
设置光源的位置、强度等信息,再绘制物体,从而实现渲染一个单光源甚至多光源的场景。
单个 Mesh 的表示和渲染方面,代码框架提供了 MeshRender
来保存渲染一个 Mesh 所需要的 Mesh
对象(包含使用 JavaScript 原生数据类型保存的顶点、法线、纹理坐标等数据)、材质、着色器实例,以及提供与 WebGL 交换数据的 WebGLBuffer
的引用。MeshRender
还提供 draw
方法,在其内先向 WebGL 通过设置 Attributes
来传递各类顶点坐标、法线、纹理坐标等数据,接着通过 gl.uniformXXX
系列方法从材质实例和 draw
函数参数中读取并设置 Shader 的各类 Uniforms
渲染参数,最后调用 gl.drawElements
来实现渲染单个物体。
WebGLRenderer.ts
代码注释
1 | // 导入依赖项 |
MeshRender.ts
代码注释
1 | import { mat4 } from "gl-matrix"; |
Mesh
Mesh
是直接存储模型网格体的数据类型。
总体功能
要使用 Mesh
来存储一个网格体,需要传入四个参数:verticesAttrib
,normalsAttrib
,texcoordsAttrib
和 indices
前三个参数的数据类型为 AttribType
,其内的 name
字段表明该 Attribute
在 Shader 中的变量名称是什么,使用 Float32Array
类型的 array
来存储数据本身。
number[]
类型的 indices
则是网格体的顶点索引。网格体中两个三角形的某两个顶点可能是共用的,如果以三角形的三个顶点的顺序直接存储整个网格体,将导致某些顶点的数据重复存储,导致空间上的浪费,因此一般使用顶点索引来解决这个问题,顶点索引序列的每三个元素表示构成一个三角形的三个顶点在顶点、法线、纹理坐标数组中的索引是哪一个,以额外一次访存的代价实现大幅减少顶点存储数量的性能优化。
Mesh.ts
代码注释
1 | // 声明 Attribute 的类型 |
Material
材质 Material
的概念是本项目需要理解的重难点内容。
在光线追踪渲染中,一种物体表面的材质定义了光线到达该表面时反射的方向与起始点,体现但不仅限于 BRDF 定义的双向反射分布情况,纹理则决定了某种颜色的光在经过该表面反射后的新光线是什么颜色的;而在光栅化渲染中,材质定义了为该表面某一个像素进行着色的过程中所需要的各类参数,以及如何使用这些参数和场景中的各类信息来计算该像素颜色的值。
总体功能
GAMES 202 代码框架的 Material
类属于上述光栅化渲染的材质类,其内部的 flatten_uniforms: string[]
变量存储了渲染该材质的着色器所需的所有 Uniforms
变量的名称;flatten_attribs: string[]
和 attribs: string
则存储了所有 Attributes
类型的顶点数据的名称;uniforms
则以 Uniforms
类型变量的名称为 key
,以具体的数据类型和数据本身构成的对象为 value
,存储渲染该材质所需的各类数值、纹理等数据参数。
Material.ts
代码注释
1 | import { Shader } from "../shaders/Shader"; |
Shader
本节是本项目中最接近底层的内容,我们将讲述如何通过传入源码来获得一个由 WebGL 编译好的着色器子程序的,理解这一切的成本仅仅是了解 WebGL 的基本使用方法。
总体功能
GAMES 202 代码框架中 Shader
类型的基本任务是从 Material
端调用 Shader
的构造函数,通过传入 Vertex Shader 和 Fragment Shader 的源码来编译并链接形成完整的着色器程序。
此外,由于在着色器程序之上设置 Attributes
和 Uniforms
值的操作是非常频繁的,而一般要设置它们的值,一般要先从 WebGL 上下文中获取它们的地址,再将值设置到对应的地址上。为了避免反复获取变量地址的过程带来的麻烦,Shader
类还提供 addShaderLocations
的功能,通过传入一组 Attributes
和 Uniforms
的名称,指示 Shader
类将这些变量名称对应的地址(索引、位置)保存到 ProgramInfo
类的 uniforms
和 attribs
字段中,ProgramInfo
还通过 glShaderProgram
字段保存编译好的着色器程序的引用。
Shader.ts
代码注释
1 | // 一组着色器 attributes 和 uniforms 变量名字的记录 |
Texture
纹理是图形渲染过程中所需的重要数据,大部分时候它是每个像素点着色的重要参数,另一些时候它用于保存物体的法线、表明凹凸性等信息
总体功能
在 Web 应用中,存储图片数据最主要的容器是 HTMLImageElement
,WebGL 从 JavaScript 端获取 WebGLTexture
数据同样依赖 HTMLImageElement
。GAMES 202 作业代码框架提供了 Texture
类来通过传入 WebGLRenderingContext
和 HTMLImageElement
来构造 WebGLTexture
、设置一系列参数、拷贝纹理数据。
Texture.ts
代码注释
1 | export class Texture { |
Loader
作为一个渲染器,从外部加载模型、着色器源码资源是必要的功能之一。
总体功能
GAMES 202 代码框架封装了两种加载器,其中一种用于从本地加载 .mtl
和 .obj
格式的模型资源;另一种用于加载着色器源码。由于我们已经使用了 Vite 来在编译器打包外部着色器源码资源,因此后一种加载器的原理将不过多赘述。
对于 loadOBJ
函数,这是一个使用 THREE.js
的 MTLLoader
和 OBJLoader
进行封装的函数。它首先创建了一个 THREE.LoadingManager
来便于我们观察加载的进度;接着创建一个 MTLLoader
用来加载 .mtl
格式的材质库文件,并调用 load
函数、传入加载完成后的回调函数,MTLLoader
在加载完成后调用回调函数、传入加载好的材质(纹理)相关数据;传入 MTLLoader
的回调函数里创建了一个 OBJLoader
用于加载 .obj
模型的顶点、法线、纹理坐标等信息,该加载器同样具有加载完成的回调函数,该回调函数中传入了加载完成的模型实例,包含顶点、纹理等数据,用这些数据创建 Mesh
,Texture
,Material
等数据实例,最终创建出一个 MeshRender
并添加到 WebGLRenderer
中,从而完成了从外部静态资源中加载模型数据并添加到场景中的功能。
loadOBJ.ts
1 | import * as THREE from "three"; |
loadShader.ts
代码注释
1 | import * as THREE from 'three'; |
Lights
光源是代码框架中结构较为简单的类型。逻辑上光源只表示场景空间中的某处存在一个发光的物体,它具有一定的发光强度,可以照亮场景中的其他物体;表现上在渲染出的场景中具有具体的形态,可能是一个灯泡模型,也可能是其他,带有自己的 Mesh 和相应的材质。
总体功能
代码框架在光源部分提供了两个类型:EmissiveMaterial
和 PointLight
。前者定义了一种特殊的材质,用来表现发光实体本身的视觉效果;后者定义了光源的具体数据,包括发光强度,使用什么样的 Mesh 表现它的形状等,同时保存了发光材质的引用。
Light.ts
代码注释
1 | import { Material } from '../materials/Material'; |
PointLight.ts
代码注释
1 | import { Mesh } from '../objects/Mesh'; |