引言:知其然,更要知其所以然

作为一个长期与 Cesium 打交道的 Giser,在搭建Web3D场景时,面对Shader、矩阵变换、酷炫材质等深层定制需求,我常常因不了解三维渲染底层逻辑而感到困惑与束手无策。为此,我在B站上学习了闫令琪大佬的计算机图形学课程,并使用原生WebGL在实践中验证理论。

这篇文章不讲复杂的 API 调用,只讲最纯粹的渲染逻辑。让我们从零开始,写出属于 Web3D 的“Hello World”,一起攻克那些让人头秃的图形学概念!

建立基本概念

在正式开始写代码之前,我们首先需要对以下几个概念建立起明确的认知:

1.WebGL到底是什么?

很多人说到 WebGL ,就想到三维、想到酷炫的3D动画效果,认为它是一个无所不能的3D渲染引擎。但实际上WebGLFundamentals 在教程中给出了清晰的定义:

WebGL仅仅是一个光栅化引擎,它可以根据你的代码绘制出点,线和三角形。 想要利用WebGL完成更复杂任务,取决于你能否提供合适的代码,组合使用点,线和三角形代替实现。

换言之,WebGL实际上只负责在2D的画布上,画点、线、三角形,以及上色而已,它的功能相当简单。想要画出复杂的 3D 物体,完全取决于你能否提供相应的代码。

就像一位画家要在一张画纸上画出逼真的有立体感、透视关系的画作,WebGL只是他的画笔,而你的代码才是他的大脑。要指挥这个画家,我们需要两份“说明书“:

  • 顶点着色器:负责定位,告诉 GPU 顶点在裁剪空间的位置。
  • 片元着色器:负责上色,告诉 GPU 每个像素该涂什么颜色。

2.像摄影师一样思考:MVP 矩阵

如果你觉得数学枯燥,不妨把自己想象成一名摄影师。在现实中拍好一张照片,通常需要三步:

  1. 摆 Pose:让模特站好位置,摆好姿势。
    • 在 3D 中,这对应将物体移动到世界坐标系中的特定位置,并设置朝向——Model Transform(模型变换)
  2. 找机位:摄影师移动脚步,扛着相机找到最佳的拍摄角度。
    • 在 3D 中,这对应相机的位置和视线方向百变换。—— View Transform(视口变换)
  3. 选镜头/按快门:决定是用广角还是长焦,将三维的景象“拍扁”在二维的底片(屏幕)上。
    • 在 3D 中,这是将三维空间压缩到二维屏幕的过程,产生“近大远小”的透视效果。——Projection Transform(投影变换)

这就是图形学中著名的 MVP 矩阵。一个三维空间中的顶点,经过完整的MVP矩阵变换后,就能得到它在画布中的位置:

$$
顶点坐标 × M × V × P = 裁剪空间坐标
$$
理解了它,你就掌握了 3D 渲染的核心逻辑。

代码实战:动手构建三维世界

有了上面的理论基础,我们就可以开始动手写代码了!

1.创造物质:定义几何体 (Geometry)

首先,我们需要建立坐标系,用来表示3D世界中物体的位置,比如我们的老朋友——笛卡尔右手直角坐标系:

有了坐标系后,我们还需要定义物体的“骨架”。这里我们参考 Three.js 的设计,封装一个 BoxGeometry 类。它负责生成立方体 6 个面的顶点坐标、颜色和索引。然后new一个方块实例出来,不改变它的位置时,它默认会出生在坐标原点(0,0,0)。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 伪代码示意:构建几何体数据
export class BoxGeometry {
constructor(width = 1, height = 1, depth = 1) {
// ...构建前、后、左、右、上、下6个面的数据
// 最终生成 positions(顶点), colors(颜色), indices(索引)
this.buildPlane('z', 'y', 'x', ...);
// ...
}
// ... buildPlane 具体实现逻辑
}

// 实例化一个长宽高为 2 的立方体
const cube = new BoxGeometry(2, 2, 2)

2.放置眼睛:定义相机 (Camera)

接下来,我们需要一只观察世界的“眼睛”。我们封装一个 PerspectiveCamera(透视相机),它模拟了眼睛的成像原理,具有近大远小的透视关系。

这里我们引入了 gl-matrix 库来处理复杂的矩阵运算。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { mat4, vec3 } from 'gl-matrix'

export class PerspectiveCamera {
constructor(fov, aspect, near, far) {
// 初始化相机参数
this.updateProjectionMatrix() // 计算投影矩阵 (P)
this.updateViewMatrix() // 计算视图矩阵 (V)
}

// 调整镜头参数 (P矩阵)
updateProjectionMatrix() {
mat4.perspective(this.projectionMatrix, this.fov, this.aspect, this.near, this.far)
}

updateViewMatrix() {
// lookAt: 也就是让相机“盯着”某个目标看
mat4.lookAt(this.viewMatrix, this.position, this.target, this.up)
}
// ...
}

3.搭建流水线:渲染管线 (Pipeline)

现在,我们要搭建 WebGL 的渲染管线。这部分稍显繁琐,但却是 CPU 与 GPU 沟通的桥梁,主要包含以下三个步骤:

  1. **编写着色器 (Shader)**:这是运行在 GPU 上的代码。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    // 顶点着色器:核心是 u_mvp 矩阵
    const VERTEX_SHADER = `#version 300 es
    in vec4 a_position;
    uniform mat4 u_mvp; // 接收 Model-View-Projection 矩阵
    void main() {
    gl_Position = u_mvp * a_position; // 见证奇迹的时刻
    }
    `
    // 片元着色器
    const FRAGMENT_SHADER_SOURCE = `#version 300 es
    precision highp float;
    in vec4 v_color;
    out vec4 outColor;
    void main() {
    outColor = v_color;
    }
    `
  2. **创建程序 (Program)**:编译并链接着色器。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    //从source创建着色器程序
    export function createProgramFromSources(gl: WebGL2RenderingContext, [vs, fs]: [string, string]) {
    // 编译着色器
    const vertexShader = compileShader(gl, vs, gl.VERTEX_SHADER)
    const fragmentShader = compileShader(gl, fs, gl.FRAGMENT_SHADER)
    if (!(vertexShader && fragmentShader)) return null

    // 创建程序
    const program = createProgram(gl, vertexShader, fragmentShader)
    return program
    }

    // 编译着色器
    // ...
  3. **创建缓冲区 (Buffer)**:将我们的立方体数据(顶点、颜色)搬运到 GPU 内存中。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    const initBuffers = () => {
    if (!gl || !program) return
    const geometry = new BoxGeometry(2, 2, 2)
    indexCount = geometry.indices.length
    vao = gl.createVertexArray()
    gl.bindVertexArray(vao)
    //顶点buffer...

    //颜色buffer...

    //顶点索引buffer(告诉gpu怎么连线)...
    gl.bindVertexArray(null)
    }

经过上面的三个步骤,GPU就能从缓冲区读取顶点数据,然后交给着色器处理了。

4.封装相机交互

封装一个仿threejs的轨道控制器,实现鼠标拖拽控制相机从不同的角度观察目标:

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
import { PerspectiveCamera } from "./camera/perspectiveCamera"
import { mat4, vec3 } from 'gl-matrix'

// ==========================================
// 2. OrbitControls (控制器类)
// 职责:监听鼠标 -> 计算球坐标 -> 修改 Camera 坐标
// ==========================================
export class OrbitControls {
public camera: PerspectiveCamera
public rotateSpeed = 0.5
public zoomSpeed = 0.5
// ...
constructor(camera: PerspectiveCamera, canvas: HTMLCanvasElement) {
this.camera = camera
this.canvas = canvas

// 1. 根据相机当前的初始位置,反算球坐标参数 (Radius/Theta/Phi)
const offset = vec3.create()
vec3.subtract(offset, camera.position, camera.target)

this.radius = vec3.length(offset)

// 反算:
this.radius = vec3.length(offset)
this.theta = Math.atan2(offset[0], offset[2]) // atan2(x, z)
this.phi = Math.acos(offset[1] / this.radius) // acos(y / r)

// 防止 NaN
if (this.radius === 0) this.radius = 1

this.bindEvents()
// 立即更新一次确保同步
this.update()
}

//...
}

5.渲染循环 (Render Loop)

万事俱备,只欠东风。我们在 requestAnimationFrame 中不断绘制画面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const mvpMatrix = mat4.create()
const render = () => {
// 1. 清空画布
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)

// 2. 计算 MVP 矩阵
// MVP = 投影矩阵 * 视图矩阵 * 模型矩阵
mat4.multiply(mvpMatrix, camera.viewProjectionMatrix, modelMatrix)

// 3. 将矩阵传给 GPU
gl.uniformMatrix4fv(mvpLocation, false, mvpMatrix)

// 4. 绘制三角形
gl.drawElements(gl.TRIANGLES, indexCount, gl.UNSIGNED_SHORT, 0)

// 5. 下一帧继续
requestAnimationFrame(render)
}

//开始循环渲染
render()

最终效果与思考

通过这次“裸写” WebGL,我们不仅对WebGL的定位有了基本的认识,更深刻理解了 3D 渲染的底层逻辑:一切皆矩阵,一切皆数据。

在接下来的文章中,我会基于这个原生引擎,继续探索更高级的话题:

  • 💡 光照计算:让物体拥有明暗体积感
  • 🖼️ 纹理贴图:给立方体穿上“衣服”
  • 🧱 深度缓冲:让模型之间拥有正确的遮挡关系

如果你也对 Web3D 的底层原理感兴趣,欢迎关注我,我们一起探索Web3D的无限可能!


(源码已整理,点赞评论+关注,后台回复【Cube】即可获取完整 Vue 工程文件)