从 0 到 1 手写 WebGL:原来 3D 渲染的底层逻辑,就是拍照片
引言:知其然,更要知其所以然
作为一个长期与 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 矩阵
如果你觉得数学枯燥,不妨把自己想象成一名摄影师。在现实中拍好一张照片,通常需要三步:
- 摆 Pose:让模特站好位置,摆好姿势。
- 在 3D 中,这对应将物体移动到世界坐标系中的特定位置,并设置朝向——Model Transform(模型变换)
- 找机位:摄影师移动脚步,扛着相机找到最佳的拍摄角度。
- 在 3D 中,这对应相机的位置和视线方向百变换。—— View Transform(视口变换)
- 选镜头/按快门:决定是用广角还是长焦,将三维的景象“拍扁”在二维的底片(屏幕)上。
- 在 3D 中,这是将三维空间压缩到二维屏幕的过程,产生“近大远小”的透视效果。——Projection Transform(投影变换)
这就是图形学中著名的 MVP 矩阵。一个三维空间中的顶点,经过完整的MVP矩阵变换后,就能得到它在画布中的位置:
$$
顶点坐标 × M × V × P = 裁剪空间坐标
$$
理解了它,你就掌握了 3D 渲染的核心逻辑。
代码实战:动手构建三维世界
有了上面的理论基础,我们就可以开始动手写代码了!
1.创造物质:定义几何体 (Geometry)
首先,我们需要建立坐标系,用来表示3D世界中物体的位置,比如我们的老朋友——笛卡尔右手直角坐标系:

有了坐标系后,我们还需要定义物体的“骨架”。这里我们参考 Three.js 的设计,封装一个 BoxGeometry 类。它负责生成立方体 6 个面的顶点坐标、颜色和索引。然后new一个方块实例出来,不改变它的位置时,它默认会出生在坐标原点(0,0,0)。
1 | // 伪代码示意:构建几何体数据 |
2.放置眼睛:定义相机 (Camera)
接下来,我们需要一只观察世界的“眼睛”。我们封装一个 PerspectiveCamera(透视相机),它模拟了眼睛的成像原理,具有近大远小的透视关系。
这里我们引入了 gl-matrix 库来处理复杂的矩阵运算。
1 | import { mat4, vec3 } from 'gl-matrix' |
3.搭建流水线:渲染管线 (Pipeline)
现在,我们要搭建 WebGL 的渲染管线。这部分稍显繁琐,但却是 CPU 与 GPU 沟通的桥梁,主要包含以下三个步骤:
**编写着色器 (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;
}
`**创建程序 (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
}
// 编译着色器
// ...**创建缓冲区 (Buffer)**:将我们的立方体数据(顶点、颜色)搬运到 GPU 内存中。
1
2
3
4
5
6
7
8
9
10
11
12
13const 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 | import { PerspectiveCamera } from "./camera/perspectiveCamera" |
5.渲染循环 (Render Loop)
万事俱备,只欠东风。我们在 requestAnimationFrame 中不断绘制画面。
1 | const mvpMatrix = mat4.create() |
最终效果与思考

通过这次“裸写” WebGL,我们不仅对WebGL的定位有了基本的认识,更深刻理解了 3D 渲染的底层逻辑:一切皆矩阵,一切皆数据。
在接下来的文章中,我会基于这个原生引擎,继续探索更高级的话题:
- 💡 光照计算:让物体拥有明暗体积感
- 🖼️ 纹理贴图:给立方体穿上“衣服”
- 🧱 深度缓冲:让模型之间拥有正确的遮挡关系
如果你也对 Web3D 的底层原理感兴趣,欢迎关注我,我们一起探索Web3D的无限可能!
(源码已整理,点赞评论+关注,后台回复【Cube】即可获取完整 Vue 工程文件)

