我用 WebGL 从「画个点」一路画到「贪吃蛇」
大家好,我是tk 最近闲着没事干,学习ai学的脑子发蒙,想着学点可视化的换换脑子 所以就学了学webgl玩玩,下面是我学了几天总结下来的小经验
目录
- [第 1 步:画出人生第一个点](#第 1 步:画出人生第一个点 "#%E7%AC%AC-1-%E6%AD%A5%E7%94%BB%E5%87%BA%E4%BA%BA%E7%94%9F%E7%AC%AC%E4%B8%80%E4%B8%AA%E7%82%B9")
- [第 2 步:把点连成线](#第 2 步:把点连成线 "#%E7%AC%AC-2-%E6%AD%A5%E6%8A%8A%E7%82%B9%E8%BF%9E%E6%88%90%E7%BA%BF")
- [第 3 步:让图形动起来(着色器变量)](#第 3 步:让图形动起来(着色器变量) "#%E7%AC%AC-3-%E6%AD%A5%E8%AE%A9%E5%9B%BE%E5%BD%A2%E5%8A%A8%E8%B5%B7%E6%9D%A5%E7%9D%80%E8%89%B2%E5%99%A8%E5%8F%98%E9%87%8F%E7%89%88")
- [第 4 步:换矩阵来做变换](#第 4 步:换矩阵来做变换 "#%E7%AC%AC-4-%E6%AD%A5%E6%8D%A2%E7%9F%A9%E9%98%B5%E6%9D%A5%E5%81%9A%E5%8F%98%E6%8D%A2")
- [第 5 步:综合实战------贪吃蛇](#第 5 步:综合实战——贪吃蛇 "#%E7%AC%AC-5-%E6%AD%A5%E7%BB%BC%E5%90%88%E5%AE%9E%E6%88%98%E8%B4%AA%E5%90%83%E8%9B%87")
- 附录:踩坑清单
第 1 步:画出人生第一个点
目标:点击画布,出现一个红点。
这一步要把 WebGL 的「最小闭环」全部打通------后面每一步都在这上面加料。
1.1 拿到 WebGL 上下文
一切从 <canvas> 开始:
ts
const canvas = document.getElementById('canvas') as HTMLCanvasElement
const gl = canvas.getContext('webgl')
if (!gl) throw new Error('WebGL not supported')
getContext('webgl') 拿到的是一个状态机:你绑什么缓冲、用什么着色器、设什么颜色,它都记着。后面几乎所有操作都是在「改状态 → 下指令」。
1.2 画布尺寸:新手第一坑
CSS 写 width: 800px 只影响显示多大 。
WebGL 真正画画的「纸」由 canvas.width / height 决定,默认只有 300×150。
ts
canvas.width = canvas.clientWidth
canvas.height = canvas.clientHeight
gl.viewport(0, 0, canvas.width, canvas.height)
canvas.width/height:绘图缓冲分辨率viewport:告诉 GPU「裁剪空间[-1,1]映射到缓冲的哪块矩形」
只设 CSS、不设这两样 → 经典黑屏 / 位置错乱套餐。我为此贡献了整整一下午。
1.3 什么是着色器?为什么要两个?
WebGL 不帮你「画个圆」「画个方」。它只认:
- 顶点着色器(Vertex Shader) :每个顶点跑一遍,算出最终位置
gl_Position - 片元着色器(Fragment Shader) :每个像素(片元)跑一遍,算出颜色
gl_FragColor
可以粗暴理解成:
顶点着色器管「画在哪」,片元着色器管「涂啥色」。
顶点着色器
glsl
attribute vec2 a_position; // 每个顶点自己的数据(从缓冲里来)
uniform mat4 u_matrix; // 所有顶点共享的数据(投影矩阵)
void main() {
// 必须输出到裁剪空间
gl_Position = u_matrix * vec4(a_position, 0.0, 1.0);
gl_PointSize = 10.0; // 只有画 POINTS 时才有用
}
片元着色器
glsl
precision mediump float; // WebGL1 片元着色器必须写精度
uniform vec4 u_color;
void main() {
gl_FragColor = u_color;
}
学习点:attribute vs uniform
| attribute | uniform | |
|---|---|---|
| 谁有一份 | 每个顶点各一份 | 整次绘制共用一份 |
| 典型用途 | 位置、颜色、UV | 矩阵、时间、全局颜色 |
| 数据从哪来 | 顶点缓冲(ARRAY_BUFFER) | JS 里 uniformXxx 塞进去 |
1.4 着色器怎么变成能跑的 Program
GLSL 源码本身 GPU 不吃,要走一套仪式:
ts
// 1. 创建着色器对象
const vs = gl.createShader(gl.VERTEX_SHADER)!
const fs = gl.createShader(gl.FRAGMENT_SHADER)!
// 2. 塞源码 + 编译
gl.shaderSource(vs, vertexSource)
gl.compileShader(vs)
gl.shaderSource(fs, fragmentSource)
gl.compileShader(fs)
// 3. 创建程序,挂上两个着色器,链接
const program = gl.createProgram()!
gl.attachShader(program, vs)
gl.attachShader(program, fs)
gl.linkProgram(program)
// 4. 启用
gl.useProgram(program)
记住口诀:创建 → 源码 → 编译 → 挂载 → 链接 → 使用 。
链接失败 / 编译失败时建议查 getShaderInfoLog / getProgramInfoLog,否则它就静默让你对着黑屏发呆。
1.5 裁剪空间与正交投影
GPU 最终只认 裁剪空间 :x/y/z 都在大约 [-1, 1]。
可人点鼠标拿到的是像素坐标,比如 (320, 480)。
所以需要 投影矩阵 当翻译官。2D 常用正交投影:
ts
import { mat4 } from 'gl-matrix'
const projectMat4 = mat4.create()
mat4.ortho(projectMat4, 0, width, height, 0, -1, 1)
gl-matrix是大佬封装的开源数学库,有了这个我们就不用库库计算了。 感谢大佬✋︎😭✋︎
上面这个设定的含义:
- 左上角是
(0, 0) - X 向右增大,Y 向下增大(和 DOM 点击坐标一致)
- 像素
(x, y)会被映射进裁剪空间
然后把矩阵交给着色器:
ts
const uMatrix = gl.getUniformLocation(program, 'u_matrix')
gl.uniformMatrix4fv(uMatrix, false, projectMat4)
// 第二个参数 false:不要转置(gl-matrix 已经是列主序)
1.6 顶点缓冲:数据住在 GPU 里
JS 数组 GPU 直接用不了,要上传到 Buffer:
ts
const buffer = gl.createBuffer()!
gl.bindBuffer(gl.ARRAY_BUFFER, buffer) // 绑定到「当前 ARRAY_BUFFER 槽位」
WebGL 很多 API 不直接传 buffer 对象,而是操作「当前绑定的那个」------所以你会反复看到 bindBuffer。
告诉 GPU:怎么从缓冲里读 a_position:
ts
const aPosition = gl.getAttribLocation(program, 'a_position')
gl.enableVertexAttribArray(aPosition) // 打开这个属性通道
gl.vertexAttribPointer(
aPosition,
2, // 每个顶点读 2 个 float(x, y)
gl.FLOAT,
false, // 不归一化
0, // stride:0 表示紧密排列
0, // offset:从缓冲开头读
)
学习点:vertexAttribPointer 的 size 必须和数据布局一致。
你存的是 x,y,x,y...,就写 2;写 4 会把别人的数据当自己的坐标,图形当场变形。
1.7 颜色:那个差点击败我的 u_color
ts
const uColor = gl.getUniformLocation(program, 'u_color')
gl.uniform4f(uColor, 1, 0, 0, 1) // RGBA,大红
忘了设的话,uniform 默认接近 (0,0,0,0)。
黑底 + 透明黑 = 你画了,宇宙当没看见。新手排查第一招:先涂大红保命。
1.8 点击交互:动态更新缓冲
ts
const points: number[] = []
canvas.addEventListener('click', (e) => {
const rect = canvas.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
points.push(x, y)
gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(points), gl.DYNAMIC_DRAW)
draw()
})
学习点:bufferData 的 usage 提示
STATIC_DRAW:基本不变(几何轮廓)DYNAMIC_DRAW:经常改(点击加点、每帧更新蛇身)
1.9 真正开画:clear + drawArrays
ts
function draw() {
gl.clearColor(0, 0, 0, 1) // 设置清屏颜色
gl.clear(gl.COLOR_BUFFER_BIT) // 清掉颜色缓冲
gl.drawArrays(gl.POINTS, 0, points.length / 2)
// ↑ 从第 0 个顶点
// ↑ 画几个顶点(不是 float 个数!)
}
学习点:图元类型 POINTS
- 每个顶点画成一个点
- 点的大小看顶点着色器里的
gl_PointSize
学习点:count 怎么算
数据是 [x0,y0, x1,y1, x2,y2] → 6 个 float → 3 个顶点 → count = length / 2。
1.10 我踩的坑:空缓冲硬画
一度同时挂了两套点击逻辑:旧的直接 drawArrays(..., 0, 1),新的才写数据。
旧逻辑先冲上去,对着空缓冲喊「画一个!」:
text
GL_INVALID_OPERATION: Vertex buffer is not big enough for the draw call
翻译:你让我读顶点,缓冲里没那么多数据。
先 bufferData,再 draw。
第 1 步过关标准
点几下,冒出红点 → 上下文、着色器、缓冲、投影、绘制,整条管线通了。
第 2 步:把点连成线
目标:用一组顶点画出闭合折线。
新东西:索引缓冲 、
drawElements、LINE_LOOP、NDC 坐标。
2.1 为啥需要索引?
假设画矩形,四个角,两个三角形:
text
顶点:A B C D
三角形1:A B C
三角形2:A C D
如果不按索引,C 和 A 要存两遍。顶点一多就浪费。
索引缓冲(ELEMENT_ARRAY_BUFFER) 存的是「番号顺序」,顶点只存一份。
2.2 换一套坐标系:NDC
这一步我改用裁剪空间直接写顶点:
ts
mat4.ortho(projectMat4, -1, 1, -1, 1, -1, 1)
顶点可以直接写:
ts
const points = [
0, 0.5,
0.15, 0.15,
0.5, 0,
// ...
]
学习点:两种常用 2D 坐标
| 方式 | ortho 设定 | 顶点怎么写 | 适合 |
|---|---|---|---|
| 像素坐标 | (0,w, h,0) |
(320, 200) 这种 |
跟鼠标、格子对齐 |
| NDC | (-1,1, -1,1) |
(-0.5, 0.3) 这种 |
纯几何演示 |
2.3 创建索引缓冲
ts
const indices = new Uint16Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
const indexBuffer = gl.createBuffer()!
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer)
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW)
注意绑定目标是 ELEMENT_ARRAY_BUFFER ,不是 ARRAY_BUFFER。
两个槽位互不抢:顶点走 ARRAY,索引走 ELEMENT。
2.4 drawElements + LINE_LOOP
ts
gl.drawElements(
gl.LINE_LOOP, // 图元:闭合折线
indices.length, // 索引个数
gl.UNSIGNED_SHORT, // 和 Uint16Array 对应
0, // 从索引缓冲开头读
)
学习点:常见线相关图元
| 图元 | 含义 |
|---|---|
LINES |
两两一对:0-1,2-3,4-5... |
LINE_STRIP |
依次连:0-1-2-3...,不闭合 |
LINE_LOOP |
依次连,并且自动把末尾接回起点 |
2.5 和画点的对比(巩固)
| 点 | 线 | |
|---|---|---|
| API | drawArrays |
drawElements |
| 要不要索引 | 不需要 | 需要 |
| 图元 | POINTS |
LINE_LOOP |
gl_PointSize |
有用 | 没用 |
2.6 我踩的坑
- 数据按 4 分量存,pointer 按 2 分量读 → 形状像被洗衣机搅过
第 2 步过关标准
屏幕上出现红色闭合折线 → 你已经会「按索引组装图元」了。
第 3 步:让图形动起来(着色器变量版)
目标:画字母「F」,用按钮平移 / 旋转 / 缩放。
关键思想:顶点数据不动,只改 uniform,让 GPU 每帧重算位置。
3.1 先把 F 用三角形拼出来
F 可以拆成三块矩形,每块矩形 = 2 个三角形 = 6 个索引:
ts
// 顶点(像素坐标)
const f = [
0,0, 30,0, 0,150, 30,150, // 左竖
100,0, 30,30, 100,30, // 上横相关点
30,60, 67,60, 30,90, 67,90, // 中横
]
const indices = new Uint16Array([
0,1,2, 2,1,3, // 左竖两个三角
1,4,5, 5,4,6, // 上横
7,8,9, 9,8,10, // 中横
])
学习点:图元 TRIANGLES
每 3 个顶点(或 3 个索引)组成一个实心三角。
WebGL 没有「直接画矩形」的图元,矩形都是两个三角拼的。
3.2 在顶点着色器里手写变换
glsl
attribute vec2 a_position;
uniform mat4 u_matrix;
uniform vec2 u_rotation; // (sinθ, cosθ)
uniform vec2 u_translation;
uniform vec2 u_scale;
void main() {
// 1) 旋转
vec2 rotated = vec2(
a_position.x * u_rotation.y + a_position.y * u_rotation.x,
a_position.y * u_rotation.y - a_position.x * u_rotation.x
);
// 2) 缩放
vec2 scaled = rotated * u_scale;
// 3) 平移
vec2 world = u_translation + scaled;
gl_Position = u_matrix * vec4(world, 0.0, 1.0);
}
学习点:变换顺序很重要
这里是 先旋转 → 再缩放 → 最后平移 。
换顺序结果会不一样。以后用矩阵时,顺序问题会更明显。
学习点:为啥旋转传 sin/cos
三角函数在 GPU 里也能算,但每顶点都算一遍浪费。
JS 算一次 sin/cos,当 uniform 传进去,所有顶点共享------更划算。
3.3 JS 侧:状态 + 按钮 + 重绘
ts
const transform = {
angle: 0,
x: 0,
y: 0,
scaleX: 1,
scaleY: 1,
}
function draw() {
gl.uniform2f(uRotation, Math.sin(transform.angle), Math.cos(transform.angle))
gl.uniform2f(uScale, transform.scaleX, transform.scaleY)
gl.uniform2f(uTranslation, transform.x, transform.y)
gl.clear(gl.COLOR_BUFFER_BIT)
gl.drawElements(gl.TRIANGLES, indexCount, gl.UNSIGNED_SHORT, 0)
}
// 按钮只改状态,然后 draw
translateX.onclick = () => { transform.x += 20; draw() }
rotate.onclick = () => { transform.angle += Math.PI / 12; draw() }
scale.onclick = () => {
transform.scaleX *= 1.1
transform.scaleY *= 1.1
draw()
}
学习点:CPU 改参数,GPU 算位置
顶点数组始终是那个 F。动起来靠的是每帧改 uniform 再画一遍------这就是最原始的「动画」。
3.4 我踩的坑
draw 里一度写死:
ts
gl.uniform2f(uTranslation, 0, 0) // 💀
按钮狂点,F 纹丝不动。不是事件没绑上,是状态传到 GPU 的路上被我掐断了。
3.5 这一步的收获和局限
收获:理解了「变换可以发生在着色器里」。
局限:公式一多就崩(绕任意点转、父子节点......)。
所以下一步请矩阵出山。
第 3 步过关标准
点按钮,F 会平移/旋转/缩放 → GPU 侧变换直觉建立完成。
第 4 步:换矩阵来做变换
目标:还是 F,但用 模型矩阵 表达变换。
你会发现着色器突然变短,JS 里开始「叠 buff」。
4.1 为啥需要矩阵?
二维里一个点的变换,其实都能写成:
text
新位置 = 矩阵 × 旧位置
平移、旋转、缩放,各自对应一个矩阵。
组合变换 = 矩阵相乘。
引擎里乱七八糟的变换,最后都是在乘矩阵。
我们用 gl-matrix 的 mat4(4×4,兼容 3D,2D 也能用)。
4.2 着色器变得很干净
glsl
attribute vec2 a_position;
uniform mat4 u_matrix; // 投影矩阵
uniform mat4 u_model; // 模型矩阵
void main() {
gl_Position = u_matrix * u_model * vec4(a_position, 0.0, 1.0);
}
学习点:矩阵乘法顺序(从右往左作用)
text
gl_Position = 投影 × 模型 × 顶点
含义:先对顶点做模型变换(挪到世界里),再投影到裁剪空间。
写反了,画面会怪到你怀疑人生。
4.3 JS 里累乘模型矩阵
ts
const modelMat4 = mat4.create() // 单位矩阵起步
mat4.translate(modelMat4, modelMat4, [20, 0, 0])
mat4.rotateZ(modelMat4, modelMat4, angleInRad)
mat4.scale(modelMat4, modelMat4, [1.1, 1.1, 1])
gl.uniformMatrix4fv(modelLocation, false, modelMat4)
学习点:这些 API 都是「在现有矩阵上继续乘」
每次点击都在原矩阵上叠加,所以会越移越远、越转越多------符合「按钮累加」的直觉。
学习点:角度要用弧度
ts
const degToRad = (deg: number) => (deg * Math.PI) / 180
rotateZ 吃的是弧度,传 60 以为转 60°,其实转上天了。
4.4 绕中心旋转
默认旋转是绕原点 (0,0)。想绕 F 自己的中心转,套路是:
text
平移到中心 → 旋转 → 平移回去
ts
mat4.translate(model, model, [cx, cy, 0])
mat4.rotateZ(model, model, rad)
mat4.translate(model, model, [-cx, -cy, 0])
学习点:矩阵乘法不交换
A×B 不等于 B×A。顺序写反,F 可能原地转,也可能飞出屏幕去度假。
4.5 和第 3 步对比
| 着色器变量硬算 | 模型矩阵 | |
|---|---|---|
| 变换写哪 | GLSL 公式 | JS mat4 累乘 |
| 着色器 | 又长又具体 | 一行乘完 |
| 组合变换 | 越写越乱 | 继续乘就行 |
| 像不像引擎 | 演示向 | 更接近真实做法 |
第 4 步过关标准
会平移、旋转、缩放、绕中心转 → 你已经掌握 2D 模型变换的核心武器。
盘点:现在你手里有啥
- 点 / 线 / 三角形
- 顶点缓冲 + 索引缓冲
- attribute / uniform
- 投影矩阵 + 模型矩阵
- 清屏与多种图元
可以拼游戏了。
第 5 步:综合实战------贪吃蛇
目标:不追新 API,把前面积木拼成能玩的东西。
这一步重点是:同一套管线,怎么服务多个物体、怎么每帧更新、怎么处理游戏逻辑。
5.1 把需求映射回 WebGL
| 游戏需求 | WebGL 怎么做 |
|---|---|
| 网格地图 | LINES 画横竖线 |
| 蛇身 | 每节格子 → 两个三角形 → TRIANGLES |
| 食物 | 一个小方块 → TRIANGLE_FAN 也能画 |
| 不同颜色 | 画之前改 u_color |
| 持续动 | requestAnimationFrame 循环里 update + draw |
| 格子坐标 | 逻辑用格子,绘制时乘 cellSize 变像素 |
5.2 多物体 = 多次 draw(换缓冲 / 换颜色)
蛇、地图、食物顶点不同、颜色不同。新手最朴素的做法:
ts
// 画地图
bindBuffer(mapBuffer)
setColor([0.25, 0.25, 0.25, 1])
gl.drawArrays(gl.LINES, 0, mapVertexCount)
// 画蛇
bindBuffer(snakeBuffer)
setColor([0, 0, 1, 1])
gl.drawArrays(gl.TRIANGLES, 0, snakeVertexCount)
// 画食物
bindBuffer(foodBuffer)
setColor([1, 0.15, 0.15, 1])
gl.drawArrays(gl.TRIANGLE_FAN, 0, 4)
学习点:一次 draw 用的是「当前状态」
当前 program、当前 ARRAY_BUFFER 绑定、当前 attribute 指针、当前 uniform 值......
换东西画之前,把该绑的绑好、该改的改好。
学习点:TRIANGLE_FAN
从第一个顶点出发扇形展开。四个点刚好能围成一个方块,写食物很方便。
5.3 格子坐标 → 像素顶点
逻辑层蛇头在格子 (5, 3),cellSize=20,则像素矩形是:
ts
const x = 5 * 20
const y = 3 * 20
// 矩形四个角:
// (x,y) (x+20,y) (x+20,y+20) (x,y+20)
// 再拆成两个三角形的 6 个顶点
学习点:逻辑与渲染分离
- 逻辑:格子、方向、碰撞,全是整数
- 渲染:只负责把格子变成三角形顶点丢给 GPU
这样调试简单:游戏错了查逻辑,画面错了查顶点生成。
5.4 游戏循环与固定时间步
屏幕刷新率可能 60/120/144,但蛇不能因此跑速翻倍。做法:
ts
let last = 0
let acc = 0
const STEP = 150 // 毫秒走一格
function loop(now: number) {
const dt = now - last
last = now
acc += dt
while (acc >= STEP) {
update() // 只在这里改蛇的格子位置
acc -= STEP
}
const progress = acc / STEP // 0~1,用于插值
render(progress)
requestAnimationFrame(loop)
}
学习点:固定时间步
逻辑更新频率固定,画面怎么刷都不影响手感。
5.5 插值:让移动不那么「蹦」
蛇逻辑上是一格一格跳的,但渲染可以在两格之间插值:
ts
const x = (prev.x + (curr.x - prev.x) * progress) * cellSize
const y = (prev.y + (curr.y - prev.y) * progress) * cellSize
学习点:previousBody
每走一步前先备份身体,渲染时用「旧位置 → 新位置」插值,看起来就丝滑了。
5.6 碰撞、生长、输入(游戏规则,但影响你怎么画)
ts
// 下一格是食物 → 生长:头 unshift,尾巴不 pop
// 出界 / 撞自己 → Game Over,停掉 rAF
// 输入与当前方向相反 → 忽略(防止当场表演 U 转咬自己)
吃到食物后要 bufferData 更新蛇的顶点(更长了),食物也要重新随机并更新缓冲。
5.7 一帧里到底发生了啥(串起来)
text
requestAnimationFrame
→ 累积时间,必要时 update(改格子数据)
→ 根据格子 + progress 生成像素顶点
→ clear
→ 绑地图缓冲,设灰色,draw LINES
→ 绑蛇缓冲,设蓝色,draw TRIANGLES
→ 绑食物缓冲,设红色,draw TRIANGLE_FAN
回头看:全部都是第 1~4 步的零件,没有魔法。
展示一下成果

附录:踩坑清单
| 现象 | 人话原因 | 处理 |
|---|---|---|
| 整页黑屏 | 绘图缓冲还是 300×150,viewport 对不上 | 设 canvas.width/height 再 viewport |
| 画了看不见 | 忘了 u_color,透明黑画在黑底上 |
先 uniform4f(...,1,0,0,1) |
| buffer not big enough | 空缓冲就 draw,或 count 大于实际顶点 | 先 bufferData;count=顶点数 |
| 形状扭曲 | vertexAttribPointer 的 size/stride 和数据不一致 |
vec2 就写 size 2 |
| Y 轴反了 | 投影 Y 方向和点击换算不一致 | 统一「Y 向下」或统一换算 |
| 按钮点了没动 | uniform 写死了 / 改完没 draw | 用当前状态更新 uniform 再画 |
| 绕中心转飞了 | 矩阵乘顺序反了 | 先移到中心,再转,再移回 |
| 角度离谱 | 把角度当弧度传了 | deg * Math.PI / 180 |
调试三板斧
- 看控制台有没有 GL 报错(WebGL 很爱默默失败)
- 打印
canvas.width和canvas.clientWidth,不一样先别往下写 - 先画一个固定三角形证明管线通,再加点击、动画、游戏逻辑
五步串讲(合上书前回看)
text
第1步 点
学会:上下文、画布尺寸、着色器、Program、
attribute/uniform、投影、VBO、POINTS、clear/draw
第2步 线
学会:索引缓冲、drawElements、LINE_LOOP、NDC
第3步 着色器变换
学会:TRIANGLES 拼几何、用 uniform 做平移旋转缩放、
CPU 改状态 GPU 算位置
第4步 矩阵变换
学会:模型矩阵、矩阵乘法顺序、绕中心旋转、mat4 累乘
第5步 贪吃蛇
学会:多物体多次 draw、换颜色、格子到像素、
rAF、固定时间步、插值动画
一句话:
WebGL = 把数据塞进缓冲,让着色器算位置和颜色,然后按图元喊「画!」
矩阵只是让「算位置」少写点神仙公式。
最后强烈推荐一个开源的学习笔记[webglfundamentals.org/webgl/lesso...](https://link.juejin.cn?target=https%3A%2F%2Fwebglfundamentals.org%2Fwebgl%2Flessons%2Fzh_cn%2F "https://webglfundamentals.org/webgl/lessons/zh_cn/")