Web特效008—一个 getContext('webgl'),浏览器到底交出了什么?
上一节我们已经知道,Canvas 2D、WebGL 和 WebGPU 都可以借助同一个 <canvas> 元素显示图形,但它们从画布中拿到的“绘图工具”并不是一回事。当代码写下 canvas.getContext("webgl") 时,浏览器到底交给了 JavaScript 什么?它是一个真正的 GPU 对象,还是一套负责转发命令的接口?它和前面熟悉的 Canvas 2D Context 有什么区别,又为什么一旦选定 WebGL,就不能随意切换回 2D?本文将从这行代码开始,拆开 WebGL Context 的真实身份:它如何连接 Canvas、浏览器和底层图形设备,为什么内部会保存大量绘制状态,以及 Buffer、Texture、Shader 这些资源为什么都要通过它来创建、绑定和管理。我们还会跟着一次 drawArrays() 调用,看看 JavaScript 如何通过 Context 把绘制命令送到 GPU,最终让一组顶点变成屏幕上的像素。
一、从一行 getContext('webgl') 开始:你打开的不是一张普通画布
1. <canvas> 本身只是一个“画面容器”
写 WebGL 时,代码通常从这一行开始:
const canvas = document.querySelector("canvas");
const gl = canvas.getContext("webgl");
很多人会下意识认为:canvas 就是 WebGL,调用 getContext("webgl") 只是把画布打开。其实这两个概念需要分开看。
<canvas> 首先只是 HTML 页面里的一个元素,和 <div>、<img> 一样,拥有宽高、CSS 样式和在页面中的位置。它负责给浏览器提供一块可以显示绘制结果的区域,但它本身不会画圆、不会画三角形,也不知道该如何调用 GPU。
<canvas id="stage" width="800" height="500"></canvas>
这行 HTML 只是在页面中放了一块 800 × 500 的画布区域。没有 Context 时,这块区域只是一个尚未选定绘制方式的容器:你可以让它使用 Canvas 2D,也可以让它使用 WebGL,还可以使用 WebGL 2。
| 名称 | 它是什么 | 主要职责 |
|---|---|---|
<canvas> | HTML 元素 | 承载并显示最终画面 |
| Canvas 2D Context | 二维绘制接口 | 提供路径、文字、图片等高层 API |
| WebGL Context | 图形渲染接口 | 提供与 GPU 渲染相关的控制命令 |
| GPU | 硬件与驱动体系 | 执行底层图形计算与渲染任务 |
canvas是舞台;Context 是控制台;GPU 才是实际参与图形计算的硬件。
2. getContext('webgl') 拿到的是一套 WebGL 控制接口
当执行 canvas.getContext("webgl") 时,你并没有直接拿到显卡,也没有拿到一张新的画布。浏览器会尝试为这块 Canvas 建立 WebGL 绘制环境,并把一个 JavaScript 对象返回给你。
const gl = canvas.getContext("webgl");
if (!gl) {
console.error("当前浏览器或设备无法创建 WebGL Context");
}
这个 gl 通常是一个 WebGLRenderingContext 对象。它可以理解为浏览器提供的一套渲染控制台:你通过它创建缓冲区、上传顶点数据、编译着色器、配置颜色和状态,然后向底层图形系统提交绘制命令。
gl.clearColor(0.1, 0.6, 0.7, 1.0); // 设置清屏颜色
gl.clear(gl.COLOR_BUFFER_BIT); // 清空颜色缓冲区
这里的 gl 不是一个图形对象,也不像 Canvas 2D 的 ctx 那样直接提供 arc()、fillRect() 这类画笔式方法。它更像一套需要按顺序操作的控制命令:每个方法都在配置或驱动 WebGL 的渲染过程。
你可以把两种 Context 的使用方式粗略对比成这样:
// Canvas 2D:直接描述要画什么
ctx.fillStyle = "#1b9b9c";
ctx.fillRect(40, 40, 160, 100);
// WebGL:配置绘制状态,再向渲染系统发命令
gl.clearColor(0.1, 0.6, 0.7, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);
Canvas 2D 更像“请画一个青色矩形”;WebGL 更像“把颜色缓冲区按这个 RGBA 颜色清除”。两者最终都会影响 Canvas 上的像素,但表达方式处在不同层级。
3. 一块 Canvas 只能选定一种 Context
一块 Canvas 在第一次成功获取 Context 后,绘制类型就基本确定了。比如下面的代码:
const canvas = document.querySelector("canvas");
const gl = canvas.getContext("webgl");
const ctx = canvas.getContext("2d");
console.log(gl); // WebGLRenderingContext
console.log(ctx); // null
因为这块 Canvas 已经被用于 WebGL,浏览器不会再把同一个画布同时交给 Canvas 2D 使用。反过来,如果先成功获取了 2d Context,再请求 webgl,通常也会得到 null。
但如果重复请求同一种 Context,得到的不是一套完全独立的新环境,而是同一块 Canvas 已经建立好的 WebGL Context:
const gl1 = canvas.getContext("webgl");
const gl2 = canvas.getContext("webgl");
console.log(gl1 === gl2); // true
这很好理解:WebGL Context 内部保存着大量持续存在的状态,例如当前绑定的缓冲区、当前使用的着色器、视口大小、混合模式和纹理数据。如果每次调用 getContext() 都创建一个新的渲染环境,前面配置的状态都会被打散,绘制过程也无法连续进行。
flowchart TD
A[HTML Canvas 元素] --> B{第一次请求哪种 Context?}
B -- 2d --> C[Canvas 2D Context]
B -- webgl --> D[WebGLRenderingContext]
B -- webgl2 --> E[WebGL2RenderingContext]
D --> F[同一 Canvas 后续再请求 2d:通常返回 null]
所以,getContext("webgl") 的真正含义不是“从 Canvas 中取出一个工具”,而是为这块 Canvas 选定 WebGL 这套绘制体系,并获取控制它的入口。下一节我们继续追问:这个入口为什么叫 Context,它到底替浏览器和 GPU 保存了哪些渲染环境与状态?
二、WebGL Context 到底是什么:浏览器交给 JavaScript 的“渲染控制台”
1. Context 不是某个图形对象,而是一套持续存在的绘制环境
上一节我们把 <canvas> 理解成舞台,把 getContext("webgl") 理解成打开 WebGL 绘制体系的入口。现在问题来了:返回的这个 gl 到底是什么?为什么后面的 WebGL 操作几乎都要通过它完成?
const canvas = document.querySelector("canvas");
const gl = canvas.getContext("webgl");
这里的 gl 通常是一个 WebGLRenderingContext 对象。它不是某一个圆形、三角形或纹理,也不是 GPU 硬件本身,而是浏览器提供给 JavaScript 的一套 WebGL 编程接口。
可以把它理解成一个持续存在的“渲染控制台”:你通过它创建图形资源、配置渲染规则、查询设备能力,然后向底层图形系统提交绘制命令。浏览器再负责把这些命令翻译成当前设备和驱动能够执行的操作。
// 通过 gl 查询 WebGL 版本信息
console.log(gl.getParameter(gl.VERSION));
// 通过 gl 设置视口
gl.viewport(0, 0, canvas.width, canvas.height);
// 通过 gl 清空颜色缓冲区
gl.clearColor(0.1, 0.6, 0.7, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);
注意,这些代码并没有直接操作某个“图形对象”或“显卡对象”。它们是在调用 Context 提供的接口,让 WebGL 绘制环境逐步进入我们需要的状态。
WebGL Context 可以看成 JavaScript 与浏览器图形系统之间的一座桥,而不是一张新的画布。
2. Context 为什么叫“上下文”:它会记住当前绘制状态
Context 的重要性不只是因为它提供了很多方法,更因为它会持续保存一组当前状态。后面的绘制命令会读取这些状态,因此调用顺序非常重要。
例如,clearColor() 并不会立刻把 Canvas 清空,它只是修改 Context 中保存的“清屏颜色”:
// 修改当前清屏颜色
gl.clearColor(1.0, 0.0, 0.0, 1.0);
// 真正执行清屏
gl.clear(gl.COLOR_BUFFER_BIT);
如果只调用 clearColor() 而不调用 clear(),画布上的像素并不会因此改变。类似的状态还有当前视口、当前使用的 Program、当前绑定的 Buffer、混合是否开启、深度测试是否开启,以及纹理绑定在哪个纹理单元上。
可以把 WebGL 的工作方式理解成:先拧好控制台上的旋钮,再按下执行按钮。
flowchart TD
A[调用 WebGL 方法] --> B{修改状态还是执行动作?}
B -- 修改状态 --> C[更新 Context 当前状态]
B -- 执行动作 --> D[读取当前状态]
C --> D
D --> E[提交图形命令]
E --> F[影响绘制缓冲区]
这也是 WebGL 被称为状态机(State Machine)的原因:状态不会在一条命令执行完后自动消失,而是会继续影响后面的绘制,直到你主动修改它。
3. Context 连接着命令接口、绘制缓冲区和底层设备
为了更准确地理解 WebGL Context,可以把它拆成三个相互关联的部分。
第一部分是命令接口,也就是 gl.clear()、gl.drawArrays()、gl.bindBuffer() 这些 JavaScript 方法。它们是我们能够直接调用的入口。
第二部分是绘制缓冲区。WebGL 的绘制结果最终会进入 Canvas 关联的颜色缓冲区,浏览器再把这个缓冲区作为页面内容进行显示。我们看到的不是 gl 对象,而是它提交绘制命令后产生的像素结果。
第三部分是底层图形设备连接。浏览器会根据当前系统、显卡和驱动,决定这些 WebGL 命令如何执行。JavaScript 不需要直接管理显卡驱动,但可以通过 Context 查询当前环境的能力和限制:
const maxTextureSize = gl.getParameter(gl.MAX_TEXTURE_SIZE);
const shadingLanguageVersion = gl.getParameter(
gl.SHADING_LANGUAGE_VERSION
);
console.log("最大纹理尺寸:", maxTextureSize);
console.log("着色器语言版本:", shadingLanguageVersion);
这几个部分可以串成一条链:
JavaScript 调用 gl 的方法
↓
WebGL Context 记录状态并组织命令
↓
浏览器与底层图形设备执行命令
↓
绘制结果进入 Canvas 的缓冲区
因此,Context 不是“画面本身”,而是管理画面生成过程的环境。它让 JavaScript 能够用相对统一的 WebGL API 操作图形资源,而不需要针对不同操作系统和显卡分别编写底层驱动代码。
4. Context 还有生命周期:创建、使用、丢失与恢复
WebGL Context 一旦创建成功,就会持续服务于这块 Canvas。重复请求同一种 Context,通常会得到同一个绘制环境:
const gl1 = canvas.getContext("webgl");
const gl2 = canvas.getContext("webgl");
console.log(gl1 === gl2); // true
但 Context 并不是永远不会失效。显卡驱动重置、设备资源紧张、浏览器策略或系统环境变化,都可能触发上下文丢失。可以通过 isContextLost() 查询当前状态:
if (gl.isContextLost()) {
console.warn("WebGL Context 当前不可用");
}
在重要的 WebGL 应用中,还应该监听上下文丢失和恢复事件:
canvas.addEventListener("webglcontextlost", (event) => {
event.preventDefault();
stopRendering();
});
canvas.addEventListener("webglcontextrestored", () => {
initializeResources();
startRendering();
});
上下文恢复后,之前创建的 Buffer、Texture 和 Program 不能简单地假设仍然可用,通常需要重新创建和重新上传资源。这个问题会在后面的兼容性章节中专门展开。
到这里,getContext("webgl") 的含义就清楚了:它让浏览器为 Canvas 建立一个 WebGL 渲染环境,并返回一个能够配置状态、管理资源和提交绘制命令的控制接口。下一节我们继续深入这个控制接口的状态机特征,看看一次状态修改究竟会怎样影响后面的绘制操作。
三、它为什么像一台状态机:一次设置,会影响后面的每一次绘制
1. WebGL 不是“调用一次画一次”,而是先修改环境,再执行命令
在 Canvas 2D 中,我们习惯这样写代码:设置颜色,调用绘制方法,图形就出现了。WebGL 也有类似的调用方式,但它更像一台需要不断调节旋钮的机器:你先修改当前环境,后面的绘制命令会读取这些环境设置。
例如,clearColor() 并不会立刻把 Canvas 清空,它只是修改 WebGL Context 中保存的“清屏颜色”:
// 修改 Context 的当前清屏颜色
gl.clearColor(1.0, 0.0, 0.0, 1.0);
// 真正执行清屏
gl.clear(gl.COLOR_BUFFER_BIT);
上面第一行是在设置状态,第二行才是在执行动作。如果只调用 clearColor() 而不调用 clear(),画布上的像素并不会因此改变。
这就是 WebGL 状态机(State Machine)的基本思路:Context 内部维护着许多“当前值”,WebGL 方法要么修改这些值,要么读取这些值并执行绘制。
WebGL 的绘制命令通常不是一条完整的“自包含指令”,而是依赖 Context 当前保存的状态。
2. 一个状态改变后,会一直保持到你主动修改它
假设我们先打开了混合功能:
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
从这一刻开始,后续绘制都会继续使用混合功能,直到显式关闭它:
gl.disable(gl.BLEND);
它不会因为你画完一个图形,就自动恢复到关闭状态。下面的代码中,第二个图形也会受到混合状态影响:
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
drawTransparentObject();
drawAnotherObject(); // 这里仍然处于开启混合的状态
类似的状态还有很多:
| 状态类别 | 常见设置 | 会影响什么 |
|---|---|---|
| 清除状态 | clearColor() | 清空颜色缓冲区时使用的颜色 |
| 视口状态 | viewport() | 画面映射到 Canvas 的范围 |
| 深度状态 | enable(gl.DEPTH_TEST) | 前后遮挡关系如何判断 |
| 混合状态 | enable(gl.BLEND) | 新旧像素如何混合 |
| 着色器状态 | useProgram() | 当前绘制使用哪套着色器 |
| 缓冲区状态 | bindBuffer() | 顶点数据从哪个缓冲区读取 |
因此,WebGL 代码不能只看某一行“画图函数”,还必须结合前面设置过的状态一起理解。同一个 drawArrays(),如果当前绑定的缓冲区、着色器或混合设置不同,最后画出的结果也可能完全不同。
3. 为什么 WebGL 代码容易出现“前面的设置影响后面”的问题
状态机的优点是减少重复配置。比如多个图形使用同一个着色器和同一种混合方式时,只需要设置一次,后面可以连续绘制。
gl.useProgram(program);
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
drawCircle(circleA);
drawCircle(circleB);
drawCircle(circleC);
但它的缺点也很明显:如果某个函数偷偷修改了 Context 状态,调用它之后的其他函数就可能得到意料之外的结果。
function drawTransparentObject() {
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
drawObject();
}
function drawOpaqueObject() {
// 如果这里忘记关闭 BLEND,结果可能和预期不同
drawObject();
}
更稳妥的做法,是让每个绘制函数明确声明自己依赖哪些状态,并在需要时主动设置或恢复:
function drawOpaqueObject() {
gl.disable(gl.BLEND);
gl.enable(gl.DEPTH_TEST);
gl.useProgram(opaqueProgram);
drawObject();
}
function drawTransparentObject() {
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
gl.disable(gl.DEPTH_TEST);
gl.useProgram(transparentProgram);
drawObject();
}
在复杂项目中,还可以把一次绘制所需要的配置集中起来,形成类似“绘制状态包”的结构。这样做的目的不是让 WebGL 变成无状态接口,而是减少状态依赖藏在代码角落里的情况。
flowchart TD
A[设置当前状态] --> B[绑定资源与着色器]
B --> C[发出绘制命令]
C --> D[读取当前状态并渲染]
D --> E{下一次绘制}
E -->|继续沿用| C
E -->|切换状态| A
所以,WebGL 状态机可以概括成一句话:**设置不会只影响当前这一行,除非你主动改变,否则它会继续影响后面的绘制。**下一节我们再把这些状态背后的资源单独拆出来,看看缓冲区、纹理和着色器究竟分别承担什么工作。
四、Context 里到底装着什么:缓冲区、纹理、着色器与默认状态
1. Context 不等于资源本身,而是资源的管理入口
前面我们把 WebGL Context 比作“渲染控制台”。那么,缓冲区、纹理和着色器是不是都直接装在这个 gl 对象里面?严格来说,不能这样理解。
Context 主要负责提供创建和管理资源的接口,并记录当前正在使用哪些资源。真正的缓冲区、纹理和着色器程序,会由浏览器和底层图形系统创建并管理;JavaScript 拿到的只是可以操作它们的 WebGL 对象引用。
const buffer = gl.createBuffer();
const texture = gl.createTexture();
const program = gl.createProgram();
这些方法返回的不是顶点数组、图片数据或一段着色器代码,而是几个 WebGL 资源对象。你可以把它们理解成资源的“句柄(Handle)”:以后通过这个句柄告诉 WebGL,你要使用或修改哪一份资源。
JavaScript 变量
↓ 保存引用
WebGL 资源对象
↓ 由浏览器与驱动管理
GPU 侧的缓冲区、纹理或程序资源
所以更准确的说法是:Context 管理着一组 WebGL 资源和当前状态,但 Context 自己并不是这些资源。
2. Buffer、Texture 和 Shader:三类资源分别负责什么
WebGL 绘制一个图形时,通常至少要处理三类资源:缓冲区(Buffer)、纹理(Texture)和着色器程序(Program)。它们解决的是不同问题。
| 资源 | 主要作用 | 可以把它理解成 |
|---|---|---|
| Buffer | 保存顶点、颜色、法线等结构化数据 | GPU 的数据仓库 |
| Texture | 保存图片或采样用的颜色数据 | GPU 可以读取的图像 |
| Shader | 规定顶点如何变换、像素如何着色 | GPU 上运行的小程序 |
| Program | 把顶点着色器和片元着色器组合起来 | 一套完整的着色方案 |
例如,顶点数据通常会先放入类型化数组,再上传到 Buffer:
const vertices = new Float32Array([
0.0, 0.8,
-0.8, -0.8,
0.8, -0.8
]);
const positionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.bufferData(
gl.ARRAY_BUFFER,
vertices,
gl.STATIC_DRAW
);
这里的 vertices 是 JavaScript 侧的数据;positionBuffer 是 WebGL 创建的资源引用;bufferData() 则把数据上传到当前绑定的 Buffer。之后 GPU 执行绘制时,会从这个 Buffer 中读取顶点位置。
纹理的使用方式也类似:先创建并绑定纹理,再把图片数据上传进去。
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
gl.RGBA,
gl.UNSIGNED_BYTE,
image
);
gl.texParameteri(
gl.TEXTURE_2D,
gl.TEXTURE_MIN_FILTER,
gl.LINEAR
);
着色器则负责告诉 GPU“数据应该如何变成最终画面”。顶点着色器处理顶点位置,片元着色器处理最终像素颜色;两者编译完成后,需要链接成一个 WebGLProgram,绘制时再通过 useProgram() 选中它。
3. bind 为什么如此重要:WebGL 通过“当前绑定”决定操作对象
WebGL 资源创建出来以后,并不是把资源对象直接传给每一个操作方法,而是先把它绑定到某个目标上,再让后续方法作用于当前绑定的资源。
const bufferA = gl.createBuffer();
const bufferB = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, bufferA);
gl.bufferData(gl.ARRAY_BUFFER, dataA, gl.STATIC_DRAW);
gl.bindBuffer(gl.ARRAY_BUFFER, bufferB);
gl.bufferData(gl.ARRAY_BUFFER, dataB, gl.STATIC_DRAW);
第一次调用 bufferData() 时,当前绑定的是 bufferA;第二次调用前,绑定对象已经切换成 bufferB。同一个 gl.bufferData(),因为当前状态不同,实际修改的资源也不同。
这就是上一节所说的状态机在资源管理中的具体体现:bindBuffer() 修改当前绑定状态,bufferData() 读取这个状态并把数据写入对应资源。
flowchart TD
A[创建资源对象] --> B[绑定到指定目标]
B --> C[上传数据或配置参数]
C --> D[设置为当前绘制资源]
D --> E[发出绘制命令]
E --> F[GPU 读取资源并生成像素]
如果忘记重新绑定,后面的代码就可能继续修改旧资源;如果绑定了错误类型的资源,WebGL 还可能产生错误。很多 WebGL 初学者遇到“数据明明上传了,画面却不对”,问题往往就出在当前绑定对象不是自己以为的那个对象。
4. 默认状态:Context 创建时并不是一张完全空白的白纸
WebGL Context 创建成功后,浏览器已经为它准备了一批默认状态。但实际开发中不应该只依赖自己对默认值的记忆,而应该在开始绘制前明确设置关键状态:
gl.viewport(0, 0, canvas.width, canvas.height);
gl.disable(gl.BLEND);
gl.enable(gl.DEPTH_TEST);
gl.clearColor(0.06, 0.09, 0.16, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
这样做有两个好处:第一,代码的绘制前提一目了然;第二,当多个绘制函数共享同一个 Context 时,不容易因为前一段代码留下的状态而产生隐蔽错误。
到这里可以把 Context 里的内容归纳成三部分:它提供操作资源的命令接口,维护当前绑定和绘制状态,并连接着 Canvas 的绘制缓冲区。下一节我们把这些东西串起来,跟着一次 drawArrays() 看看一条绘制命令是怎样从 JavaScript 走到 GPU 的。
五、从 JavaScript 到 GPU:一次 drawArrays() 背后发生了什么
1. drawArrays() 不是“画一个图形”,而是启动一条渲染流程
在 Canvas 2D 中,我们会调用 fillRect() 或 arc() 来表达“画一个矩形”或“画一个圆”。但 WebGL 的 drawArrays() 并不知道什么是圆和矩形,它做的事情更底层:从当前绑定的顶点数据中取出若干个顶点,按照指定的图元类型组织起来,然后启动一次 GPU 绘制。
gl.drawArrays(
gl.TRIANGLES, // 图元类型:用三角形组织顶点
0, // 从第 0 个顶点开始读取
3 // 一共读取 3 个顶点
);
这段代码的意思不是“画一个三角形对象”,而是:从当前顶点属性配置中读取 3 个顶点,把它们按照 TRIANGLES 规则组成一个三角形,并交给当前着色器程序处理。
所以,drawArrays() 能否画出正确结果,取决于调用它之前是否已经准备好了完整的绘制环境:当前使用的着色器、顶点缓冲区、顶点属性、视口和各种渲染状态。
drawArrays()更像“启动渲染流水线”,而不是 Canvas 2D 中那种直接描述图形的高级绘图方法。
2. CPU 先准备数据,WebGL 再把数据交给 GPU
一次完整的绘制,通常先从 JavaScript 数组开始。假设我们要绘制一个三角形,可以先准备三个顶点:
const vertices = new Float32Array([
0.0, 0.8,
-0.8, -0.8,
0.8, -0.8
]);
接着创建缓冲区并上传数据:
const positionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.bufferData(
gl.ARRAY_BUFFER,
vertices,
gl.STATIC_DRAW
);
然后告诉 WebGL:这些数据应该交给哪个顶点属性读取,每两个数字组成一个顶点坐标:
const positionLocation = gl.getAttribLocation(
program,
"a_position"
);
gl.enableVertexAttribArray(positionLocation);
gl.vertexAttribPointer(
positionLocation,
2,
gl.FLOAT,
false,
0,
0
);
到这里还没有真正画出三角形。我们只是完成了“数据放在哪里”“数据如何读取”“使用哪套程序”等准备工作。真正的启动点,才是最后的 drawArrays()。
3. GPU 如何沿着渲染管线处理这次绘制
当 JavaScript 调用 drawArrays() 后,浏览器会检查当前状态、参数和资源是否满足绘制要求,再把绘制命令提交给底层图形系统。GPU 接过命令后,会依次执行顶点处理、图元组装、光栅化和片元处理。
flowchart TD
A[JavaScript 调用 drawArrays] --> B[读取当前 Buffer 与顶点属性]
B --> C[顶点着色器处理每个顶点]
C --> D[图元组装成点、线或三角形]
D --> E[裁剪并执行光栅化]
E --> F[片元着色器计算颜色]
F --> G[深度测试、模板测试与混合]
G --> H[写入绘制缓冲区]
顶点着色器负责计算每个顶点的位置;图元组装阶段负责决定顶点怎样连接;光栅化负责找出图形覆盖的片元;片元着色器负责计算这些位置应该显示的颜色。通过深度、模板和混合等测试后,颜色才会写入绘制缓冲区。
这里的“提交”不一定意味着 GPU 在这一行代码返回之前已经完成了全部工作。浏览器和图形驱动通常会对命令进行组织和排队,JavaScript 可以继续执行后面的代码,GPU 则在合适的时机处理这些命令。
4. 为什么同一个 drawArrays() 能画出完全不同的结果
drawArrays() 的函数形式看起来固定,但它绘制的内容并不固定,因为它读取的是当前状态。只要更换顶点数据、图元类型或当前 Program,同一个方法就可以产生完全不同的结果。
// 每 3 个顶点组成一个三角形
gl.drawArrays(gl.TRIANGLES, 0, 3);
// 每 2 个顶点组成一条独立线段
gl.drawArrays(gl.LINES, 0, 4);
// 每个顶点单独作为一个点
gl.drawArrays(gl.POINTS, 0, pointCount);
第一个参数决定顶点如何组织成图元,第二个参数表示从哪个顶点开始读取,第三个参数表示读取多少个顶点。TRIANGLES 每三个顶点组成一个三角形,LINES 每两个顶点组成一条线,POINTS 则把每个顶点当成一个点。
也就是说,drawArrays() 本身没有“图形知识”。真正决定图形形状的,是顶点数据和图元规则;真正决定图形外观的,是当前着色器和渲染状态。
5. 一次 Draw Call 的成本,不只是一行代码
在复杂 WebGL 页面中,性能不能只看 JavaScript 代码有多少行。一次 drawArrays() 可能涉及状态检查、资源读取、顶点着色器计算、片元生成和大量像素处理。
影响成本的因素包括:顶点数量、绘制调用次数、着色器复杂度、纹理采样次数、覆盖的像素数量、混合和深度测试,以及 CPU 与 GPU 之间的数据传输。
// 不要在循环里频繁重复创建相同资源
for (let i = 0; i < particles.length; i++) {
// 更好的方式是复用 Buffer 和 Program,批量提交数据
drawParticle(particles[i]);
}
当页面中存在大量相似图形时,通常应该考虑批量更新、合并顶点、纹理图集、实例化绘制和资源复用,而不是为每个对象都重新创建一套 WebGL 资源。
到这里,我们已经把 drawArrays() 从一行 API 拆成了一条完整链路:JavaScript 准备数据和状态,WebGL 组织资源与命令,GPU 执行渲染流水线,最后得到绘制缓冲区中的像素。下一节我们再集中处理创建 Context 时的版本选择、兼容性和上下文丢失问题。
六、创建 Context 时最容易踩的坑:WebGL 1、WebGL 2 与上下文丢失
1. WebGL 1 和 WebGL 2:先判断能力,不要直接假设环境
前面我们一直使用 canvas.getContext("webgl")。它拿到的是 WebGL 1 的上下文,对应的对象类型通常是 WebGLRenderingContext。如果想使用更新的 WebGL 2,则需要请求 webgl2:
const canvas = document.querySelector("canvas");
const gl = canvas.getContext("webgl2");
if (!gl) {
console.warn("当前环境不支持 WebGL 2,尝试回退到 WebGL 1");
}
WebGL 2 基于 OpenGL ES 3.0,提供了更多内置能力,例如更灵活的纹理格式、整数顶点属性、实例化绘制和顶点数组对象等。WebGL 1 的兼容范围更广,但可直接使用的功能相对基础。
实际项目中,不应该把“浏览器能打开网页”直接等同于“浏览器一定能创建 WebGL Context”。系统可能没有可用的图形驱动,浏览器可能主动禁用了 WebGL,设备也可能因为硬件或安全策略而创建失败。
更稳妥的写法,是优先尝试 WebGL 2,失败后再回退到 WebGL 1:
const canvas = document.querySelector("canvas");
const gl =
canvas.getContext("webgl2") ||
canvas.getContext("webgl");
if (!gl) {
const message = document.createElement("p");
message.textContent = "当前设备不支持 WebGL,无法显示此特效。";
canvas.replaceWith(message);
}
需要注意的是,webgl2 和 webgl 不是同一个 Context 类型。代码使用了 WebGL 2 的专有 API 后,不能只因为成功回退到 WebGL 1 就继续执行;应该根据实际得到的 Context 选择对应的代码路径。
2. 创建参数:一开始的配置,会决定绘制环境的特征
getContext() 的第二个参数可以传入上下文创建属性,用来表达这次绘制环境的需求:
const gl = canvas.getContext("webgl", {
alpha: true,
antialias: true,
depth: true,
premultipliedAlpha: true,
preserveDrawingBuffer: false
});
这些参数不是普通的绘制状态,而是参与 Context 创建过程的初始化选项:
| 属性 | 作用 | 需要注意什么 |
|---|---|---|
alpha | 是否需要透明的绘制缓冲区 | 需要和页面背景合成时再考虑 |
antialias | 是否请求默认抗锯齿 | 最终是否启用还取决于设备与浏览器 |
depth | 是否创建深度缓冲区 | 三维遮挡关系通常需要它 |
stencil | 是否创建模板缓冲区 | 遮罩、裁剪等效果可能会用到 |
preserveDrawingBuffer | 绘制后是否保留缓冲区内容 | 截图有用,但可能增加性能和内存成本 |
例如,preserveDrawingBuffer 通常不需要为了普通动画而开启。只有在绘制完成后需要读取或截图时,才应该结合具体设备评估是否使用它。
Context 创建成功后,还可以查看当前环境实际采用的属性:
const attributes = gl.getContextAttributes();
console.log(attributes);
功能检测比浏览器型号判断更可靠。即使两个设备使用同一个浏览器,也可能因为显卡、驱动、系统策略和设备性能不同,得到不同的 WebGL 能力。
3. 上下文丢失:Context 可能暂时失效,资源也不能假设还在
WebGL Context 不是永远不会出问题的连接。显卡驱动重置、系统资源紧张、设备切换、浏览器策略或页面所在环境发生变化,都可能导致上下文丢失(Context Lost)。
上下文丢失后,原来的 Buffer、Texture 和 Program 不能继续按正常状态使用。此时如果代码还在持续调用 drawArrays(),画面就可能停止更新。
因此,在重要的 WebGL 应用中,应该监听两个事件:
canvas.addEventListener("webglcontextlost", (event) => {
event.preventDefault();
console.warn("WebGL Context 已丢失,暂停渲染");
stopRendering();
});
canvas.addEventListener("webglcontextrestored", () => {
console.info("WebGL Context 已恢复,重新创建资源");
initializeWebGLResources();
startRendering();
});
webglcontextlost 发生后,应该暂停动画循环;调用 preventDefault() 可以允许浏览器尝试恢复上下文。恢复成功后,不能只重新调用一次绘制函数,因为之前创建的缓冲区、纹理和着色器资源通常需要重新建立。
4. 把初始化和资源创建拆开,恢复时才能重新开始
比较稳妥的工程结构,是把 WebGL 初始化拆成两部分:一部分负责获取 Context 和配置基础状态,另一部分负责创建可以随时重建的 GPU 资源。
function initializeContext() {
gl.viewport(0, 0, canvas.width, canvas.height);
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
}
function initializeWebGLResources() {
program = createProgram();
positionBuffer = createPositionBuffer();
texture = createTexture();
}
function startRendering() {
requestAnimationFrame(render);
}
这样,上下文恢复时只需要重新执行资源初始化,而不必把业务数据、交互逻辑和整个页面推倒重来。
flowchart TD
A[请求 WebGL 2] --> B{创建成功吗?}
B -- 否 --> C[回退到 WebGL 1]
C --> D{创建成功吗?}
B -- 是 --> E[配置 Context]
D -- 否 --> F[显示降级内容或提示]
D -- 是 --> E
E --> G[创建 Buffer、Texture、Program]
G --> H[开始渲染循环]
H --> I{Context 是否丢失?}
I -- 否 --> H
I -- 是 --> J[暂停渲染并等待恢复]
J --> K{Context 是否恢复?}
K -- 否 --> J
K -- 是 --> G
回头看这一篇,从 getContext("webgl") 到 drawArrays(),我们实际上走完了一条完整的路径:Canvas 提供显示区域,Context 提供状态和命令接口,Buffer 保存数据,Shader 定义计算方式,GPU 执行渲染流水线,最后把结果写入绘制缓冲区并交给浏览器合成。
理解 Context,不只是为了记住一个 API 名字,而是为了知道 JavaScript 究竟通过什么入口开始控制一次图形渲染,也知道当画面黑屏、资源失效或设备不支持时,应该从哪里检查和恢复。
七、关于八荒启
八荒启是一家专注于交互体验产品与解决方案的品牌,持续探索交互技术在教育教学、产品展示、过程模拟、操作训练和数据可视化等场景中的应用。
我们不仅分享技术实现,也持续创作和沉淀交互动画、数字作品、开发教程、项目案例与行业解决方案,希望通过交互技术,让复杂事物变得更加容易理解、探索、操作和创造。
八荒启,专为交互动画而生
让复杂事物可探索、可操作、可反馈
如果你正在寻找交互作品、学习相关技术,或者希望把一个想法转化为可实际操作的交互项目,欢迎访问八荒启官网了解更多案例与服务。
- 官方网站:bahuangqi.com
- 主要内容:交互动画、3D 可视化、教育互动、仿真模拟与技术教程
- 定制服务:可通过官网提交需求或联系人工客服进行评估
感谢阅读。如果本文对你有所帮助,欢迎点赞、收藏和关注,我们会继续分享更多交互作品与项目实践。