第 1 节:WebGL 渲染管线与坐标系统
🎯 学习目标
- 理解 WebGL 渲染管线的 5 个关键阶段
- 理解坐标系统从模型空间到屏幕空间的变换
- 对比原生 WebGL 和 Three.js 的实现方式
📖 理论:WebGL 渲染管线
WebGL 渲染管线是一个固定的流水线,将 3D 数据转换为屏幕上的 2D 像素。这个过程分为 5 个主要阶段:
顶点数据 → 顶点着色器 → 图元装配 → 光栅化 → 片段着色器 → 屏幕像素
🧭 关键概念地图(建议先建立整体心智模型)
WebGL 学起来“零散”的根因是:它其实是一套状态机 + GPU 数据流。下面这些概念一旦串起来,后面每一节都会更顺。
1) Buffer:GPU 侧数据的“存放处”
- 一句话:Buffer 是 GPU/驱动管理的一块连续内存;WebGL 用
WebGLBuffer/Framebuffer等对象把它暴露出来;Three.js 用BufferGeometry/BufferAttribute等再封装一层。 - 你会在哪里用到:
- 顶点缓冲(VBO):
gl.ARRAY_BUFFER,给 attribute 提供数据 - 索引缓冲(EBO/IBO):
gl.ELEMENT_ARRAY_BUFFER,复用顶点 - 深度缓冲:深度测试用的“每像素记事本”
- 帧缓冲(FBO):把输出写到屏幕以外的目标(后处理/阴影/拾取等)
- GPU/WebGL 视角:
Framebuffer是一套“输出目标组合”,可以把颜色、深度、模板等缓冲绑在一起,让本来要画到屏幕的结果先画到一块离屏的颜色缓冲/纹理里。 - 典型用途:
- 后处理:先把整个场景渲染到一张纹理,再用全屏三角形+Shader 做模糊/泛光等效果。
- 阴影:从光源视角渲染深度到一张“阴影贴图”纹理,然后主渲染 pass 里采样它判断是否被遮挡。
- 拾取/ID 缓冲:渲染一帧只包含物体 ID 的颜色缓冲,用鼠标点选时读取像素反查 ID。
- Three.js 里的对应物(封装在引擎内部):
WebGLRenderTarget/WebGLMultisampleRenderTarget/WebGLCubeRenderTarget- 后处理里的
EffectComposer/RenderPass/ 各种*Pass内部都在用 FBO 做多次渲染 - 阴影系统里的 shadow map(
Light.shadow.map)本质也是一个用 FBO 渲染出来的深度纹理
- GPU/WebGL 视角:
- 顶点缓冲(VBO):
2) Draw Call + State:一次绘制到底带着哪些“状态”
- 一句话:一次
gl.drawArrays/drawElements= 固定管线 + 当前绑定的 Buffer/纹理/Program + 当前开启的测试与混合规则。 - 为什么重要:性能优化经常就是在减少 draw call、减少 state 切换、减少带宽(纹理/帧缓冲写入)。
把这句展开解释一下(更贴近工程直觉):
什么是 state(状态)?
- WebGL 很像“状态机”:你先用一堆
gl.xxx(...)把“当前状态”配好,然后draw*会把当前这一套状态用于这次绘制。 - 常见 state(只列最常用的):
- 当前 Program:
gl.useProgram(...) - 当前绑定的纹理/采样器:
gl.activeTexture(...)+gl.bindTexture(...)+gl.uniform1i(...) - 混合/深度/剔除开关与参数:
gl.enable(gl.DEPTH_TEST)、gl.enable(gl.BLEND)、gl.blendFunc(...)、gl.depthFunc(...)、gl.enable(gl.CULL_FACE) - 当前 Framebuffer/Viewport:
gl.bindFramebuffer(...)、gl.viewport(...)
- 当前 Program:
- WebGL 很像“状态机”:你先用一堆
“减少 state 切换”到底在减少什么?
- 当你在两次 draw 之间频繁改变上面这些状态(比如不停换 program、换纹理、切 FBO、改 blend/depth 规则),驱动需要做更多工作,GPU 也更难“顺滑流水线”地连续处理。
- 典型优化思路就是:把使用同一套材质/纹理/渲染状态的物体排在一起画(减少切换次数)。
这里的“带宽”指什么?为什么它会成为瓶颈?
- 带宽 = GPU 读写显存数据的吞吐量(不是网络带宽)。
- 主要来自两类动作:
- 读:片元着色器采样纹理(尤其是大纹理、多次采样、mipmap/各向异性都可能增加访问压力)
- 写:把结果写入颜色缓冲/深度缓冲(分辨率越高、写得越多、越吃带宽)
- 所以“减少带宽”常见落点是:
- 少采样:减少纹理采样次数、避免不必要的高精度/高分辨率纹理
- 少写:减少 overdraw(同一像素被画很多遍)、能 early-z 丢掉的就尽量丢
- 少大目标:能用更小的渲染目标就别用全分辨率(比如后处理用 half/quarter res)
3) Shader Program:VS/FS 的“成对执行单位”
- 一句话:WebGL 不是“跑一个 shader”,而是跑一个 Program(顶点着色器 + 片段着色器链接后的整体)。
- 你会在哪里用到:第 3 节会详细讲编译、链接、报错定位(这是 WebGL 最常见卡点之一)。
4) Attribute / Uniform / Varying:数据怎么进来、怎么跨阶段
- Attribute:每个顶点一份(来自 VBO)
- Uniform:一次 draw call 共享(常用来传矩阵、时间、颜色、纹理等)
- Varying:VS 输出给 FS,并且会在三角形内部自动插值
5) 插值(Interpolation):为什么“自动支持”
- 一句话:顶点只有 3 个,但像素有很多;光栅化阶段必须把顶点属性连续地扩散到每个片元,才能做纹理采样/渐变/光照等。
- 关键点:GPU 做的是“透视正确插值”(否则透视下纹理会明显不对)。
6) 坐标系链路(MVP):从模型到屏幕的必经之路
- 模型空间 →(M)→ 世界空间 →(V)→ 相机空间 →(P)→ 裁剪空间 →(齐次除法)→ NDC →(Viewport)→ 屏幕像素
- 注意易混点:
- 裁剪空间(Clip Space)不是屏幕坐标:
gl_Position是齐次裁剪坐标 ((x,y,z,w)) - NDC 是做完
xyz /= w后的 ([-1,1]) 坐标 - Viewport 变换把 NDC 映射到像素坐标(需要你先
gl.viewport(...)设定规则)
- 裁剪空间(Clip Space)不是屏幕坐标:
7) Depth / Blending:为什么会“遮挡正确”和“透明有坑”
- 深度测试:靠深度缓冲决定谁挡谁;
near/far会影响精度,导致z-fighting - 混合:透明靠 blending;常见坑是透明排序、预乘 alpha、边缘发黑/发灰
8) Texture:不只是“贴图”,也是 GPU 数据表
- 采样与过滤(nearest/linear、mipmap)、wrap、UV
- 颜色空间(sRGB vs linear)会直接影响“颜色看起来对不对”
怎么理解 GPU 数据表,假设你要给每个像素存 4 个物理属性:
- R:金属度 metalness
- G:粗糙度 roughness
- B:AO 环境遮蔽
- A:自发光强度
如果你各用一张图,就要四次采样:
float m = texture(metalTex, uv).r;
float r = texture(roughTex, uv).g;
float ao = texture(aoTex, uv).r;
float e = texture(emissiveTex, uv).r;
如果你把它们打在一张 RGBA 纹理里:
vec4 packed = texture(packedMRaoE, uv);
float metalness = packed.r;
float roughness = packed.g;
float ao = packed.b;
float emissive = packed.a;
1 次采样 = 4 个数。这里这张纹理就不是“照片”,而是一张“每个 texel 存 4 个 float 的表”。
另外阴影贴图(shadow map)其实本质就是这样一个数据表:
- 事先从“光源视角”渲一次场景,把每个方向上最近的深度写进一张纹理(shadow map)。
- 主渲染时,对某个像素:
- 把它的位置变换到光源的裁剪空间 → 得到一个 UV + depth
- 用 UV 去采样 shadow map:拿到“光看过去最近的深度”
- 对比这两个深度:
- 如果当前点更远 → 在阴影里
- 否则 → 在光照下
这张 shadow map 完全可以当成一句话:“(方向, 深度) -> 是否在阴影中” 的查找表。
1. 顶点数据(Vertex Data)
作用:定义 3D 物体的形状
- 位置坐标(x, y, z)
- 颜色信息
- 纹理坐标(UV)
- 法线向量
类比:就像建筑图纸上的点,定义了房子的结构。
2. 顶点着色器(Vertex Shader)
作用:处理每个顶点,计算它在屏幕上的位置
- 接收顶点数据(attribute)
- 接收变换矩阵(uniform)
- 输出屏幕坐标(gl_Position)
- 输出传递给片段着色器的数据(varying)
类比:就像摄影师决定从哪个角度拍摄,把 3D 物体投影到 2D 照片上。
3. 图元装配(Primitive Assembly)
作用:将顶点连接成三角形、线条等基本图形
- 裁剪(Clipping):去掉屏幕外的部分
- 背面剔除(Face Culling):去掉背对相机的面
类比:把点连成线,把线连成面。
4. 光栅化(Rasterization)
作用:确定哪些像素被三角形覆盖
- 将三角形转换为像素片段(Fragment)
- 每个片段包含位置、深度、颜色等信息
类比:把照片放大,确定每个像素点的颜色。
5. 片段着色器(Fragment Shader)
作用:计算每个像素的最终颜色
- 接收插值后的 varying 变量
- 接收纹理、光照等信息
- 输出最终颜色(gl_FragColor)
类比:给照片上色,决定每个像素的颜色。
🔧 坐标系统变换(MVP 矩阵)
从 3D 模型到屏幕,需要经过 4 个坐标空间的变换:
1. 模型空间(Model Space)
定义:物体自己的坐标系,原点在物体中心
// 在模型空间中,一个立方体的顶点可能是:
// (-0.5, -0.5, -0.5) 到 (0.5, 0.5, 0.5)
2. 世界空间(World Space)
定义:整个场景的坐标系,所有物体都在这个空间中
变换:Model 矩阵(平移、旋转、缩放)
// 在世界空间中,立方体可能位于:
// position: (10, 0, 5)
// rotation: (0, Math.PI/4, 0)
// scale: (2, 2, 2)
3. 视图空间(View Space / Camera Space)
定义:以相机为原点的坐标系
变换:View 矩阵(相机的逆变换)
// 在视图空间中,相机位于原点 (0, 0, 0)
// 物体相对于相机的位置
4. 裁剪空间(Clip Space)
定义:标准化设备坐标(NDC),范围 [-1, 1]
变换:Projection 矩阵(透视投影或正交投影)
// 在裁剪空间中,坐标范围是:
// x: [-1, 1] (左到右)
// y: [-1, 1] (下到上)
// z: [-1, 1] (近到远)
5. 屏幕空间(Screen Space)
定义:实际的像素坐标
变换:视口变换(Viewport Transform)
// 在屏幕空间中,坐标是:
// x: [0, canvas.width]
// y: [0, canvas.height]
💻 原生 WebGL 实现
让我们用原生 WebGL 绘制一个简单的点,理解渲染管线:
<!DOCTYPE html>
<html>
<head>
<title>WebGL 渲染管线演示</title>
<style>
body {
margin: 0;
}
canvas {
display: block;
}
</style>
</head>
<body>
<canvas id="glcanvas"></canvas>
<script>
// 1. 获取 WebGL 上下文
const canvas = document.getElementById("glcanvas");
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const gl = canvas.getContext("webgl");
if (!gl) {
alert("无法初始化 WebGL");
}
// 2. 顶点着色器源码
// 作用:将顶点位置变换到裁剪空间
const vertexShaderSource = `
attribute vec4 aVertexPosition;
void main() {
// 直接输出位置(已经在裁剪空间中)
// 如果不在裁剪空间,需要乘以 MVP 矩阵
gl_Position = aVertexPosition;
gl_PointSize = 20.0; // 点的大小
}
`;
// 3. 片段着色器源码
// 作用:计算每个像素的颜色
const fragmentShaderSource = `
precision mediump float;
void main() {
// 输出红色
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
}
`;
// 4. 编译着色器
function createShader(gl, type, source) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error("着色器编译错误:", gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
return shader;
}
const vertexShader = createShader(
gl,
gl.VERTEX_SHADER,
vertexShaderSource,
);
const fragmentShader = createShader(
gl,
gl.FRAGMENT_SHADER,
fragmentShaderSource,
);
// 5. 创建程序并链接
function createProgram(gl, vertexShader, fragmentShader) {
const program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error("程序链接错误:", gl.getProgramInfoLog(program));
gl.deleteProgram(program);
return null;
}
return program;
}
const program = createProgram(gl, vertexShader, fragmentShader);
gl.useProgram(program);
// 6. 准备顶点数据
// 在裁剪空间中,中心点是 (0, 0, 0)
const positions = new Float32Array([
0.0,
0.0,
0.0, // 屏幕中心
]);
// 7. 创建缓冲区
const positionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW);
// 8. 配置属性
const positionAttributeLocation = gl.getAttribLocation(
program,
"aVertexPosition",
);
gl.enableVertexAttribArray(positionAttributeLocation);
gl.vertexAttribPointer(
positionAttributeLocation, // 属性位置
3, // 每个顶点有 3 个分量 (x, y, z)
gl.FLOAT, // 数据类型
false, // 不归一化
0, // stride
0, // offset
);
// 9. 设置视口
gl.viewport(0, 0, canvas.width, canvas.height);
// 10. 清空画布
gl.clearColor(0.0, 0.0, 0.0, 1.0); // 黑色背景
gl.clear(gl.COLOR_BUFFER_BIT);
// 11. 绘制
gl.drawArrays(gl.POINTS, 0, 1); // 绘制 1 个点
</script>
</body>
</html>
代码解析
- 获取上下文:
getContext('webgl')获取 WebGL 渲染上下文 - 编写 Shader:顶点着色器和片段着色器都是字符串
- 编译链接:需要手动编译每个着色器,然后链接成程序
- 准备数据:顶点数据存储在 Float32Array 中
- 创建缓冲区:将数据上传到 GPU
- 配置属性:告诉 GPU 如何读取缓冲区数据
- 绘制:调用
drawArrays执行渲染
🎨 Three.js 对比实现
Three.js 封装了所有底层操作,代码简洁很多:
<!DOCTYPE html>
<html>
<head>
<title>Three.js 渲染管线演示</title>
<style>
body {
margin: 0;
}
canvas {
display: block;
}
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js"
}
}
</script>
<script type="module">
import * as THREE from "three";
// 1. 创建场景(对应 WebGL 的上下文管理)
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x000000);
// 2. 创建相机(对应 View 和 Projection 矩阵)
const camera = new THREE.PerspectiveCamera(
75, // FOV
window.innerWidth / window.innerHeight, // 宽高比
0.1, // 近裁剪面
1000, // 远裁剪面
);
camera.position.z = 5;
// 3. 创建渲染器(封装了 WebGL 上下文)
const renderer = new THREE.WebGLRenderer({
canvas: document.getElementById("canvas"),
});
renderer.setSize(window.innerWidth, window.innerHeight);
// 4. 创建几何体(封装了 Buffer 和 vertexAttribPointer)
const geometry = new THREE.BufferGeometry();
const positions = new Float32Array([0, 0, 0]);
geometry.setAttribute(
"position",
new THREE.BufferAttribute(positions, 3),
);
// 5. 创建材质(封装了 Shader 编译和链接)
const material = new THREE.PointsMaterial({
color: 0xff0000, // 红色
size: 20,
});
// 6. 创建网格(封装了 Program 和绘制调用)
const points = new THREE.Points(geometry, material);
scene.add(points);
// 7. 渲染(内部调用 gl.drawArrays)
renderer.render(scene, camera);
</script>
</body>
</html>
Three.js 的封装对应关系
| 原生 WebGL | Three.js | 说明 |
|---|---|---|
getContext('webgl') | WebGLRenderer | 渲染器封装了上下文 |
createShader + compileShader | Material | 材质自动编译 Shader |
createProgram + linkProgram | Material | 材质自动链接程序 |
createBuffer + bufferData | BufferGeometry | 几何体自动管理 Buffer |
vertexAttribPointer | setAttribute | 几何体自动配置属性 |
drawArrays | render() | 渲染器自动调用绘制 |
🔍 原理对比分析
1. 渲染管线的封装
原生 WebGL:
- 需要手动管理每个阶段
- 代码冗长,但完全可控
- 适合学习底层原理
Three.js:
- 自动管理整个管线
- 代码简洁,但隐藏了细节
- 适合快速开发
2. 坐标变换的封装
原生 WebGL:
// 需要手动计算 MVP 矩阵
const mvpMatrix = mat4.multiply(
projectionMatrix,
mat4.multiply(viewMatrix, modelMatrix),
);
gl.uniformMatrix4fv(mvpLocation, false, mvpMatrix);
Three.js:
// 自动计算和传递矩阵
// Object3D 自动更新 modelMatrix
// Camera 自动更新 viewMatrix 和 projectionMatrix
// Material 自动接收这些矩阵
3. 数据管理的封装
原生 WebGL:
- 需要手动创建、绑定、上传 Buffer
- 需要手动配置 vertexAttribPointer
- 需要手动管理内存
Three.js:
- BufferGeometry 自动管理所有 Buffer
- 自动处理数据类型和 stride
- 自动处理内存清理
📝 编程作业
基础作业(⭐)
任务:实现一个在屏幕中心显示红色点的程序
要求:
- 使用原生 WebGL 实现
- 使用 Three.js 实现
- 对比两种实现的代码量和复杂度
检查清单:
- 原生 WebGL 版本可以正常显示点
- Three.js 版本可以正常显示点
- 理解两种实现的对应关系
- 能够解释渲染管线的 5 个阶段
进阶作业(⭐⭐)
任务:绘制多个不同颜色的点
要求:
- 在屏幕的 4 个角落各绘制一个点
- 每个点使用不同的颜色
- 使用原生 WebGL 和 Three.js 两种方式实现
提示:
// 原生 WebGL:需要为每个点设置不同的颜色
// 可以使用 attribute 传递颜色,或使用 uniform 数组
// Three.js:可以使用 Points 和多个 BufferAttribute
检查清单:
- 4 个点都正确显示
- 每个点颜色不同
- 理解 varying 变量的插值(虽然点不插值,但为后续学习做准备)
挑战作业(⭐⭐⭐)
任务:实现一个坐标系统可视化工具
要求:
- 绘制 3 条坐标轴(X 红色、Y 绿色、Z 蓝色)
- 在轴上标注刻度
- 实现相机控制(可以用 OrbitControls 或自己实现)
- 显示当前坐标空间的信息
扩展:
- 实现坐标空间的切换(模型空间、世界空间、视图空间)
- 可视化 MVP 矩阵的变换过程
检查清单:
- 坐标轴正确显示
- 相机可以旋转和缩放
- 理解不同坐标空间的概念
- 能够解释 MVP 矩阵的作用
🎓 本节总结
关键概念
- 渲染管线:顶点数据 → 顶点着色器 → 图元装配 → 光栅化 → 片段着色器
- 坐标变换:模型空间 → 世界空间 → 视图空间 → 裁剪空间 → 屏幕空间
- MVP 矩阵:Model(模型变换)、View(视图变换)、Projection(投影变换)
原生 WebGL vs Three.js
| 方面 | 原生 WebGL | Three.js |
|---|---|---|
| 代码量 | 多(~100 行) | 少(~30 行) |
| 学习曲线 | 陡峭 | 平缓 |
| 控制力 | 完全控制 | 受限于框架 |
| 开发速度 | 慢 | 快 |
| 理解深度 | 深入底层 | 理解封装 |
下一步
完成作业后,进入下一节:第 2 节:第一个三角形(顶点数据与缓冲区)