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.drawElements、uniformMatrix4fv、vertexShader、fragmentShader——如果这些词对你像天书一样,那当线上出现着色器编译报错、贴图颜色不对、模型出现条纹 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 之前,必须把脑子从”串行思维”切换到”并行思维”。
| 维度 | CPU | GPU |
|---|---|---|
| 核心数 | 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(模型-视图-投影),完整公式是:
但入门阶段我们用一个简单的时间动画演示 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,主要做两件事:
- 顶点数据加 z 坐标(模型空间 → 世界空间)
- 顶点着色器加投影矩阵(世界空间 → 裁剪空间)
- 画布绑定深度缓冲
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 光照模型把光照分成三部分:
| 成分 | 含义 | 公式 |
|---|---|---|
| 环境光 | 全局恒定亮度,模拟”间接光” | |
| 漫反射 | 表面均匀反射,亮度与法线和光线夹角有关 | |
| 高光 | 镜面反射,光泽表面才有的”亮点” |
我们给立方体每个顶点加法线(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 支持
texelFetch、inverse()、transpose()内建函数 - 3D 纹理 / 数组纹理 体积数据、体素渲染
- 多重渲染目标(MRT) 一次 draw 写多个缓冲(GBuffer 基础)
- Uniform Buffer Object(UBO) 大批量 uniform 一次更新
- 顶点数组对象(VAO) 一次绑定替代多次 attribute 切换
WebGPU 则是 W3C 正在推进的下一代标准:
| 维度 | WebGL 2 | WebGPU |
|---|---|---|
| 抽象层次 | 接近 GL, 命令式 | 接近 Vulkan/Metal/DX12 |
| 着色器语言 | GLSL | WGSL |
| 多线程 | 不支持 | 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次) │ │ 算颜色、光照、纹理采样 │ └────────────┬────────────┘ ↓ ┌─────────────────────────┐ │ 深度测试 → 帧缓冲 → 屏幕│ └─────────────────────────┘三条铁律:
- 顶点着色器决定形状,片元着色器决定颜色——前者的输出经过光栅化插值后,作为后者的输入。
- uniform 对所有顶点/片元一致,attribute/varying 逐顶点变化——这是 CPU 与 GPU 的”协议”。
- 优化片元、优化 draw call、优化带宽——这是 WebGL 性能的三板斧,优先级依次降低。
延伸阅读
- WebGL Fundamentals —— 最棒的入门系列,从零讲透每个 API
- LearnWebGL —— Brown 大学的图形学课程
- The Book of Shaders —— GLSL 图形编程入门
- WebGL2 Reference Card —— Khronos 官方速查
- Three.js Source Code —— 看
src/renderers/webgl目录里的 WebGLRenderer - Real-Time Rendering 4th —— 图形学圣经
- WebGPU Explainer —— WebGPU 标准入门
- GPU Gems —— NVIDIA 经典图形学论文集
掌握 WebGL 与 GLSL,你不再只是”调库的前端”——你能精准定位 Three.js 场景里每一个性能问题,看懂现代引擎的每一个 API,甚至自己写一个迷你 3D 引擎。这是通往图形学、AR/VR、WebXR 的起点,也是高级前端工程师的”硬通货”。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!