Skip to main content

第 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 渲染出来的深度纹理

2) Draw Call + State:一次绘制到底带着哪些“状态”

  • 一句话:一次 gl.drawArrays/drawElements = 固定管线 + 当前绑定的 Buffer/纹理/Program + 当前开启的测试与混合规则。
  • 为什么重要:性能优化经常就是在减少 draw call、减少 state 切换、减少带宽(纹理/帧缓冲写入)。

把这句展开解释一下(更贴近工程直觉):

  • 什么是 state(状态)?

    • WebGL 很像“状态机”:你先用一堆 gl.xxx(...) 把“当前状态”配好,然后 draw* 会把当前这一套状态用于这次绘制。
    • 常见 state(只列最常用的):
      • 当前 Programgl.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/Viewportgl.bindFramebuffer(...)gl.viewport(...)
  • “减少 state 切换”到底在减少什么?

    • 当你在两次 draw 之间频繁改变上面这些状态(比如不停换 program、换纹理、切 FBO、改 blend/depth 规则),驱动需要做更多工作,GPU 也更难“顺滑流水线”地连续处理。
    • 典型优化思路就是:把使用同一套材质/纹理/渲染状态的物体排在一起画(减少切换次数)。
  • 这里的“带宽”指什么?为什么它会成为瓶颈?

    • 带宽 = GPU 读写显存数据的吞吐量(不是网络带宽)。
    • 主要来自两类动作:
      1. :片元着色器采样纹理(尤其是大纹理、多次采样、mipmap/各向异性都可能增加访问压力)
      2. :把结果写入颜色缓冲/深度缓冲(分辨率越高、写得越多、越吃带宽)
    • 所以“减少带宽”常见落点是:
      • 少采样:减少纹理采样次数、避免不必要的高精度/高分辨率纹理
      • 少写:减少 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(...) 设定规则)

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>

代码解析

  1. 获取上下文getContext('webgl') 获取 WebGL 渲染上下文
  2. 编写 Shader:顶点着色器和片段着色器都是字符串
  3. 编译链接:需要手动编译每个着色器,然后链接成程序
  4. 准备数据:顶点数据存储在 Float32Array 中
  5. 创建缓冲区:将数据上传到 GPU
  6. 配置属性:告诉 GPU 如何读取缓冲区数据
  7. 绘制:调用 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 的封装对应关系

原生 WebGLThree.js说明
getContext('webgl')WebGLRenderer渲染器封装了上下文
createShader + compileShaderMaterial材质自动编译 Shader
createProgram + linkProgramMaterial材质自动链接程序
createBuffer + bufferDataBufferGeometry几何体自动管理 Buffer
vertexAttribPointersetAttribute几何体自动配置属性
drawArraysrender()渲染器自动调用绘制

🔍 原理对比分析

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
  • 自动处理内存清理

📝 编程作业

基础作业(⭐)

任务:实现一个在屏幕中心显示红色点的程序

要求

  1. 使用原生 WebGL 实现
  2. 使用 Three.js 实现
  3. 对比两种实现的代码量和复杂度

检查清单

  • 原生 WebGL 版本可以正常显示点
  • Three.js 版本可以正常显示点
  • 理解两种实现的对应关系
  • 能够解释渲染管线的 5 个阶段

进阶作业(⭐⭐)

任务:绘制多个不同颜色的点

要求

  1. 在屏幕的 4 个角落各绘制一个点
  2. 每个点使用不同的颜色
  3. 使用原生 WebGL 和 Three.js 两种方式实现

提示

// 原生 WebGL:需要为每个点设置不同的颜色
// 可以使用 attribute 传递颜色,或使用 uniform 数组

// Three.js:可以使用 Points 和多个 BufferAttribute

检查清单

  • 4 个点都正确显示
  • 每个点颜色不同
  • 理解 varying 变量的插值(虽然点不插值,但为后续学习做准备)

挑战作业(⭐⭐⭐)

任务:实现一个坐标系统可视化工具

要求

  1. 绘制 3 条坐标轴(X 红色、Y 绿色、Z 蓝色)
  2. 在轴上标注刻度
  3. 实现相机控制(可以用 OrbitControls 或自己实现)
  4. 显示当前坐标空间的信息

扩展

  • 实现坐标空间的切换(模型空间、世界空间、视图空间)
  • 可视化 MVP 矩阵的变换过程

检查清单

  • 坐标轴正确显示
  • 相机可以旋转和缩放
  • 理解不同坐标空间的概念
  • 能够解释 MVP 矩阵的作用

🎓 本节总结

关键概念

  1. 渲染管线:顶点数据 → 顶点着色器 → 图元装配 → 光栅化 → 片段着色器
  2. 坐标变换:模型空间 → 世界空间 → 视图空间 → 裁剪空间 → 屏幕空间
  3. MVP 矩阵:Model(模型变换)、View(视图变换)、Projection(投影变换)

原生 WebGL vs Three.js

方面原生 WebGLThree.js
代码量多(~100 行)少(~30 行)
学习曲线陡峭平缓
控制力完全控制受限于框架
开发速度
理解深度深入底层理解封装

下一步

完成作业后,进入下一节:第 2 节:第一个三角形(顶点数据与缓冲区)


📚 参考资源