WebGL 与 GLSL 着色器入门:从画三角形到基础光照模型

4987 字
25 分钟
WebGL 与 GLSL 着色器入门:从画三角形到基础光照模型

前言:为什么前端工程师需要学 WebGL?#

打开 Three.js 文档,你会看到一行极简的代码:

const renderer = new THREE.WebGLRenderer();
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, w/h, 0.1, 1000);
const cube = new THREE.Mesh(new THREE.BoxGeometry(), new THREE.MeshNormalMaterial());
scene.add(cube);
renderer.render(scene, camera);

几行代码,一个会旋转的 3D 立方体就出现了。但你按下 F12,翻进源代码,看到的全是 gl.drawElementsuniformMatrix4fvvertexShaderfragmentShader——如果这些词对你像天书一样,那当线上出现着色器编译报错、贴图颜色不对、模型出现条纹 artifact 时,你只能跪求 Three.js 源码作者救火。

WebGL 是浏览器与 GPU 之间的唯一标准接口(WebGPU 还在普及中)。理解它,你就理解了:

  • 为什么一个看起来”差不多”的 shader,性能能差 10 倍
  • 为什么阴影会出现 acne(表面条纹)或 peter-panning(悬浮)
  • 为什么 alpha 混合顺序会决定整个画面正确与否
  • 为什么 Three.js 的 MeshStandardMaterial 有几十个 uniform

本文将不借助任何图形库,从最原始的 <canvas> + 原生 WebGL 出发,带你手写顶点着色器、片元着色器、缓冲区、纹理,最后实现一个完整的 Phong 光照模型。读完本文,你会发现 Three.js / Babylon.js 那些”魔法”API 背后,都是这套东西。

一、CPU vs GPU:理解并行思维#

学 WebGL 之前,必须把脑子从”串行思维”切换到”并行思维”。

维度CPUGPU
核心数4-16 核(强串行)数千个 ALU(弱串行,极强并行)
适合分支多、逻辑复杂、数据量小分支少、计算密集、数据量大
思维一步一步来每个数据走同一段代码
经典比喻教授解方程几千个小学生同时算 1+1

WebGL 的着色器(GLSL)运行在 GPU 上,每个顶点会执行一次顶点着色器,每个像素会执行一次片元着色器。你要写的不是”给一个三角形画颜色”,而是”给任何一个顶点/像素,定义它的变换规则”。

WebGL 渲染管线简化如下:

CPU 端 GPU 端
┌──────────┐ 顶点数据 ┌──────────────────┐
│ JS 准备 │ ──── 传输 ──→ │ 顶点着色器 │
│ 缓冲区 │ (VBO) │ (vertex shader) │
│ 纹理 │ └────────┬─────────┘
│ uniform │ ↓
└──────────┘ ┌──────────────────┐
│ 图元装配(三角形) │
└────────┬─────────┘
┌──────────────────┐
│ 光栅化(转像素) │
└────────┬─────────┘
┌──────────────────┐
│ 片元着色器 │
│ (fragment shader)│
└────────┬─────────┘
┌──────────────────┐
│ 深度/模板测试 │
│ 混合 → 帧缓冲 │
└──────────────────┘

整条流水线中,只有顶点和片元着色器是可编程的(就是你要写 GLSL 的地方)。其他阶段是固定功能,只能配置开关。

二、最小可运行示例:画一个红色三角形#

先来一段”Hello World”级别的 WebGL——不用任何框架,纯 HTML+JS,画一个红色三角形。

<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>WebGL 三角形</title></head>
<body>
<canvas id="c" width="400" height="400"></canvas>
<script>
const canvas = document.getElementById('c');
const gl = canvas.getContext('webgl');
if (!gl) { alert('浏览器不支持 WebGL'); throw new Error('no webgl'); }
// 1. 编写顶点着色器
const vsSource = `
attribute vec2 a_position; // 顶点位置
void main() {
gl_Position = vec4(a_position, 0.0, 1.0);
}
`;
// 2. 编写片元着色器
const fsSource = `
precision mediump float;
void main() {
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0); // 红色
}
`;
// 3. 编译着色器
function compile(type, source) {
const sh = gl.createShader(type);
gl.shaderSource(sh, source);
gl.compileShader(sh);
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
console.error('编译失败:', gl.getShaderInfoLog(sh));
gl.deleteShader(sh);
return null;
}
return sh;
}
const vs = compile(gl.VERTEX_SHADER, vsSource);
const fs = compile(gl.FRAGMENT_SHADER, fsSource);
// 4. 链接着色器程序
const program = gl.createProgram();
gl.attachShader(program, vs);
gl.attachShader(program, fs);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error('链接失败:', gl.getProgramInfoLog(program));
}
gl.useProgram(program);
// 5. 把顶点数据塞进 GPU 缓冲区
const positions = new Float32Array([
0.0, 0.5, // 顶点 0: 上
-0.5, -0.5, // 顶点 1: 左下
0.5, -0.5, // 顶点 2: 右下
]);
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW);
// 6. 把 attribute 变量绑定到缓冲区
const aPosition = gl.getAttribLocation(program, 'a_position');
gl.enableVertexAttribArray(aPosition);
gl.vertexAttribPointer(aPosition, 2, gl.FLOAT, false, 0, 0);
// 7. 绘制
gl.viewport(0, 0, 400, 400);
gl.clearColor(0.0, 0.0, 0.0, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawArrays(gl.TRIANGLES, 0, 3);
</script>
</body>
</html>

把这段代码保存成 .html 双击打开,你就会看到一个红色三角形。注意顶点坐标是裁剪空间坐标,范围 [-1, 1],原点是屏幕中心。

短短 50 行,我们亲手走完了”传顶点 → GPU 跑顶点着色器 → 装配三角形 → 插值 → 跑片元着色器 → 写到屏幕”的全部流程。后面所有内容,都是这套骨架的扩展。

三、顶点着色器入门:让三角形动起来#

顶点着色器的职责:把每个顶点从模型空间变换到裁剪空间。最常见的变换是 MVP(模型-视图-投影),完整公式是:

gl_Position=Projection×View×Model×vec4(position,1.0)gl\_Position = Projection \times View \times Model \times vec4(position, 1.0)

但入门阶段我们用一个简单的时间动画演示 uniform 的用法:

// 顶点着色器
attribute vec2 a_position;
uniform float u_time; // CPU 端传进来的时间
uniform vec2 u_resolution; // 画布分辨率
void main() {
// 简单的左右摆动
float wave = sin(a_position.x * 4.0 + u_time) * 0.1;
gl_Position = vec4(a_position.x, a_position.y + wave, 0.0, 1.0);
}

CPU 端用 requestAnimationFrame 持续更新时间:

const uTime = gl.getUniformLocation(program, 'u_time');
function render(time) {
gl.uniform1f(uTime, time * 0.001);
gl.drawArrays(gl.TRIANGLES, 0, 3);
requestAnimationFrame(render);
}
render(0);

你会看到三角形在原地”扭动”。这个例子展示了 uniform 的核心特性:对一次 drawArrays 内的所有顶点都是同一个值;但每次 drawArrays 之间可以不同。

四、片元着色器入门:上色、纹理、渐变#

片元着色器决定每个像素的最终颜色。它的输入是经过顶点着色器输出插值后的 varying 变量。

4.1 渐变三角形#

// 顶点着色器
attribute vec2 a_position;
varying vec2 v_uv; // varying: 顶点输出,片元接收(自动插值)
void main() {
v_uv = a_position; // 把顶点坐标当作 UV 传过去
gl_Position = vec4(a_position, 0.0, 1.0);
}
// 片元着色器
precision mediump float;
varying vec2 v_uv;
void main() {
// v_uv 在三角形三个顶点处分别是 (0,0.5)、(-0.5,-0.5)、(0.5,-0.5)
// 内部点的 v_uv 是这三个值的重心插值
vec3 color = vec3(
v_uv.x + 0.5, // 红:水平渐变
v_uv.y + 0.5, // 绿:垂直渐变
0.5 // 蓝:常量
);
gl_FragColor = vec4(color, 1.0);
}

你会看到一个从黑到蓝、再到绿/红的渐变三角形。这就是 GPU 重心插值的可视化——varying 变量在三角形内部是线性插值的,这是片元着色器能产生渐变效果的根本原因。

4.2 贴纹理:从图像到片元#

纹理(texture)是 GPU 端的”图像”,通过 gl.texImage2D 上传,通过 sampler2D 在片元着色器里采样。

// CPU 端:加载图片并上传成纹理
const image = new Image();
image.src = 'cat.png';
image.onload = () => {
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
// 必须设置这两个,否则非 2^n 大小的图会失败
gl.generateMipmap(gl.TEXTURE_2D);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
};
// 片元着色器
precision mediump float;
uniform sampler2D u_texture;
varying vec2 v_uv;
void main() {
gl_FragColor = texture2D(u_texture, v_uv);
}

UV 坐标的 y 轴是向上的(0 在底部),而图像像素的 y 轴是向下的(0 在顶部)。这是新手最常踩的坑——纹理上下颠倒。常见解法:

// 上传时翻转
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);

4.3 多纹理混合:实现一个带光斑的木地板#

precision mediump float;
uniform sampler2D u_wood; // 木纹
uniform sampler2D u_light; // 圆形光斑(alpha 贴图)
varying vec2 v_uv;
void main() {
vec3 base = texture2D(u_wood, v_uv).rgb;
vec4 spot = texture2D(u_light, v_uv);
// 用光斑的 alpha 作为混合系数
gl_FragColor = vec4(base * (1.0 - spot.a) + vec3(1.0, 0.9, 0.7) * spot.a, 1.0);
}

五、索引缓冲:用 EBO 减少重复顶点#

画一个正方形需要 2 个三角形、6 个顶点,但其实只有 4 个不同的角。索引缓冲(EBO) 让多个三角形共享顶点。

// 4 个顶点,2 个三角形用索引拼出来
const positions = new Float32Array([
-0.5, 0.5, // 0: 左上
0.5, 0.5, // 1: 右上
-0.5, -0.5, // 2: 左下
0.5, -0.5, // 3: 右下
]);
const indices = new Uint16Array([
0, 1, 2, // 第一个三角形
1, 3, 2, // 第二个三角形
]);
// 上传位置缓冲(同上)
// 上传索引缓冲
const ebo = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ebo);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW);
// 用索引绘制
gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0);

性能提升非常显著:画一个 100×100 的网格(渲染 19802 个三角形),直接 drawArrays 需要 59406 个顶点,索引只需要 10201 个顶点,显存和数据传输都降到 1/6。

六、模型变换:让正方形”动起来”#

WebGL 没有”对象”概念,所有变换都在顶点着色器里用矩阵完成。

attribute vec2 a_position;
uniform mat3 u_matrix;
void main() {
vec3 pos = u_matrix * vec3(a_position, 1.0);
gl_Position = vec4(pos.xy, 0.0, 1.0);
}

u_matrix 是个 3x3 矩阵(2D 用,3D 用 4x4)。我们来手写一个组合矩阵:旋转 + 平移 + 缩放。

// 2D 仿射变换矩阵的组合(列主序,与 GLSL 兼容)
function makeMatrix(translation, rotation, scale) {
const [tx, ty] = translation;
const cos = Math.cos(rotation), sin = Math.sin(rotation);
const [sx, sy] = scale;
// GLSL 是列主序,这里直接写列向量
return new Float32Array([
sx * cos, sin, 0,
-sy * sin, cos, 0,
tx, ty, 1,
]);
}
const uMatrix = gl.getUniformLocation(program, 'u_matrix');
function render(t) {
const angle = t * 0.001;
const m = makeMatrix([Math.sin(t*0.001)*0.3, 0], angle, [1, 1]);
gl.uniformMatrix3fv(uMatrix, false, m);
gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0);
requestAnimationFrame(render);
}
render(0);

矩阵乘法顺序很重要。在 GLSL 里 A * B * v 表示”先施加 B 变换,再施加 A 变换”。所以完整的 MVP 链写法是 Projection * View * Model * position,与变换施加顺序相反。

七、3D 入门:立方体与透视#

从 2D 跳到 3D,主要做两件事:

  1. 顶点数据加 z 坐标(模型空间 → 世界空间)
  2. 顶点着色器加投影矩阵(世界空间 → 裁剪空间)
  3. 画布绑定深度缓冲

7.1 立方体的 8 个顶点#

// 立方体的 8 个角,坐标范围 [-0.5, 0.5]
const cube = new Float32Array([
// 位置 // 颜色
-0.5, -0.5, -0.5, 1, 0, 0, // 0 红
0.5, -0.5, -0.5, 0, 1, 0, // 1 绿
0.5, 0.5, -0.5, 0, 0, 1, // 2 蓝
-0.5, 0.5, -0.5, 1, 1, 0, // 3 黄
-0.5, -0.5, 0.5, 1, 0, 1, // 4 品红
0.5, -0.5, 0.5, 0, 1, 1, // 5 青
0.5, 0.5, 0.5, 1, 1, 1, // 6 白
-0.5, 0.5, 0.5, 0, 0, 0, // 7 黑
]);
// 6 个面 × 2 个三角形 = 12 个三角形
// 每个三角形 3 个顶点,顶点之间不共享(因为我们用法线 + 颜色)
// 完整 36 个索引
const indices = new Uint8Array([
// 后面
0, 1, 2, 0, 2, 3,
// 前面
4, 6, 5, 4, 7, 6,
// 左面
0, 3, 7, 0, 7, 4,
// 右面
1, 5, 6, 1, 6, 2,
// 底面
0, 4, 5, 0, 5, 1,
// 顶面
3, 2, 6, 3, 6, 7,
]);

7.2 透视投影矩阵#

最常用的透视投影:

function perspective(fovY, aspect, near, far) {
const f = 1.0 / Math.tan(fovY / 2);
const nf = 1 / (near - far);
return new Float32Array([
f / aspect, 0, 0, 0,
0, f, 0, 0,
0, 0, (far + near) * nf, -1,
0, 0, 2 * far * near * nf, 0,
]);
}

7.3 启用深度测试#

// 创建 WebGL 时显式请求深度缓冲
const gl = canvas.getContext('webgl', { depth: true });
// 渲染时启用
gl.enable(gl.DEPTH_TEST);
gl.depthFunc(gl.LESS); // 默认就是这个
// 每帧渲染前清空深度
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

不开启深度测试,你会看到立方体后面的面盖在前面,整个一团糟。

八、光照模型 反射#

终于到正题了。Phong 光照模型把光照分成三部分:

I=Iambient+Idiffuse+IspecularI = I_{ambient} + I_{diffuse} + I_{specular}
成分含义公式
环境光全局恒定亮度,模拟”间接光”Ka×IaK_a \times I_a
漫反射表面均匀反射,亮度与法线和光线夹角有关Kd×Id×(NL)K_d \times I_d \times (N \cdot L)
高光镜面反射,光泽表面才有的”亮点”Ks×Is×(RV)αK_s \times I_s \times (R \cdot V)^\alpha

我们给立方体每个顶点加法线(normal)数据,然后在片元着色器里逐像素计算光照。

8.1 数据准备#

立方体有 6 个面,每个面法线不同。一个面 4 个顶点 × 6 个面 = 24 个顶点(不共享,因为法线不同)。

// 立方体的 24 个顶点(4 个 × 6 面),每个顶点带位置和法线
function makeCubeWithNormals() {
const faces = [
{ normal: [ 0, 0, -1], verts: [[-0.5,-0.5,-0.5],[ 0.5,-0.5,-0.5],[ 0.5, 0.5,-0.5],[-0.5, 0.5,-0.5]] },
{ normal: [ 0, 0, 1], verts: [[-0.5,-0.5, 0.5],[ 0.5,-0.5, 0.5],[ 0.5, 0.5, 0.5],[-0.5, 0.5, 0.5]] },
{ normal: [-1, 0, 0], verts: [[-0.5,-0.5,-0.5],[-0.5, 0.5,-0.5],[-0.5, 0.5, 0.5],[-0.5,-0.5, 0.5]] },
{ normal: [ 1, 0, 0], verts: [[ 0.5,-0.5,-0.5],[ 0.5, 0.5,-0.5],[ 0.5, 0.5, 0.5],[ 0.5,-0.5, 0.5]] },
{ normal: [ 0, -1, 0], verts: [[-0.5,-0.5,-0.5],[ 0.5,-0.5,-0.5],[ 0.5,-0.5, 0.5],[-0.5,-0.5, 0.5]] },
{ normal: [ 0, 1, 0], verts: [[-0.5, 0.5,-0.5],[ 0.5, 0.5,-0.5],[ 0.5, 0.5, 0.5],[-0.5, 0.5, 0.5]] },
];
const positions = [];
const normals = [];
for (const f of faces) {
for (const v of f.verts) {
positions.push(...v);
normals.push(...f.normal);
}
}
// 6 面 × 2 三角形 = 12 三角形 × 3 顶点 = 36 索引
// 顶点按 0,1,2,3, 4,5,6,7, ... 排列,每个面用 4 个
const indices = [];
for (let f = 0; f < 6; f++) {
const o = f * 4;
indices.push(o, o+1, o+2, o, o+2, o+3);
}
return {
positions: new Float32Array(positions),
normals: new Float32Array(normals),
indices: new Uint16Array(indices),
};
}

8.2 顶点着色器:变换法线#

法线在物体空间,要先经过模型矩阵的逆转置矩阵变换到世界空间(因为模型可能有非均匀缩放):

attribute vec3 a_position;
attribute vec3 a_normal;
uniform mat4 u_model;
uniform mat4 u_view;
uniform mat4 u_projection;
uniform mat4 u_normalMatrix; // = transpose(inverse(model)) 的 4x4
varying vec3 v_worldNormal;
varying vec3 v_worldPos;
void main() {
vec4 worldPos = u_model * vec4(a_position, 1.0);
v_worldPos = worldPos.xyz;
// 法线变换:用逆转置
v_worldNormal = normalize((u_normalMatrix * vec4(a_normal, 0.0)).xyz);
gl_Position = u_projection * u_view * worldPos;
}

注意 vec4(a_normal, 0.0),w=0 表示这是方向而不是点(平移不作用)。

8.3 片元着色器 公式#

precision mediump float;
varying vec3 v_worldNormal;
varying vec3 v_worldPos;
uniform vec3 u_lightPos; // 光源位置
uniform vec3 u_lightColor; // 光源颜色
uniform vec3 u_ambientColor; // 环境光颜色
uniform vec3 u_cameraPos; // 相机位置(用于镜面反射)
uniform float u_shininess; // 高光指数(越大越集中)
void main() {
// 1. 准备向量
vec3 N = normalize(v_worldNormal);
vec3 L = normalize(u_lightPos - v_worldPos);
vec3 V = normalize(u_cameraPos - v_worldPos);
vec3 R = reflect(-L, N); // 反射方向:光线打过来再反射
// 2. 环境光
vec3 ambient = u_ambientColor;
// 3. 漫反射
float diff = max(dot(N, L), 0.0); // 夹角余弦,负数取 0
vec3 diffuse = u_lightColor * diff;
// 4. 高光
float spec = pow(max(dot(R, V), 0.0), u_shininess);
vec3 specular = u_lightColor * spec;
// 5. 叠加
vec3 color = ambient + diffuse * 0.8 + specular * 0.5;
gl_FragColor = vec4(color, 1.0);
}

8.4 CPU 端组装#

const cube = makeCubeWithNormals();
// 创建位置缓冲
const posBuf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, posBuf);
gl.bufferData(gl.ARRAY_BUFFER, cube.positions, gl.STATIC_DRAW);
const aPos = gl.getAttribLocation(program, 'a_position');
gl.vertexAttribPointer(aPos, 3, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(aPos);
// 创建法线缓冲
const normBuf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, normBuf);
gl.bufferData(gl.ARRAY_BUFFER, cube.normals, gl.STATIC_DRAW);
const aNorm = gl.getAttribLocation(program, 'a_normal');
gl.vertexAttribPointer(aNorm, 3, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(aNorm);
// 创建索引缓冲
const idxBuf = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, idxBuf);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, cube.indices, gl.STATIC_DRAW);
// uniform 位置
const uModel = gl.getUniformLocation(program, 'u_model');
const uView = gl.getUniformLocation(program, 'u_view');
const uProj = gl.getUniformLocation(program, 'u_projection');
const uNormalMat = gl.getUniformLocation(program, 'u_normalMatrix');
const uLightPos = gl.getUniformLocation(program, 'u_lightPos');
const uLightColor = gl.getUniformLocation(program, 'u_lightColor');
const uAmbientColor = gl.getUniformLocation(program, 'u_ambientColor');
const uCameraPos = gl.getUniformLocation(program, 'u_cameraPos');
const uShininess = gl.getUniformLocation(program, 'u_shininess');
// 矩阵工具
function mat4Identity() { return new Float32Array([1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1]); }
function mat4Multiply(a, b) { /* 标准矩阵乘法实现 */ }
function mat4Inverse(m) { /* 4x4 矩阵求逆 */ }
function mat4Transpose(m) { /* 转置 */ }
function mat4Translate(t) { /* 平移矩阵 */ }
function mat4RotateY(angle) { /* 绕 Y 轴旋转 */ }
function mat4Perspective(fov, aspect, near, far) { /* 前面写过 */ }
function mat4LookAt(eye, target, up) { /* 视图矩阵 */ }
const proj = mat4Perspective(Math.PI/4, canvas.width/canvas.height, 0.1, 100);
const view = mat4LookAt([0, 1, 3], [0, 0, 0], [0, 1, 0]);
const model = mat4Identity();
gl.uniformMatrix4fv(uProj, false, proj);
gl.uniformMatrix4fv(uView, false, view);
gl.uniformMatrix4fv(uModel, false, model);
gl.uniformMatrix4fv(uNormalMat, false, mat4Transpose(mat4Inverse(model)));
gl.uniform3f(uLightPos, 2, 3, 2);
gl.uniform3f(uLightColor, 1.0, 1.0, 0.9);
gl.uniform3f(uAmbientColor, 0.15, 0.15, 0.2);
gl.uniform3f(uCameraPos, 0, 1, 3);
gl.uniform1f(uShininess, 32.0);
gl.enable(gl.DEPTH_TEST);
gl.clearColor(0.0, 0.0, 0.0, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.drawElements(gl.TRIANGLES, 36, gl.UNSIGNED_SHORT, 0);

完成!打开浏览器,你会看到一个带光斑的旋转立方体——亮面、阴影、高光俱全。

九、性能优化:从 60FPS 到 16ms#

优化点原因做法
减少 draw call每帧上千次 drawArrays 拖慢 CPU合并几何体 / 用 Instanced 绘制
避免每帧 uniform 切换uniform 设置有开销排序绘制顺序
减少片元运算高分辨率屏幕每个像素都跑片元简化 shader / 减小 viewport
用 halfp / lowp精度影响寄存器颜色用 lowp,法线用 mediump
启用 mipmap远距离贴图采样更高效generateMipmap + 合适过滤
减少 overdraw透明物体反复叠加排序,Z-pass 渲染不透明
用 FBO 离屏渲染多次滤镜后处理渲染到纹理 → 再采样

片元着色器跑 1920×1080 = 207 万次,一次 pow 调用的成本,会被乘以 200 万。优化片元着色器是 WebGL 性能的第一战场

十、常见坑:从入门到放弃再到入门#

10.1 我的三角形是黑的 / 颜色不对#

  • 片元着色器忘记写 precision mediump float;
  • attribute/uniform 名字拼错,用 getError() 调试
  • 顶点位置范围超出了 [-1, 1],被裁剪

10.2 立方体是透明 / 看到后面的面#

  • 没开 gl.DEPTH_TEST
  • 没清 DEPTH_BUFFER_BIT
  • 透明物体和不透明物体混在一起没排序

10.3 贴图加载后是黑色#

  • WebGL 是异步的,贴图上传必须在 image.onload
  • 跨域图片需要 crossOrigin = 'anonymous'
  • 非 2^n 尺寸但没设置 gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL

10.4 shader 编译失败但报”OK”#

  • GLSL ES 2.0 不支持 C++ 风格的 for 循环条件?其实支持
  • 忘了 precision
  • 用了 texture2D 在 WebGL 2 里要换成 texture

调试技巧:

// 错误检查宏
function checkGLError(gl, label) {
const err = gl.getError();
if (err !== gl.NO_ERROR) console.error(`[${label}] GL error: 0x${err.toString(16)}`);
}

十一、实战案例:写一个可交互的旋转立方体#

把所有知识串起来,做一个完整的”鼠标拖拽旋转立方体”:

let rotX = 0, rotY = 0;
let dragging = false, lastX = 0, lastY = 0;
canvas.addEventListener('mousedown', e => {
dragging = true; lastX = e.clientX; lastY = e.clientY;
});
window.addEventListener('mouseup', () => dragging = false);
window.addEventListener('mousemove', e => {
if (!dragging) return;
rotY += (e.clientX - lastX) * 0.01;
rotX += (e.clientY - lastY) * 0.01;
lastX = e.clientX; lastY = e.clientY;
});
function render(t) {
const rx = mat4RotateX(rotX);
const ry = mat4RotateY(rotY);
const model = mat4Multiply(ry, rx);
gl.uniformMatrix4fv(uModel, false, model);
gl.uniformMatrix4fv(uNormalMat, false, mat4Transpose(mat4Inverse(model)));
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.drawElements(gl.TRIANGLES, 36, gl.UNSIGNED_SHORT, 0);
requestAnimationFrame(render);
}
render(0);

这是 Three.js 内部 OrbitControls 的极简原型——当你后面用 Three.js 拖拽控制相机时,别忘了它底层就是这一套矩阵乘法。

十二、走向现代 2 与 WebGPU#

WebGL 2.0 在 2017 年就成为主流浏览器标配,主要提升:

  • GLSL ES 3.00 支持 texelFetchinverse()transpose() 内建函数
  • 3D 纹理 / 数组纹理 体积数据、体素渲染
  • 多重渲染目标(MRT) 一次 draw 写多个缓冲(GBuffer 基础)
  • Uniform Buffer Object(UBO) 大批量 uniform 一次更新
  • 顶点数组对象(VAO) 一次绑定替代多次 attribute 切换

WebGPU 则是 W3C 正在推进的下一代标准:

维度WebGL 2WebGPU
抽象层次接近 GL, 命令式接近 Vulkan/Metal/DX12
着色器语言GLSLWGSL
多线程不支持compute shader
性能调优靠经验显式控制管线状态
浏览器支持100%Chrome/Edge/Safari 部分支持

Three.js 已经同时支持 WebGL 和 WebGPU 后端,同一个场景切换后端基本零代码改动。

总结:一张图看懂 WebGL 核心模型#

JS 端 GPU 端
┌────────────┐ ┌──────────────────────┐
│ 准备数据 │ gl.bufferData │ VBO / EBO / Texture │
│ - 顶点 │ ──────────────→ │ - 顶点位置/法线/UV │
│ - 索引 │ │ - 纹理图像 │
│ - 纹理 │ gl.texImage2D │ │
└────────────┘ └──────────┬───────────┘
┌────────────┐ gl.uniformXX ┌──────────↓───────────┐
│ 设置变量 │ ──────────────→ │ uniform 寄存器 │
│ - MVP 矩阵 │ │ - 矩阵/向量/纹理 │
│ - 光源 │ │ - 时间/分辨率 │
└────────────┘ └──────────┬───────────┘
┌────────────↓────────────┐
│ 顶点着色器(每顶点1次) │
│ 变换位置,法线,UV │
└────────────┬────────────┘
↓ 装配+插值
┌─────────────────────────┐
│ 片元着色器(每像素1次) │
│ 算颜色、光照、纹理采样 │
└────────────┬────────────┘
┌─────────────────────────┐
│ 深度测试 → 帧缓冲 → 屏幕│
└─────────────────────────┘

三条铁律:

  1. 顶点着色器决定形状,片元着色器决定颜色——前者的输出经过光栅化插值后,作为后者的输入。
  2. uniform 对所有顶点/片元一致,attribute/varying 逐顶点变化——这是 CPU 与 GPU 的”协议”。
  3. 优化片元、优化 draw call、优化带宽——这是 WebGL 性能的三板斧,优先级依次降低。

延伸阅读#

掌握 WebGL 与 GLSL,你不再只是”调库的前端”——你能精准定位 Three.js 场景里每一个性能问题,看懂现代引擎的每一个 API,甚至自己写一个迷你 3D 引擎。这是通往图形学、AR/VR、WebXR 的起点,也是高级前端工程师的”硬通货”

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

WebGL 与 GLSL 着色器入门:从画三角形到基础光照模型
https://boke.hackerdream.xyz/posts/frontend-webgl-shader/
作者
晴天
发布于
2026-06-15
许可协议
CC BY-NC-SA 4.0
相关文章 智能推荐
1
Service Worker 深度解析:拦截请求、离线缓存与推送通知
前端高级 深入剖析 Service Worker 的运行模型、缓存策略与推送通知,从 fetch 拦截到 Background Sync,手把手实现一个生产级离线方案。
2
WebAssembly 与 Rust 前端实战:将计算密集型任务提速 10 倍
前端高级 从 wasm-bindgen 工具链到 SIMD、Threads、wasm-pack 构建优化,逐层拆解 Rust + Wasm 的性能红利,附带图像卷积、JSON 解析、加密散列三个真实性能基准。
3
AI 函数调用与 MCP 协议深度解析:从 OpenAI tools 到 Model Context Protocol
AI 系统拆解 AI 函数调用(Function Calling/Tool Use)与 Model Context Protocol(MCP)的来龙去脉。从 OpenAI tools 协议栈、Claude Tool Use、Gemini Function Calling 的差异,到 MCP 协议的 JSON-RPC 通信、Resources/Prompts/Tools/Sampling 四大原语与 stdio/SSE/HTTP 三种传输,再到自建 MCP Server 接入 Claude Desktop 与 Claude Agent SDK 的全链路实战,附 6 个完整可运行示例。
4
LLM 结构化输出实战:JSON Schema、函数调用与 Zod 校验全链路
AI 深入剖析 LLM 结构化输出的三种主流方案——JSON Mode、Function Calling/Tool Use 与 JSON Schema 约束式生成。从原理、可靠性、Token 成本到前端解析与 Zod 运行时校验,搭配 8 个完整可运行示例,帮你把 AI 的'自由发挥'装进确定的格子里。
5
Web Worker 与 SharedArrayBuffer:主线程阻塞的终极解法
浏览器与性能 当主线程遇到 10 万行数据、密码学运算、实时音视频解码时,Dedicated Worker 已经救不了你——你需要 SharedArrayBuffer + Atomics + Comlink 三件套。本文从痛点出发,拆解跨 Worker 共享内存的底层机制、COOP/COEP 跨域隔离配置,并给出可立即复用的图像处理实战项目。
随机文章 随机推荐
Profile Image of the Author
晴天
Hello, I'm 晴天.
公告
欢迎来到我的博客!这是一则示例公告。
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
155
分类
24
标签
387
总字数
345,424
运行时长
0
最后活动
0 天前

目录