《GAMES202:高质量实时渲染》是由闫令琪老师开设的计算机图形学进阶课程。相比 GAMES 101,GAMES 202 不仅作业内容的难度更高,其代码框架也更难以理解。由于对代码框架、尤其是 WebGL API 的理解直接影响到完成作业的思路,并且 GAMES 202 代码框架也是使用 WebGL 封装简易图形渲染引擎的一次很好的实践,因此本文将对 GAMES 202 使用了 WebGL 的部分作业代码框架进行剖析,尝试解释代码框架中每一个文件、每一行代码做了什么,从而为更好更深入地理解和完成作业内容打下坚实基础。

WebGL 使用方法回顾

WebGL 是 OpenGL 的一个子集,是一套光栅化 API。浏览器在底层实现了 OpenGL 标准接口并在以 JavaScript 封装的方式提供给开发者使用,开发者首先通过 canvas 元素获取 WebGL 上下文对象,通过操作该对象上的 WebGL 接口并输出到画布上,从而实现各类渲染功能。

要在 WebGL 中实现渲染物体,比如渲染一个三角形,需要向着色器提供以下几类数据:

  • AttributesBuffer:Buffer 通常是一个类似数组的东西,里面按顺序存储了物体的顶点、法线、纹理坐标、颜色等信息。将一组 Buffer 提供给着色器,通过 Attributes 来告诉着色器如何理解这些数据(如多少个数表示第一个顶点,一次渲染中这一组数据在着色器中对应的变量名称是什么等)
  • Uniforms:可以认为是渲染一帧过程中,着色器程序每次运行过程中都不变的常量,如摄像机的参数、光源的位置等
  • Textures:纹理数据在渲染过程中的数据不必多说,WebGL 提供 Textures 数据类型及其相关方法专门用于传递纹理数据,当然,这也只是一种数据类型而已,开发者也可以向其中存储任意类型的数据,并以自己理解的方式进行读取和使用,前提是使用 Textures 数据类型提供的数据操作方法

WebGL 在运行过程中,首先将顶点数据输入顶点着色器(Vertex Shader),以由 AttributesBuffer 提供的顶点数据为单位将顶点坐标变换到裁剪空间,接着根据顶点进行光栅化和插值,以插值得到的每一个结果作为片元着色器(Fragment Shader)的输入,运行该着色器以获得一个像素的值。该过程中渲染所需的其他参数,如变换规则、摄像机的位置、光源位置等,需通过 UniformsTextures 得到。

顶点着色器运行过程

更多关于 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
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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
+
| .gitignore
| index.html
| package.json
| tsconfig.json
| vite.config.ts
| yarn.lock
|
+---public
| | vite.svg
| |
| \---assets
| \---mary
| Marry.mtl
| Marry.obj
| MC003_Kozakura_Mari.png
|
\---src
| engine.ts
| main.ts
| style.css
| typescript.svg
| vite-env.d.ts
|
+---lights
| Light.ts
| PointLight.ts
|
+---loads
| loadOBJ.ts
| loadShader.ts
|
+---materials
| Material.ts
|
+---objects
| Mesh.ts
|
+---renderers
| MeshRender.ts
| WebGLRenderer.ts
|
+---shaders
| | Shader.ts
| |
| +---InternalShader
| | FragmentShader.glsl
| | index.ts
| | LightCubeFragmentShader.glsl
| | LightCubeVertexShader.glsl
| | VertexShader.glsl
| |
| +---lightShader
| | fragment.glsl
| | vertex.glsl
| |
| \---phongShader
| fragment.glsl
| vertex.glsl
|
\---textures
Texture.ts
  • index.html:描述网页的布局文件,这里只需要一个充满屏幕的 canvas 元素即可,用来获取 WebGL 的上下文
  • public 文件夹:存放各类公共资源,如网页 logo,要渲染的模型等,在 JavaScript 中引入本地静态资源时使用的相对路径可以被认为是以此为根目录的相对路径。
    • assets:这里一般存放要渲染的模型资产,如要在 JavaScript 中访问 marry 文件夹,使用 assets/marry 进行访问即可
  • src:这是存放各类代码的地方,包括 TypeScript 文件和 GLSL 文件
    • main.ts:程序的入口,从这里调用来自 engine.tsGAMES202Main 来初始化 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 及其 Shader
      • Materials.ts:保存了对赋予了该材质的表面进行渲染/着色的过程中所使用的着色器的源码,以及渲染管线中需要从外部传入的 Uniforms 数据的名称,将要输入顶点着色器的各类 Attributes 在顶点着色器中的变量名等
    • shaders:保存了项目所需的各类着色器的源码,以及从 WebGL 封装的着色器类
      • Shader.ts:通过传入顶点着色器与片元着色器的源码、该源码需要关注的 AttributesUniforms 名称列表,编译着色器,并整理出所有需要关注的 Attributes 的顶点着色器中的索引和 Uniforms 在整个渲染管线中的 WebGLUniformLocation 值,以便为 Material 提供编译着色器与快速设置 AttributesUniforms 值的服务
    • textures
      • Texture.ts:向 OBJ LoaderMaterial 模块提供将 HTMLImageElement 转换为 WebGLTexture 的功能
    • loads:几种外部资源加载器
      • loadOBJ.ts:提供从本地加载模型资产的功能,通过传入 WebGLRenderer 和文件路径,借助 THREE.js 提供的 MTLLoader 来从本地加载模型资产、纹理等数据,自动为 MeshRender 创建 MeshMaterialTexture 数据并添加到 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.jsPerspectiveCamera 来构造摄像机实例,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
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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
// 导入依赖项
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
import { PointLight } from './lights/PointLight';
import * as dat from 'dat.gui';
import { WebGLRenderer } from './renderers/WebGLRenderer';
import { loadOBJ } from './loads/loadOBJ';

// 定义摄像机的初始位置
var cameraPosition = [-20, 180, 250];

export function GAMES202Main() {
// document 对象是 JavaScript 与前端网页中的 HTML 元素进行交互的接口
// 这里是从 DOM 树中获取 id 为 glcanvas 的 HTMLCanvasElement 对象
// querySelector 函数通过传入一个 CSS 选择器 '#glcanvas' 来从 DOM 树中筛选出
// 第一个 id 为 glcanvas 的元素
// <HTMLCanvasElement> 泛型表明期望返回 HTMLCanvasElement | null 类型的对象
const canvas = document.querySelector<HTMLCanvasElement>('#glcanvas');

if(canvas === null) {
// 如果返回了 null,表明不存在 id 为 glcanvas 的 HTMLCanvasElement 元素
console.log("can not find #glcanvas");
return;
}

// 将 canvas 的长宽更新为浏览器视口的长和宽
canvas.width = window.screen.width;
canvas.height = window.screen.height;
// 从 canvas 对象中获取 WebGLRenderingContext,便于后续控制画布渲染
const gl = canvas.getContext('webgl');
if (!gl) {
// 如果没有成功获取,说明浏览器可能不支持 WebGL
alert('Unable to initialize WebGL. Your browser or machine may not support it.');
return;
}

// 使用 THREE.js 创建了一个透视投影摄像机,FOV = 75,长宽比为 canvas 自己的长宽比
// 远点和近点分别为 1000 和 0.1
const camera = new THREE.PerspectiveCamera(75, canvas.clientWidth / canvas.clientHeight, 0.1, 1000);
// 创建一个摄像机控制对象,并将其绑定到先前创建的摄像机上
const cameraControls = new OrbitControls(camera, canvas);
// 设置摄像机的控制参数:
// 允许缩放、旋转、移动
// 旋转、缩放、移动速度分别为 0.3, 1.0, 2.0
cameraControls.enableZoom = true;
cameraControls.enableRotate = true;
cameraControls.enablePan = true;
cameraControls.rotateSpeed = 0.3;
cameraControls.zoomSpeed = 1.0;
cameraControls.keyPanSpeed = 2.0;

// 封装设置摄像机长宽比的功能
function setSize(width: number, height: number) {
// 重新计算长宽比
camera.aspect = width / height;
// 根据新的长宽比重新计算投影变换矩阵
// 即 MVP 矩阵变换中的 P
camera.updateProjectionMatrix();
}

// 用画布的长宽比更新摄像机的长宽比及其投影变换矩阵
setSize(canvas.clientWidth, canvas.clientHeight);

// 监听浏览器视口的 resize 事件,以便在用户缩放视口时摄像机参数仍实时正确更新
window.addEventListener('resize', () => setSize(canvas.clientWidth, canvas.clientHeight));

// 初始化摄像机位置
camera.position.set(cameraPosition[0], cameraPosition[1], cameraPosition[2]);

// 通过 camaeraControls 设置摄像机的朝向位置
cameraControls.target.set(0, 1, 0);

// 创建一个光源,并初始化其亮度、位置
const pointLight = new PointLight(250, [1, 1, 1]);

// 创建一个 WebGLRenderer 渲染器对象,并将 WebGL 渲染上下文和摄像机对象绑定到其上
const renderer = new WebGLRenderer(gl, camera);
// 向渲染器中添加光源
renderer.addLight(pointLight);
// 从本地的 assets/mary 目录中加载名为 Marry 的资源模型,并添加到渲染器对象中
loadOBJ(renderer, 'assets/mary/', 'Marry');


// 以下涉及 dat.GUI 的基本使用
// 简单来说,这里先定义了需要使用 dat.GUI 控制的数据 guiParams
// 使用 dat.GUI 创建一个 GUI 的实例后,使用 addFolder 来添加数据面板
// 每个面板可以包含若干用来调整数据的 UI
// 调用面板的 add 方法可以通过传入要操控的数据对象引用、操控的对象名称、
// 和在面板中显示的对象名字,来实现将对象的某一字段绑定到面板中

// 定义需要使用 dat.GUI 控制的数据:模型的 translation 和 scale
// 分别初始化为 (0, 0, 0) 和 (52, 52, 52)
var guiParams = {
modelTransX: 0,
modelTransY: 0,
modelTransZ: 0,
modelScaleX: 52,
modelScaleY: 52,
modelScaleZ: 52,
}
function createGUI() {
// 创建一个 dat.GUI 实例
const gui = new dat.GUI();
// 创建一个总的面板,名为 panelModel
const panelModel = gui.addFolder('Model properties');
// 在 panelModel 下创建两个子面板,分别名为 Translation 和 Scale
const panelModelTrans = panelModel.addFolder('Translation');
const panelModelScale = panelModel.addFolder('Scale');
// 在 Translation 面板下添加一个参数输入框与控制杆,
// 表示将这个控件的值与 guiParams 中名为 modelTransX 的字段绑定起来
// 每次在 UI 中更新它的值,都将更新 guiParams 的值
// 并在面板中将这个值的名称显示为 X
panelModelTrans.add(guiParams, 'modelTransX').name('X');
// 其他 add 语句的含义与上述相同
panelModelTrans.add(guiParams, 'modelTransY').name('Y');
panelModelTrans.add(guiParams, 'modelTransZ').name('Z');
panelModelScale.add(guiParams, 'modelScaleX').name('X');
panelModelScale.add(guiParams, 'modelScaleY').name('Y');
panelModelScale.add(guiParams, 'modelScaleZ').name('Z');
// 依次展开 panelModel,panelModelTrans 和 panelModelScale 面板
panelModel.open();
panelModelTrans.open();
panelModelScale.open();
}

// 创建 dat.GUI 实例并将 guiParams 绑定到 GUI 上
createGUI();

// requestAnimationFrame 是由浏览器提供的动画 API,可以认为传入的函数将在下一次浏览器更新动画时被执行
// 传入的 mainLoop 是用于在每一帧进行渲染的主循环函数
function mainLoop(now: number) {
// cameraControls 实际上提供了使用鼠标、滚轮更新其参数的功能
// 因此每一帧其中的数据都可能更新
// 需要调用 update 来更新 cameraControls 自身的某些参数
// 和被其控制的摄像机的参数
cameraControls.update();

// 使用新的(可能被用户通过 dat.GUI 更新的)guiParams 来对场景和模型进行重新渲染
renderer.render(guiParams);

// 在主循环结尾再次将自身传入 requestAnimationFrame 可以实现浏览器每帧动画更新时都重新渲染 canvas 的内容
requestAnimationFrame(mainLoop);
}
// 在引擎各参数初始化完成后手动调用第一次 requestAnimationFrame 来进入主渲染循环
requestAnimationFrame(mainLoop);
}

Renderer

要想渲染一个场景,首先需要一个对象用来管理整个场景中的所有物体,如摄像机、可渲染的 Mesh、灯光等。在本例中,除了场景本身的表示,我们还需要一种方式来表示场景中可以被渲染的每一个 Mesh。

总体功能

GAMES 202 的代码框架提供了 WebGLRenderer 类来存储和管理整个场景中的摄像机、灯光和 Mesh 等数据,在 engine.tsGAMES202Main 函数中,我们曾通过调用 WebGLRenderer 函数来重新渲染整个场景,其内部实际上是先清空整块画布,接着在有光源的情况下使用两重循环来遍历场景中的所有光源,针对某一个光源再遍历场景中的所有物体,在每个物体渲染前为 Shader 通过 Uniforms 设置光源的位置、强度等信息,再绘制物体,从而实现渲染一个单光源甚至多光源的场景。

单个 Mesh 的表示和渲染方面,代码框架提供了 MeshRender 来保存渲染一个 Mesh 所需要的 Mesh 对象(包含使用 JavaScript 原生数据类型保存的顶点、法线、纹理坐标等数据)、材质、着色器实例,以及提供与 WebGL 交换数据的 WebGLBuffer 的引用。MeshRender 还提供 draw 方法,在其内先向 WebGL 通过设置 Attributes 来传递各类顶点坐标、法线、纹理坐标等数据,接着通过 gl.uniformXXX 系列方法从材质实例和 draw 函数参数中读取并设置 Shader 的各类 Uniforms 渲染参数,最后调用 gl.drawElements 来实现渲染单个物体。

WebGLRenderer.ts 代码注释

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
// 导入依赖项
import { MeshRender } from "./MeshRender";
import * as THREE from "three";
import { PointLight } from "../lights/PointLight";
import { vec3 } from "gl-matrix";

// 保存一组 Transform 参数,包含位移和缩放信息,但保持物体旋转不变
// Remain rotatation
export class TRSTransform {
// 位移数据
translate: vec3;
// 缩放数据
scale: vec3;

constructor(translate: vec3 = [0, 0, 0], scale: vec3 = [1, 1, 1]) {
this.translate = translate;
this.scale = scale;
}
}

export class WebGLRenderer {
// 保存场景中所有的 MeshRender
meshes: MeshRender[] = [];
// 保存场景中所有的光源
// 包含光源本身的数据,以及光源模型自己的 MeshRender
lights: {
entity: PointLight;
meshRender: MeshRender;
}[] = [];

// 保存目标画布的 WebGL 渲染上下文
gl: WebGLRenderingContext;
// 保存先前创建的透视投影摄像机的引用
camera: THREE.PerspectiveCamera;

// 构造函数,需要传入 WebGL 渲染上下文和相应的透视投影摄像机
constructor(gl: WebGLRenderingContext, camera: THREE.PerspectiveCamera) {
this.gl = gl;
this.camera = camera;
}

// 添加一组点光源,除了保存点光源外,并使用光源对应的 Mesh 和材质创建一个光源对应的 MeshRender
addLight(light: PointLight) {
this.lights.push({
entity: light,
meshRender: new MeshRender(this.gl, light.mesh, light.mat),
});
}

// 为场景添加一个 MeshRender
addMesh(mesh: MeshRender) {
this.meshes.push(mesh);
}

// 以 guiParams 包含的位移、缩放参数来重新渲染整个场景
render(guiParams: {
modelTransX: number;
modelTransY: number;
modelTransZ: number;
modelScaleX: number;
modelScaleY: number;
modelScaleZ: number;
}) {
const gl = this.gl;

// 将画布清空,像素充值为不透明黑色,同时重置 Z 深度,并重新启用深度测试
gl.clearColor(0.0, 0.0, 0.0, 1.0); // Clear to black, fully opaque
gl.clearDepth(1.0); // Clear everything
gl.enable(gl.DEPTH_TEST); // Enable depth testing
gl.depthFunc(gl.LEQUAL); // Near things obscure far things

gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

// 让场景中的光源以一种特定的周期性空间曲线路径移动
// Handle light
const timer = Date.now() * 0.00025;
let lightPos: vec3 = [
Math.sin(timer * 6) * 100,
Math.cos(timer * 4) * 150,
Math.cos(timer * 2) * 100,
];

if (this.lights.length != 0) {
for (let l = 0; l < this.lights.length; l++) {
// 遍历场景中每一个光源,用当前的新的 lightPos 更新并渲染其 Mesh
let trans = new TRSTransform(lightPos);
this.lights[l].meshRender.draw(this.camera, trans);

for (let i = 0; i < this.meshes.length; i++) {
const mesh = this.meshes[i];

// 对场景中每一个 MeshRender
// 先使用 guiParams 设置其位移和缩放
const modelTranslation: vec3 = [
guiParams.modelTransX,
guiParams.modelTransY,
guiParams.modelTransZ,
];
const modelScale: vec3 = [
guiParams.modelScaleX,
guiParams.modelScaleY,
guiParams.modelScaleZ,
];
let meshTrans = new TRSTransform(
modelTranslation,
modelScale
);

// 应用当前 MeshRender 的着色器程序
this.gl.useProgram(mesh.shader.program.glShaderProgram);
// 使用当前光源位置设置着色器的参数
// 着色器中关于光源参数的 uLightPos: WebGLUniformLocation 是在着色器初始化时赋值的
// 这里可以认为是将着色器程序中第 uLightPos 号 uniforms 参数的值设置为 lightPos
this.gl.uniform3fv(
mesh.shader.program.uniforms.uLightPos,
lightPos
);
// 传入摄像机和对应的 trans 来渲染该 Mesh
mesh.draw(this.camera, meshTrans);
}
}
} else {
// 如果没有任何光源,则跳过灯光渲染,直接渲染 Mesh
// Handle mesh(no light)
for (let i = 0; i < this.meshes.length; i++) {
const mesh = this.meshes[i];
let trans = new TRSTransform();
mesh.draw(this.camera, trans);
}
}
}
}

MeshRender.ts 代码注释

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
import { mat4 } from "gl-matrix";
import { Mesh } from "../objects/Mesh";
import { Material } from "../materials/Material";
import { Shader } from "../shaders/Shader";
import * as THREE from "three";
import { TRSTransform } from "./WebGLRenderer";

export class MeshRender {
// 渲染一个 Mesh 所需要的几组向 WebGL 传递数据的 WebGLBuffer
// 这些 WebGLBuffer 都将通过 Attributes 来将 JavaScript 端的数据
// 传递给 WebGL
private vertexBuffer;
private normalBuffer;
private texcoordBuffer;
private indicesBuffer;

// 保存 WebGL 渲染上下文
gl: WebGLRenderingContext;
// 保存 Mesh 本身的顶点坐标、法线、纹理坐标等数据的对象
mesh: Mesh;
// 保存要渲染 Mesh 所需要的各类参数,包括各种 Uniforms 和 Textures
material: Material;
// 保存渲染一个 Mesh 所使用的着色器程序,
// 在 MeshRender 初始化时进行编译
shader: Shader;

constructor(gl: WebGLRenderingContext, mesh: Mesh, material: Material) {
// 从传入的参数初始化 gl, mesh, material 等数据
this.gl = gl;
this.mesh = mesh;
this.material = material;

// 通过 gl 对象为每一种 buffer 创建实例
this.vertexBuffer = gl.createBuffer();
this.normalBuffer = gl.createBuffer();
this.texcoordBuffer = gl.createBuffer();
this.indicesBuffer = gl.createBuffer();

let extraAttribs = [];
if (mesh.hasVertices) {
// 如果 mesh 包含顶点坐标信息
// 保存该顶点坐标序列在着色器中对应的 attribute 名字
extraAttribs.push(mesh.verticesName);
// 将 mesh 存储在 JavaScript 端的顶点数据拷贝到 WebGL 端的 vertexBuffer
gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer);
gl.bufferData(gl.ARRAY_BUFFER, mesh.vertices, gl.STATIC_DRAW);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
}

if (mesh.hasNormals) {
// 如果 mesh 包含法线信息
// 保存该法线序列在着色器中对应的 attribute 名字
extraAttribs.push(mesh.normalsName);
// 将 mesh 存储在 JavaScript 端的法线数据拷贝到 WebGL 端的 normalBuffer
gl.bindBuffer(gl.ARRAY_BUFFER, this.normalBuffer);
gl.bufferData(gl.ARRAY_BUFFER, mesh.normals, gl.STATIC_DRAW);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
}

if (mesh.hasTexcoords) {
// 如果 mesh 包含纹理坐标信息
// 保存该纹理坐标序列在着色器中对应的 attribute 名字
extraAttribs.push(mesh.texcoordsName);
// 将 mesh 存储在 JavaScript 端的纹理坐标数据拷贝到 WebGL 端的 texcoordBuffer
gl.bindBuffer(gl.ARRAY_BUFFER, this.texcoordBuffer);
gl.bufferData(gl.ARRAY_BUFFER, mesh.texcoords, gl.STATIC_DRAW);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
}

// mesh 默认需要有顶点索引,用来描述每个三角形由顶点坐标序列中的哪几个顶点构成
// 这里将 mesh 包含的顶点索引数据拷贝到 indicesBuffer
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.indicesBuffer);
gl.bufferData(
gl.ELEMENT_ARRAY_BUFFER,
new Uint16Array(mesh.indices),
gl.STATIC_DRAW
);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);

// 将新收集到的 attribute 数据名字添加到材质对象的 attribute 列表中
this.material.setMeshAttribs(extraAttribs);
// 重新从 material 对象中编译着色器
this.shader = this.material.compile(gl);
}

draw(camera: THREE.PerspectiveCamera, transform: TRSTransform) {
const gl = this.gl;

// 创建 MVP 变换矩阵,
// 其中模型变换和视口变换直接综合到一个 modelViewMatrix 矩阵中
let modelViewMatrix = mat4.create();
let projectionMatrix = mat4.create();

camera.updateMatrixWorld();

// 坐标变换算法推导:
// X 表示顶点原坐标,X‘ 表示变换空间的坐标
// P 表示投影变换矩阵,V 表示视口变换矩阵,M 表示模型变换矩阵
// T 表示位移变换矩阵,S 表示缩放变换矩阵
// M = S(T) = S T
// X' = P(V(M(X))) = P V M X
// 由矩阵满足交换律,上式可变换为:X' = P (V S T) X
// projectionMatrix = P
// modelViewMatrix = V S T

// 对摄像机世界坐标求逆即可得到视口变换矩阵 V
mat4.invert(
modelViewMatrix,
new Float32Array(camera.matrixWorld.elements)
);
// modelViewMatrix = V S T
mat4.translate(modelViewMatrix, modelViewMatrix, transform.translate);
mat4.scale(modelViewMatrix, modelViewMatrix, transform.scale);
// projectionMatrix = P
mat4.copy(
projectionMatrix,
new Float32Array(camera.projectionMatrix.elements)
);

if (this.mesh.hasVertices) {
const numComponents = 3;
const type = gl.FLOAT;
const normalize = false;
const stride = 0;
const offset = 0;
// 指示 WebGL 我们系统从 vertexBuffer 中提供 ARRAY_BUFFER 类型数据
gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer);
// 将 vertexBuffer 绑定到顶点坐标在 Shader 中对应的地址
// 地址/位置由 shader.program.attribs 提供,在编译 shader 时确定
gl.vertexAttribPointer(
this.shader.program.attribs[this.mesh.verticesName],
numComponents,
type,
normalize,
stride,
offset
);
// 启用 vertexBuffer 对应地址的数据
gl.enableVertexAttribArray(
this.shader.program.attribs[this.mesh.verticesName]
);
}

if (this.mesh.hasNormals) {
const numComponents = 3;
const type = gl.FLOAT;
const normalize = false;
const stride = 0;
const offset = 0;
// 指示 WebGL 我们系统从 normalBuffer 中提供 ARRAY_BUFFER 类型数据
gl.bindBuffer(gl.ARRAY_BUFFER, this.normalBuffer);
// 将 normalBuffer 绑定到顶点坐标在 Shader 中对应的地址
gl.vertexAttribPointer(
this.shader.program.attribs[this.mesh.normalsName],
numComponents,
type,
normalize,
stride,
offset
);
// 启用 normalBuffer 对应地址的数据
gl.enableVertexAttribArray(
this.shader.program.attribs[this.mesh.normalsName]
);
}

if (this.mesh.hasTexcoords) {
const numComponents = 2;
const type = gl.FLOAT;
const normalize = false;
const stride = 0;
const offset = 0;
// 指示 WebGL 我们系统从 texcoordBuffer 中提供 ARRAY_BUFFER 类型数据
gl.bindBuffer(gl.ARRAY_BUFFER, this.texcoordBuffer);
// 将 texcoordBuffer 绑定到顶点坐标在 Shader 中对应的地址
gl.vertexAttribPointer(
this.shader.program.attribs[this.mesh.texcoordsName],
numComponents,
type,
normalize,
stride,
offset
);
// 启用 texcoordBuffer 对应地址的数据
gl.enableVertexAttribArray(
this.shader.program.attribs[this.mesh.texcoordsName]
);
}
// 指示 WebGL 我们系统从 indicesBuffer 中提供 ARRAY_BUFFER 类型数据
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.indicesBuffer);
// 将 indicesBuffer 绑定到顶点坐标在 Shader 中对应的地址
gl.useProgram(this.shader.program.glShaderProgram);

// 向 Shader 中对应地址设置投影变换矩阵
// 视口变换数据在 Shader 中的地址,即 WebGLUniformLocation,
// 同样由着色器编译时确定
gl.uniformMatrix4fv(
this.shader.program.uniforms.uProjectionMatrix,
false,
projectionMatrix
);
// 向 Shader 中对应地址设置模型-视口变换矩阵
gl.uniformMatrix4fv(
this.shader.program.uniforms.uModelViewMatrix,
false,
modelViewMatrix
);

// 向 Shader 中对应地址设置摄像机坐标
// Specific the camera uniforms
gl.uniform3fv(this.shader.program.uniforms.uCameraPos, [
camera.position.x,
camera.position.y,
camera.position.z,
]);

for (let k in this.material.uniforms) {
// 遍历材质中包含的其余 uniforms 参数,判断其类型,
// 并向 Shader 对应位置设置 Uniforms 参数的值
// 本代码框架支持设置 1 维整数,1 维浮点数,3 维浮点数,4 维浮点数
// 以及材质数据
const uniform = this.material.uniforms[k];
if (uniform.type == "matrix4fv") {
gl.uniformMatrix4fv(
this.shader.program.uniforms[k],
false,
uniform.value
);
} else if (uniform.type == "3fv") {
gl.uniform3fv(this.shader.program.uniforms[k], uniform.value);
} else if (uniform.type == "1f") {
gl.uniform1f(this.shader.program.uniforms[k], uniform.value);
} else if (uniform.type == "1i") {
gl.uniform1i(this.shader.program.uniforms[k], uniform.value);
} else if (uniform.type == "texture") {
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, uniform.value.texture);
gl.uniform1i(this.shader.program.uniforms[k], 0);
}
}

{
// 所有参数设置完成后,以 TRIANGLES 使用顶点索引渲染模型
const vertexCount = this.mesh.count;
const type = gl.UNSIGNED_SHORT;
const offset = 0;
gl.drawElements(gl.TRIANGLES, vertexCount, type, offset);
}
}
}

Mesh

Mesh 是直接存储模型网格体的数据类型。

总体功能

要使用 Mesh 来存储一个网格体,需要传入四个参数:verticesAttribnormalsAttribtexcoordsAttribindices 前三个参数的数据类型为 AttribType,其内的 name 字段表明该 Attribute 在 Shader 中的变量名称是什么,使用 Float32Array 类型的 array 来存储数据本身。

number[] 类型的 indices 则是网格体的顶点索引。网格体中两个三角形的某两个顶点可能是共用的,如果以三角形的三个顶点的顺序直接存储整个网格体,将导致某些顶点的数据重复存储,导致空间上的浪费,因此一般使用顶点索引来解决这个问题,顶点索引序列的每三个元素表示构成一个三角形的三个顶点在顶点、法线、纹理坐标数组中的索引是哪一个,以额外一次访存的代价实现大幅减少顶点存储数量的性能优化。

Mesh.ts 代码注释

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
// 声明 Attribute 的类型
interface AttribType {
// 表明该 Attribute 在 Shader 中的变量名
name: string;
// 将要作为 Attribute 的数据本身
array: Float32Array;
}

export class Mesh {
// 顶点索引
indices: number[];
// 顶点索引的长度
count: number;
// 是否包含顶点坐标
hasVertices: boolean;
// 是否包含法线
hasNormals: boolean;
// 是否包含纹理坐标
hasTexcoords: boolean;

// 顶点坐标数据序列
vertices: Float32Array = new Float32Array();
// 顶点坐标数据在 Shader 中的变量名
verticesName: string = "";

// 法线数据序列
normals: Float32Array = new Float32Array();
// 法线数据在 Shader 中的变量名
normalsName: string = "";

// 纹理坐标数据序列
texcoords: Float32Array = new Float32Array();
// 纹理坐标数据在 Shader 中的变量名
texcoordsName: string = "";

constructor(
verticesAttrib: AttribType,
normalsAttrib: AttribType | null,
texcoordsAttrib: AttribType | null,
indices: number[]
) {
// 初始化顶点索引数据
this.indices = indices;
this.count = indices.length;
// 默认没有顶点坐标、法线、纹理坐标数据
this.hasVertices = false;
this.hasNormals = false;
this.hasTexcoords = false;
let extraAttribs = [];

if (verticesAttrib != null) {
// 如果有顶点坐标数据,则赋值
this.hasVertices = true;
this.vertices = verticesAttrib.array;
this.verticesName = verticesAttrib.name;
}
if (normalsAttrib != null) {
// 如果有法线数据,则赋值
this.hasNormals = true;
this.normals = normalsAttrib.array;
this.normalsName = normalsAttrib.name;
}
if (texcoordsAttrib != null) {
// 如果有纹理坐标数据,则赋值
this.hasTexcoords = true;
this.texcoords = texcoordsAttrib.array;
this.texcoordsName = texcoordsAttrib.name;
}
}

static cube() {
// 预先定义一个边长为 1 的立方体 Mesh,方便后续使用
const positions = [
// Front face
-1.0, -1.0, 1.0, 1.0, -1.0, 1.0, 1.0, 1.0, 1.0, -1.0, 1.0, 1.0,
// Back face
-1.0, -1.0, -1.0, -1.0, 1.0, -1.0, 1.0, 1.0, -1.0, 1.0, -1.0, -1.0,
// Top face
-1.0, 1.0, -1.0, -1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, -1.0,
// Bottom face
-1.0, -1.0, -1.0, 1.0, -1.0, -1.0, 1.0, -1.0, 1.0, -1.0, -1.0, 1.0,
// Right face
1.0, -1.0, -1.0, 1.0, 1.0, -1.0, 1.0, 1.0, 1.0, 1.0, -1.0, 1.0,
// Left face
-1.0, -1.0, -1.0, -1.0, -1.0, 1.0, -1.0, 1.0, 1.0, -1.0, 1.0, -1.0,
];
const indices = [
0, 1, 2, 0, 2, 3, // front
4, 5, 6, 4, 6, 7, // back
8, 9, 10, 8, 10, 11, // top
12, 13, 14, 12, 14, 15, // bottom
16, 17, 18, 16, 18, 19, // right
20, 21, 22, 20, 22, 23, // left
];
return new Mesh(
{ name: "aVertexPosition", array: new Float32Array(positions) },
null,
null,
indices
);
}
}

Material

材质 Material 的概念是本项目需要理解的重难点内容。

在光线追踪渲染中,一种物体表面的材质定义了光线到达该表面时反射的方向与起始点,体现但不仅限于 BRDF 定义的双向反射分布情况,纹理则决定了某种颜色的光在经过该表面反射后的新光线是什么颜色的;而在光栅化渲染中,材质定义了为该表面某一个像素进行着色的过程中所需要的各类参数,以及如何使用这些参数和场景中的各类信息来计算该像素颜色的值。

总体功能

GAMES 202 代码框架的 Material 类属于上述光栅化渲染的材质类,其内部的 flatten_uniforms: string[] 变量存储了渲染该材质的着色器所需的所有 Uniforms 变量的名称;flatten_attribs: string[]attribs: string 则存储了所有 Attributes 类型的顶点数据的名称;uniforms 则以 Uniforms 类型变量的名称为 key,以具体的数据类型和数据本身构成的对象为 value,存储渲染该材质所需的各类数值、纹理等数据参数。

Material.ts 代码注释

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
import { Shader } from "../shaders/Shader";
import { vec3, mat4 } from 'gl-matrix';
import { Texture } from "../textures/Texture";

// 定义 Uniforms 的复合类型,通过 type 来判断 uniform 的类型,并给出正确类型的值
type Uniform =
| {
type: "texture";
value: Texture;
}
| {
type: "1i";
value: number;
}
| {
type: "1f";
value: number;
}
| {
type: "3fv";
value: vec3;
}
| {
type: "uKd";
value: number[];
}
| {
type: "matrix4fv";
value: mat4;
};
export class Material {
// 保存所有 uniform 变量的名称
private flatten_uniforms;
// 保存所有 attribute 变量的名称
private flatten_attribs;
// vertex shader 的源码
private vsSrc;
// fragment shader 的源码
private fsSrc;

// uniform 变量与值的对应关系
uniforms;
// 保存所有 attribute 变量的名称
attribs;

// Uniforms is a map, attribs is a Array
constructor(
uniforms: {
[index: string]: Uniform;
},
attribs: string[],
vsSrc: string,
fsSrc: string
) {
// 初始化 uniforms, attribs 与着色器源码
this.uniforms = uniforms;
this.attribs = attribs;
this.vsSrc = vsSrc;
this.fsSrc = fsSrc;

// 所有材质着色器都默认具有
// 位移-视口变换矩阵,投影变换矩阵
// 摄像机位置,光源位置参数
this.flatten_uniforms = [
"uModelViewMatrix",
"uProjectionMatrix",
"uCameraPos",
"uLightPos",
];
// 将其他 uniforms 的参数名添加到 flatten_uniforms 中
for (let k in uniforms) {
this.flatten_uniforms.push(k);
}
// 保持 flatten_attribs 和 attribs 一致
this.flatten_attribs = attribs;
}

setMeshAttribs(extraAttribs: string[]) {
// 添加额外的 Attribute 变量名
for (let i = 0; i < extraAttribs.length; i++) {
this.flatten_attribs.push(extraAttribs[i]);
}
}

compile(gl: WebGLRenderingContext) {
// 从源码编译 Shader,返回着色器程序和各类 Attribute,Uniform 变量名与地址的对应关系
return new Shader(gl, this.vsSrc, this.fsSrc, {
uniforms: this.flatten_uniforms,
attribs: this.flatten_attribs,
});
}
}

Shader

本节是本项目中最接近底层的内容,我们将讲述如何通过传入源码来获得一个由 WebGL 编译好的着色器子程序的,理解这一切的成本仅仅是了解 WebGL 的基本使用方法。

总体功能

GAMES 202 代码框架中 Shader 类型的基本任务是从 Material 端调用 Shader 的构造函数,通过传入 Vertex Shader 和 Fragment Shader 的源码来编译并链接形成完整的着色器程序。

此外,由于在着色器程序之上设置 AttributesUniforms 值的操作是非常频繁的,而一般要设置它们的值,一般要先从 WebGL 上下文中获取它们的地址,再将值设置到对应的地址上。为了避免反复获取变量地址的过程带来的麻烦,Shader 类还提供 addShaderLocations 的功能,通过传入一组 AttributesUniforms 的名称,指示 Shader 类将这些变量名称对应的地址(索引、位置)保存到 ProgramInfo 类的 uniformsattribs 字段中,ProgramInfo 还通过 glShaderProgram 字段保存编译好的着色器程序的引用。

Shader.ts 代码注释

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
// 一组着色器 attributes 和 uniforms 变量名字的记录
// 用于指示从编译好的着色器中查找对变量对应的地址
interface ShaderLocations {
uniforms: string[] | null;
attribs: string[] | null;
}

// 存储着色器信息的一组变量
interface ProgramInfo {
// 编译好的着色器程序的引用
glShaderProgram: WebGLProgram;
// key 表示着色器程序中某个 uniform 变量的名字
// value 表示该名字对应的地址(位置)
uniforms: { [index: string]: WebGLUniformLocation };
// key 表示着色器程序中某个 attribute 变量的名字
// value 表示该名字对应的索引
attribs: { [index: string]: number };
}

export class Shader {
gl: WebGLRenderingContext;
program: ProgramInfo;

constructor(
gl: WebGLRenderingContext,
vsSrc: string,
fsSrc: string,
shaderLocations: ShaderLocations
) {
this.gl = gl;
// 从传入的源码中分别编译顶点着色器和片元着色器
const vs = this.compileShader(vsSrc, gl.VERTEX_SHADER);
const fs = this.compileShader(fsSrc, gl.FRAGMENT_SHADER);

// 将编译好的着色器进行链接操作,
// 并根据 shaderLocations 中的变量名查找出这些变量名在着色器程序中的位置
this.program = this.addShaderLocations(
{
glShaderProgram: this.linkShader(vs, fs),
uniforms: {},
attribs: {},
},
shaderLocations
);
}

// 通过提供的源码与类型编译着色器
compileShader(shaderSource: string, shaderType: number) {
const gl = this.gl;
// 创建一个 WebGL 着色器实例
var shader = gl.createShader(shaderType);

if (shader === null) throw "shader creator error";

// 设置着色器源码与类型
gl.shaderSource(shader, shaderSource);
// 编译着色器
gl.compileShader(shader);

if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
// 编译出错时打印错误信息
console.error(shaderSource);
console.error(
"shader compiler error:\n" + gl.getShaderInfoLog(shader)
);
}

return shader;
}

// 通过提供的顶点和片元着色器来链接并返回一个完整的着色器程序
linkShader(vs: WebGLShader, fs: WebGLShader) {
const gl = this.gl;
// 创建一个 WebGL 着色器程序实例
var prog = gl.createProgram();

if (!prog) {
throw "shader creator error";
}

// 添加顶点着色器
gl.attachShader(prog, vs);
// 添加片元着色器
gl.attachShader(prog, fs);
// 链接两个着色器
gl.linkProgram(prog);

if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
// 链接出错时打印错误信息
throw `shader linker error:\n ${gl.getProgramInfoLog(prog)}`;
}
return prog;
}

addShaderLocations(
result: ProgramInfo,
shaderLocations: ShaderLocations | null
) {
const gl = this.gl;
// 初始化存储 uniform, attribute 名称与地址的对象的索引
result.uniforms = {};
result.attribs = {};

if (
shaderLocations &&
shaderLocations.uniforms &&
shaderLocations.uniforms.length
) {
for (let i = 0; i < shaderLocations.uniforms.length; ++i) {
// 遍历 shaderLocations 中所有的 uniform,
// 以其名称为 key,对应的地址(位置)为 value,存储这两组变量的对应关系
result.uniforms = Object.assign(result.uniforms, {
[shaderLocations.uniforms[i]]: gl.getUniformLocation(
result.glShaderProgram,
shaderLocations.uniforms[i]
),
});
//console.log(gl.getUniformLocation(result.glShaderProgram, 'uKd'));
}
}
if (
shaderLocations &&
shaderLocations.attribs &&
shaderLocations.attribs.length
) {
for (let i = 0; i < shaderLocations.attribs.length; ++i) {
// 遍历 shaderLocations 中所有的 attribute
// 以其名称为 key,对应的索引为 value,存储这两组变量的对应关系
result.attribs = Object.assign(result.attribs, {
[shaderLocations.attribs[i]]: gl.getAttribLocation(
result.glShaderProgram,
shaderLocations.attribs[i]
),
});
}
}

return result;
}
}

Texture

纹理是图形渲染过程中所需的重要数据,大部分时候它是每个像素点着色的重要参数,另一些时候它用于保存物体的法线、表明凹凸性等信息

总体功能

在 Web 应用中,存储图片数据最主要的容器是 HTMLImageElement,WebGL 从 JavaScript 端获取 WebGLTexture 数据同样依赖 HTMLImageElement。GAMES 202 作业代码框架提供了 Texture 类来通过传入 WebGLRenderingContextHTMLImageElement 来构造 WebGLTexture、设置一系列参数、拷贝纹理数据。

Texture.ts 代码注释

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
export class Texture {
// 存储 WebGL 可直接访问的图片纹理数据
texture: WebGLTexture;

constructor(gl: WebGLRenderingContext, img: HTMLImageElement) {
// 创建 WebGLTexture 实例
const texture = gl.createTexture();
if (texture === null) {
throw "failed to create texture";
}
this.texture = texture;
// 将创建出的 WebGLTexture 实例与 gl.TEXTURE_2D 绑定
// 之后用 gl.TEXTURE_2D 这个枚举来操作纹理
// 都相当于操作当前绑定的 WebGLTexture 实例
gl.bindTexture(gl.TEXTURE_2D, this.texture);

// 为当前绑定的 WebGLTexture 设置一系列参数
// Because images have to be download over the internet
// they might take a moment until they are ready.
// Until then put a single pixel in the texture so we can
// use it immediately. When the image has finished downloading
// we'll update the texture with the contents of the image.
const level = 0; // LOD 级别为 0,即不生成 mipmap
const internalFormat = gl.RGBA; // 图片是 RGBA 图像
const width = 1; // 宽设为 1
const height = 1; // 高设为 1
const border = 0; // 边界必须设为 0
const srcFormat = gl.RGBA; // 像素格式为 RGBA
const srcType = gl.UNSIGNED_BYTE; // 每个像素的数据类型是 unsigned byte
const pixel = new Uint8Array([0, 0, 255, 255]); // opaque blue
gl.texImage2D(
gl.TEXTURE_2D,
level,
internalFormat,
width,
height,
border,
srcFormat,
srcType,
pixel
);

gl.bindTexture(gl.TEXTURE_2D, this.texture);
// 将图像做上下翻转预处理
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
// 将 HTMLImageElement 图像数据拷贝到 WebGLTexture 纹理
gl.texImage2D(
gl.TEXTURE_2D,
level,
internalFormat,
srcFormat,
srcType,
img
);

// 如果图像的长宽都是 2 的幂
// WebGL1 has different requirements for power of 2 images
// vs non power of 2 images so check if the image is a
// power of 2 in both dimensions.
if (isPowerOf2(img.width) && isPowerOf2(img.height)) {
// 则为其生成 mipmap
// Yes, it's a power of 2. Generate mips.
gl.generateMipmap(gl.TEXTURE_2D);
} else {
// 否则将其水平和垂直方向设置为重复纹理
// 图像缩放过程使用线性插值
// 从而实现绘制非 2 次幂边长的图像
// No, it's not a power of 2. Turn of mips and set
// wrapping to clamp to edge
//gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
//gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
}
gl.bindTexture(gl.TEXTURE_2D, null);
}
}

function isPowerOf2(value: number) {
// 要判断一个数是不是 2 的幂次
// 相当于判断它的二进制表示下,是否除了最高位是 1 其他位都是 0,
// 可以用如下规律来判断:
// 一个数 x 是 2 的幂次,当且仅当 x - 1 除了最高位是 0,其他低位都是 1
return (value & (value - 1)) == 0;
}

Loader

作为一个渲染器,从外部加载模型、着色器源码资源是必要的功能之一。

总体功能

GAMES 202 代码框架封装了两种加载器,其中一种用于从本地加载 .mtl.obj 格式的模型资源;另一种用于加载着色器源码。由于我们已经使用了 Vite 来在编译器打包外部着色器源码资源,因此后一种加载器的原理将不过多赘述。

对于 loadOBJ 函数,这是一个使用 THREE.jsMTLLoaderOBJLoader 进行封装的函数。它首先创建了一个 THREE.LoadingManager 来便于我们观察加载的进度;接着创建一个 MTLLoader 用来加载 .mtl 格式的材质库文件,并调用 load 函数、传入加载完成后的回调函数,MTLLoader 在加载完成后调用回调函数、传入加载好的材质(纹理)相关数据;传入 MTLLoader 的回调函数里创建了一个 OBJLoader 用于加载 .obj 模型的顶点、法线、纹理坐标等信息,该加载器同样具有加载完成的回调函数,该回调函数中传入了加载完成的模型实例,包含顶点、纹理等数据,用这些数据创建 MeshTextureMaterial 等数据实例,最终创建出一个 MeshRender 并添加到 WebGLRenderer 中,从而完成了从外部静态资源中加载模型数据并添加到场景中的功能。

loadOBJ.ts

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
import * as THREE from "three";
import { MTLLoader } from "three/examples/jsm/loaders/MTLLoader";
import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader";
import { Mesh } from "../objects/Mesh";
import { Material } from "../materials/Material";
import VertexShader from "../shaders/InternalShader/VertexShader.glsl?raw";
import FragmentShader from "../shaders/InternalShader/FragmentShader.glsl?raw";
import { MeshRender } from "../renderers/MeshRender";
import { Texture } from "../textures/Texture";
import { WebGLRenderer } from "../renderers/WebGLRenderer";
import { BufferAttribute } from "three";

export function loadOBJ(renderer: WebGLRenderer, path: string, name: string) {
// 创建一个加载管理器,可以添加到 MTLLoader 和 OBJLoader 中以追踪加载进度
const manager = new THREE.LoadingManager();
manager.onProgress = function (item, loaded, total) {
console.log(item, loaded, total);
};

// 定义打印 OBJLoader 自身加载进度的回调函数
function onProgress(xhr: ProgressEvent<EventTarget>) {
if (xhr.lengthComputable) {
const percentComplete = (xhr.loaded / xhr.total) * 100;
console.log(
// "model " + Math.round(percentComplete, 2) + "% downloaded"
"model " + Math.round(percentComplete) + "% downloaded"
);
}
}
function onError() { }

// 新建一个 MTLLoader,指定其加载外部资源的路径,并设置加载完成的回调函数
new MTLLoader(manager)
.setPath(path)
.load(name + ".mtl", function (materials) {
// 这里被执行时表明 .mtl 文件已经加载完成,传入的是将要加载的材质纹理数据
materials.preload();
// 新建一个 OBJLoader,为其设置材质和资源路径,使用类似的方法来设置加载完成的回调函数
new OBJLoader(manager)
.setMaterials(materials)
.setPath(path)
.load(
name + ".obj",
function (object) {
// 可能会加载到多个模型,因此需要遍历每一个模型来创建 MeshRender
// 并添加到 WebGLRenderer
object.traverse(function (event) {
const child = event;

// 判断每个 event 是不是 THREE.Mesh
if (child instanceof THREE.Mesh && child.isMesh) {
// 如果是,则取出它的几何数据和材质数据
let geo = child.geometry;
let mat = new THREE.MeshPhongMaterial;
// 不论只有一个材质还是多个材质,
// 都检查并转换为 MeshPhongMaterial
if (Array.isArray(child.material)) {
let firstMaterial = child
.material[0];
if (firstMaterial instanceof THREE.MeshPhongMaterial) {
mat = firstMaterial;
}
}
else if (child.material instanceof THREE.MeshPhongMaterial) {
mat = child.material;
}

// 从几何数据中取出顶点索引
var indices = Array.from(
{ length: geo.attributes.position.count },
(v, k) => k
);
// 如果存在顶点坐标、法线或纹理坐标数据,则一并取出
// 同时创建一个 Mesh 实例保存这些数据
let mesh = new Mesh(
{
name: "aVertexPosition",
array: new Float32Array(geo.attributes.position instanceof BufferAttribute ? geo.attributes.position.array : []),
},
{
name: "aNormalPosition",
array: new Float32Array(geo.attributes.normal instanceof BufferAttribute ? geo.attributes.normal.array : []),
},
{
name: "aTextureCoord",
array: new Float32Array(geo.attributes.uv instanceof BufferAttribute ? geo.attributes.uv.array : []),
},
indices
);

// 如果模型材质中包含纹理数据,则创建一个 Texture 实例
let colorMap: Texture | null = null;
if (mat.map != null)
colorMap = new Texture(
renderer.gl,
mat.map.image
);
// MARK: You can change the myMaterial object to your own Material instance

// 若存在纹理数据,在新创建的 Material 实例中添加该纹理数据
// 否则不添加任何纹理参数
let textureSample = 0;
let myMaterial;
if (colorMap != null) {
textureSample = 1;
myMaterial = new Material(
{
// 名为 uSampler 的材质参数
// 类型为 texture,值为 colorMap
uSampler: {
type: "texture",
value: colorMap,
},
uTextureSample: {
type: "1i",
value: textureSample,
},
uKd: {
type: "3fv",
value: new Float32Array(mat.color.toArray() || []),
},
},
[],
VertexShader,
FragmentShader
);
} else {
myMaterial = new Material(
{
uTextureSample: {
type: "1i",
value: textureSample,
},
uKd: {
type: "3fv",
value: new Float32Array(mat.color.toArray() || []),
},
},
[],
VertexShader,
FragmentShader
);
}

// 从模型资源中取出的各类数据准备好之后
// 用这些数据创建一个 MeshRender 实例
let meshRender = new MeshRender(
renderer.gl,
mesh,
myMaterial
);
// 并将该 MeshRenderer 添加到 WebGLRenderer 中
renderer.addMesh(meshRender);
}
});
},
onProgress,
onError
);
});
}

loadShader.ts 代码注释

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import * as THREE from 'three';

export function loadShaderFile(filename: string) {

// 使用一个将由 Three.js 提供的加载操作放在一个 Promise 中进行,
// 当加载完成后调用 resovle 表示加载完成
// 外部使用时可以在 async 函数中使用 await 等待 resolve 执行完成
// 也可以直接对 Promise 调用 then 方法获取该 Promise 中调用 resolve 所传入的参数
// 实现异步加载 Shader 源码文件
return new Promise((resolve, reject) => {
const loader = new THREE.FileLoader();
loader.load(filename, (data) => {
resolve(data);
//console.log(data);
});
});
}

Lights

光源是代码框架中结构较为简单的类型。逻辑上光源只表示场景空间中的某处存在一个发光的物体,它具有一定的发光强度,可以照亮场景中的其他物体;表现上在渲染出的场景中具有具体的形态,可能是一个灯泡模型,也可能是其他,带有自己的 Mesh 和相应的材质。

总体功能

代码框架在光源部分提供了两个类型:EmissiveMaterialPointLight。前者定义了一种特殊的材质,用来表现发光实体本身的视觉效果;后者定义了光源的具体数据,包括发光强度,使用什么样的 Mesh 表现它的形状等,同时保存了发光材质的引用。

Light.ts 代码注释

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
import { Material } from '../materials/Material';
import LightCubeVertexShader from "../shaders/InternalShader/LightCubeVertexShader.glsl?raw";
import LightCubeFragmentShader from "../shaders/InternalShader/LightCubeFragmentShader.glsl?raw";
import { vec3 } from 'gl-matrix';


export class EmissiveMaterial extends Material {

// 定义光源的发光强度
intensity: number;
// 定义发光的颜色
color: vec3;

constructor(lightIntensity: number, lightColor: vec3) {
// 构造材质实例,传入着色器源码和发光强度、颜色等参数
super({
'uLigIntensity': { type: '1f', value: lightIntensity },
'uLightColor': { type: '3fv', value: lightColor }
}, [], LightCubeVertexShader, LightCubeFragmentShader);

this.intensity = lightIntensity;
this.color = lightColor;
}
}

PointLight.ts 代码注释

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { Mesh } from '../objects/Mesh';
import { EmissiveMaterial } from './Light';
import { vec3 } from 'gl-matrix';

export class PointLight {
/**
* Creates an instance of PointLight.
* @param {float} lightIntensity The intensity of the PointLight.
* @param {vec3f} lightColor The color of the PointLight.
* @memberof PointLight
*/

// 定义发光实体的网格体
mesh: Mesh;
// 保存渲染该实体所需的材质
mat: EmissiveMaterial;

constructor(lightIntensity: number, lightColor: vec3) {
// 光源的形状默认是一个立方体
this.mesh = Mesh.cube();
this.mat = new EmissiveMaterial(lightIntensity, lightColor);
}
}