Web特效08—WebGL Context深挖:canvas.getContext('webgl') 到底拿到了什么

0 阅读25分钟

Web特效08—WebGL Context深挖:canvas.getContext('webgl') 到底拿到了什么

上一节我们说,Canvas 2D 像一支已经配好的画笔:调用 arc()fillRect(),浏览器就会替我们把路径、样式和像素处理好。但当我们写下 canvas.getContext('webgl') 时,事情突然变得不一样了——拿到的既不是一张新画布,也不是 GPU 本身,更不是一个“能直接画圆”的万能对象,而是浏览器交给 JavaScript 的一套 WebGL 渲染控制接口。它让我们能够创建缓冲区、上传顶点数据、编写着色器、绑定纹理,并向 GPU 发出绘制命令;与此同时,也意味着我们需要开始面对坐标、三角形、状态机和渲染流水线这些更底层的概念。为什么同样是 getContext(),Canvas 2D 和 WebGL 拿到的东西却如此不同?这个 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 LR
    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.06, 0.09, 0.16, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);

注意,这些代码并没有直接操作某个“画布对象”或“GPU 对象”。它们是在调用 Context 提供的接口,让 WebGL 绘制环境逐步进入我们需要的状态。

WebGL Context 可以看成 JavaScript 与浏览器图形系统之间的一座桥,而不是一张新的画布。

2. 为什么叫 Context:它还保存着“当前正在使用的绘制状态”

在 WebGL 中,Context 的重要性不只是因为它提供了很多方法,更因为它会持续保存一组当前状态。后面的绘制命令会读取这些状态,因此调用顺序非常重要。

例如,下面两段代码的结果并不一样:

// 先设置清屏颜色,再执行清屏
gl.clearColor(1.0, 0.0, 0.0, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT); // 清成红色
// 先清屏,再修改颜色
gl.clear(gl.COLOR_BUFFER_BIT); // 使用修改前的颜色
gl.clearColor(1.0, 0.0, 0.0, 1.0);

第二段代码虽然也调用了 clearColor(),但红色设置发生在 clear() 之后,所以这一次清屏并不会使用红色。clearColor() 修改的是 Context 中的当前状态;真正执行清屏的是 clear()

类似的状态还有很多:当前视口、混合是否开启、深度测试是否开启、当前使用哪个着色器程序、当前绑定哪个缓冲区,以及颜色、深度和模板缓冲区的清除值等。

这就是 WebGL 常被称为**状态机(State Machine)**的原因。它并不是每次调用函数时都把所有配置重新传一遍,而是先修改当前状态,后面的命令继续沿用这些状态,直到你主动改变它们。

flowchart TD
    A[&#34;调用 WebGL 方法&#34;] --> B{&#34;修改状态<br/>还是执行绘制?&#34;}

    B -->|&#34;修改状态&#34;| C[&#34;更新 Context<br/>当前状态&#34;]

    B -->|&#34;执行绘制&#34;| D[&#34;读取当前状态&#34;]

    C --> D

    D --> E[&#34;提交渲染命令&#34;]

    E --> F[&#34;影响 Canvas<br/>绘制缓冲区&#34;]

因此,下面这种写法很容易产生问题:你在前面开启了某个功能,后面忘记关闭,导致后续图形也受到影响。WebGL 不会自动替你恢复到“干净状态”。

gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);

// 后续绘制都会继续受到混合状态影响
drawTransparentObject();
drawAnotherObject();

这和 Canvas 2D 的绘图状态有些相似:fillStylelineWidth 和变换矩阵也会影响后续绘制。但 WebGL 的状态数量更多,状态之间的关系也更复杂,所以更需要建立“先配置,再绘制”的意识。

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 操作图形资源,而不需要针对 Windows、macOS 或不同显卡分别编写一套底层驱动代码。

到这里,canvas.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. 缓冲区、纹理和着色器:三类核心资源各自负责什么

WebGL 绘制一个图形时,通常至少要处理三类资源:缓冲区(Buffer)、纹理(Texture)和着色器程序(Program)。它们解决的是不同问题。

资源主要作用可以把它理解成
Buffer保存顶点、颜色、法线等结构化数据GPU 的数据仓库
Texture保存图片或采样用的颜色数据GPU 可以读取的图像
Shader规定顶点如何变换、像素如何着色GPU 上运行的小程序
Program把顶点着色器和片元着色器组合起来一套完整的着色方案

例如,顶点数据通常会先放入数组,再上传到缓冲区:

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() 则把数据上传到当前绑定的缓冲区。之后 GPU 执行绘制时,会从这个缓冲区读取顶点位置。

纹理的使用方式也类似:先创建并绑定纹理,再把图片数据上传进去。

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. 第一站:JavaScript 准备数据,WebGL 记录绘制配置

一次完整的绘制,通常先从 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,                // 每个顶点包含 2 个分量:x、y
  gl.FLOAT,
  false,
  0,
  0
);

到这里还没有真正画出三角形。我们只是完成了“数据放在哪里”“数据如何读取”“使用哪套程序”等准备工作。真正的启动点,才是最后的 drawArrays()

3. 第二站:顶点着色器先处理每一个顶点

当 GPU 收到绘制命令后,首先会运行顶点着色器(Vertex Shader)。它大致会对每个顶点执行一次,负责把顶点从我们提供的坐标转换成 GPU 渲染流水线需要的裁剪空间坐标。

attribute vec2 a_position;

void main() {
  gl_Position = vec4(a_position, 0.0, 1.0);
}

这里的 a_position 就是前面通过 vertexAttribPointer() 配置好的顶点数据。gl_Position 是顶点着色器必须输出的结果,它告诉 GPU 当前顶点最终位于什么位置。

如果三个顶点分别是:

( 0.0,  0.8)
(-0.8, -0.8)
( 0.8, -0.8)

GPU 会对这三个顶点分别运行顶点着色器,得到三个处理后的顶点。此时它们仍然只是三个点,还没有变成屏幕上的三角形。

4. 第三站:从顶点到像素,要经过图元组装和光栅化

顶点着色器处理完顶点后,GPU 会根据 drawArrays(gl.TRIANGLES, 0, 3) 中的图元类型,把三个顶点组装成一个三角形。

接下来进入光栅化(Rasterization)阶段。GPU 会判断这个三角形覆盖了屏幕上的哪些像素,并为覆盖范围内的每个片元运行片元着色器(Fragment Shader)。

precision mediump float;

void main() {
  gl_FragColor = vec4(0.1, 0.7, 0.7, 1.0);
}

这个片元着色器给每个片元返回同一种青绿色,于是三角形内部就会显示成一整块青绿色。完成片元着色后,GPU 还可能继续进行深度测试、模板测试、混合等处理,最后才把通过测试的颜色写入 Canvas 关联的绘制缓冲区。

flowchart TD
    A[JavaScript 调用 drawArrays] --> B[读取当前缓冲区与顶点属性]
    B --> C[顶点着色器处理每个顶点]
    C --> D[图元组装成三角形]
    D --> E[光栅化计算覆盖的片元]
    E --> F[片元着色器计算颜色]
    F --> G[深度测试、混合等处理]
    G --> H[写入绘制缓冲区]
    H --> I[Canvas 显示最终像素]

5. drawArrays() 结束后,WebGL 仍然保留当前状态

一次 drawArrays() 执行结束,并不意味着 WebGL Context 被重置。当前使用的着色器、绑定的缓冲区、顶点属性配置以及混合和深度测试状态,通常仍然保持着。

因此,连续绘制多个图形时,可以复用已经准备好的资源:

gl.useProgram(program);
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);

drawTriangleA();
drawTriangleB();
drawTriangleC();

但如果下一个图形需要另一套顶点数据或另一套着色器,就必须在绘制前切换对应资源:

gl.useProgram(anotherProgram);
gl.bindBuffer(gl.ARRAY_BUFFER, anotherBuffer);

gl.drawArrays(gl.TRIANGLES, 0, 3);

这就是一次 WebGL 绘制既像“流水线”,又像“状态机”的原因:命令负责启动流程,状态决定流程读取哪些数据、使用哪些规则,流水线最后把结果写成像素。

到这里,我们已经从 getContext("webgl") 一路追到了 drawArrays() 的最终结果。下一节再处理实际开发中最容易忽略的问题:WebGL 1、WebGL 2 和上下文丢失,以及创建 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);
}

需要注意的是,webgl2webgl 不是同一个 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 创建成功后,还可以通过 getContextAttributes() 查看当前环境实际采用的属性:

const attributes = gl.getContextAttributes();
console.log(attributes);

这里得到的是实际 Context 的属性信息,而不是对浏览器一定会满足的承诺。某些能力可能因为设备限制、浏览器策略或驱动实现而有所不同,因此功能检测始终比浏览器型号判断更可靠。

3. 上下文丢失:Context 可能会暂时失效,资源也不能直接假设还在

WebGL Context 不是永远不会出问题的连接。显卡驱动重置、系统资源紧张、设备切换、浏览器策略或页面所在环境发生变化,都可能导致 WebGL 上下文丢失(Context Lost)。

上下文丢失后,原来的缓冲区、纹理和着色器程序不能继续按正常状态使用。此时如果代码还在持续调用 drawArrays(),画面就可能停止更新,控制台也可能出现 WebGL 错误。

因此,在重要的 WebGL 应用中,应该监听两个事件:

canvas.addEventListener("webglcontextlost", (event) => {
  event.preventDefault();
  console.warn("WebGL Context 已丢失,暂停渲染");
  stopRendering();
});

canvas.addEventListener("webglcontextrestored", () => {
  console.info("WebGL Context 已恢复,重新创建资源");
  initializeWebGLResources();
  startRendering();
});

webglcontextlost 事件发生后,应该暂停动画循环;调用 preventDefault() 可以允许浏览器尝试恢复上下文。恢复成功后,不能只重新调用一次绘制函数,因为之前创建的缓冲区、纹理和着色器资源需要重新建立。

flowchart TD
    A[创建 WebGL Context] --> B{创建成功吗?}
    B -- 否 --> C[显示降级内容或提示信息]
    B -- 是 --> D[创建缓冲区、纹理和着色器]
    D --> E[开始渲染循环]
    E --> F{Context 是否丢失?}
    F -- 否 --> E
    F -- 是 --> G[暂停渲染并等待恢复]
    G --> H{Context 是否恢复?}
    H -- 否 --> G
    H -- 是 --> D

因此,WebGL 的初始化最好拆成两部分:一部分负责获取 Context 和配置基础状态,另一部分负责创建可以随时重建的 GPU 资源。这样上下文恢复时,只需要重新执行资源初始化,而不必把所有业务逻辑推倒重来。

回头看这一篇文章,从 getContext("webgl")drawArrays(),我们实际上走完了一条完整的路径:Canvas 提供显示区域,Context 提供状态和命令接口,缓冲区保存数据,着色器定义计算方式,GPU 执行渲染流水线,最后把结果写入绘制缓冲区。理解 Context,不是为了记住一个 API 名字,而是为了知道 JavaScript 究竟通过什么入口,开始控制一次真正的图形渲染。

七、关于八荒启

八荒启是一家专注于交互体验产品与解决方案的品牌,持续探索交互技术在教育教学、产品展示、过程模拟、操作训练和数据可视化等场景中的应用。

我们不仅分享技术实现,也持续创作和沉淀交互动画、数字作品、开发教程、项目案例与行业解决方案,希望通过交互技术,让复杂事物变得更加容易理解、探索、操作和创造。

八荒启,专为交互动画而生
让复杂事物可探索、可操作、可反馈

如果你正在寻找交互作品、学习相关技术,或者希望把一个想法转化为可实际操作的交互项目,欢迎访问八荒启官网了解更多案例与服务。

  • 官方网站:bahuangqi.com
  • 主要内容:交互动画、3D 可视化、教育互动、仿真模拟与技术教程
  • 定制服务:可通过官网提交需求或联系人工客服进行评估

感谢阅读。如果本文对你有所帮助,欢迎点赞、收藏和关注,我们会继续分享更多交互作品与项目实践。