跳到主要内容
OpenGL 与 GLSL · 更新于 2026年6月2日 约 6 分钟

从 WebGL 1 到 WebGL 2:GLSL ES 3.00 迁移要点

整理桌面 OpenGL、WebGL 1 与 WebGL 2 之间着色器语言的差异,包括版本指令、in/out、整数类型、纹理采样、UBO 与 instancing,以及在 three.js 中使用 RawShaderMaterial 写原生 GLSL ES 3.00 的具体做法。

本文目录

为什么值得单独写一篇

WebGL 2 对应的是 OpenGL ES 3.0,着色器语言是 GLSL ES 3.00。它和桌面 OpenGL 的 GLSL 3.30 看起来很像,但并不是同一套东西;和 WebGL 1 的 GLSL ES 1.00 差异则更大。很多从桌面 OpenGL 或者旧教程迁移过来的代码,报错信息往往只指向语法层面,真正的原因是特性在某个版本里根本不存在。

下面按「写代码时会立刻碰到」的顺序整理。

1. #version 300 es 必须是第一行

#version 300 es
precision highp float;

这一行有几个硬性要求:

  • 必须是文件的第一个 token,前面不能有空行、空格,也不能有注释;
  • 300 es 中间必须有一个空格,写成 300es 会直接被拒绝;
  • 片元着色器里 float 没有默认精度,必须显式写 precision highp float;(顶点着色器默认是 highp)。

如果版本指令缺失,编译器会按 GLSL ES 1.00 处理,于是 in / out / texture() 全部变成未声明标识符——这是最常见的误报来源。

2. 限定符与内建变量改名

GLSL ES 1.00GLSL ES 3.00
attribute vec3 position;(顶点)in vec3 position;
varying vec2 vUv;(顶点输出)out vec2 vUv;
varying vec2 vUv;(片元输入)in vec2 vUv;
gl_FragColor = ...自己声明 out vec4 fragColor;
gl_FragData[0]layout(location = 0) out vec4 outColor;
texture2D(s, uv) / textureCube(s, d)texture(s, uv) / texture(s, d)

gl_FragColor 在 3.00 里被彻底移除,必须自己声明输出变量。想要多渲染目标(MRT)时用 layout(location = n),这在 WebGL 1 里需要 WEBGL_draw_buffers 扩展。

3. 真正可用的新特性

WebGL 2 相比 WebGL 1 增加的不只是语法糖:

  • 整数类型:int 是真正的 32 位整数,还有 uint、ivec3、位运算(&、|、^、<<、>>)以及整数纹理(sampler2D 配 usampler2D / isampler2D)。
  • 纹理采样家族:textureSize() 直接拿到纹理尺寸,不必再靠 uniform 传;texelFetch() 按整数坐标取纹素、不做过滤;textureLod() 显式指定 mip 层级。
  • 导数函数内建:dFdx / dFdy / fwidth 不再需要 GL_OES_standard_derivatives,这对做法线重建、屏幕空间抗锯齿、程序化描边非常关键。
  • 3D 纹理与纹理数组:sampler3D、sampler2DArray,配合 texture() 的第三个分量做切片选择。
  • 实例化绘制:gl_InstanceID 内建变量,以及 drawArraysInstanced / drawElementsInstanced,不用再依赖 ANGLE_instanced_arrays。
  • Uniform Buffer Object:layout(std140) uniform Matrices { ... };,多 program 之间共享同一批 uniform。
  • 原生 VAO:createVertexArray / bindVertexArray 是核心 API。
  • 变换反馈(Transform Feedback):可以在 GPU 上做粒子系统的状态更新。
  • 非 2 的幂纹理:完整支持 mip 与 REPEAT 环绕,WebGL 1 里这是要靠扩展的。
  • gl_FragDepth 与 gl_VertexID。

需要注意的是 std140 的对齐规则很容易踩坑:vec3 按 16 字节对齐,所以一个 vec3 后面紧跟一个 float 会恰好填满一个 16 字节单元,但 float 后面跟 vec3 就会插入填充。写 UBO 时最好按「从大到小」排列成员,或者干脆用工具生成布局。

4. 循环与分支

GLSL ES 1.00 要求循环次数在编译期可确定,所以老代码里到处是 const int MAX = 8;。3.00 放开了这个限制,循环边界可以来自 uniform:

uniform int uWaveCount;

for (int i = 0; i < 8; ++i) {
  if (i >= uWaveCount) break;
  // ...
}

上面这种「固定上限 + 运行时 break」的写法在实践中仍然更稳:部分移动 GPU 驱动对完全动态的循环边界会退化成很慢的实现,而固定上限让编译器能够展开。

5. 在 three.js 里写原生 GLSL ES 3.00

three.js 从 r163 起 WebGLRenderer 只创建 WebGL 2 上下文,拿不到就直接抛错。要用原生 GLSL ES 3.00(而不是 three 帮你转译的 GLSL 1 风格代码),关键是 RawShaderMaterial 加 glslVersion:

const material = new THREE.RawShaderMaterial({
  glslVersion: THREE.GLSL3,
  vertexShader,
  fragmentShader,
  uniforms,
});

RawShaderMaterial 与普通 ShaderMaterial 的区别在于前缀代码:

  • ShaderMaterial 会被 three 注入一大堆 #define、uniform mat4 modelViewMatrix;、attribute→in 的兼容宏,以及 chunk include;
  • RawShaderMaterial 只在 glslVersion === GLSL3 时前置一行 #version 300 es,其余什么都不加。

这一点在 three 的源码里可以直接确认(WebGLProgram.js):

let versionString = parameters.glslVersion ? '#version ' + parameters.glslVersion + '\n' : '';
if ( parameters.isRawShaderMaterial ) { /* prefix 只包含自定义 define */ }
// ...
const vertexGlsl = versionString + prefixVertex + vertexShader;

因此有两个直接后果:

  1. 源文件里不要自己写 #version 300 es,否则会出现两次版本指令,编译必然失败;
  2. 内建 uniform 需要自己声明。three 依然会上传 modelMatrix、modelViewMatrix、projectionMatrix、viewMatrix、normalMatrix、cameraPosition(见 WebGLRenderer.js 中的 p_uniforms.setValue),但如果着色器里没有声明,这些值就没有对应的 location,等于不存在。

同理,内建属性 position、normal、uv 也要自己写 in vec3 position; 等声明——three 是按名字绑定 attribute location 的。

normalMatrix 是视图空间的

three 提供的 normalMatrix 是 modelViewMatrix 的逆转置,得到的是视图空间法线。而 URP 的 TransformObjectToWorldNormal 给的是世界空间法线。如果想在世界空间里做光照(例如直接和 cameraPosition 一起用),应该自己算:

vec3 normalWS = normalize(mat3(modelMatrix) * normal);

只有在均匀缩放时 mat3(modelMatrix) 才严格等价于逆转置;一旦引入非均匀缩放,就需要 inverse(transpose(mat3(modelMatrix))),代价明显更高。

6. 输出颜色空间不会被自动处理

RawShaderMaterial 拿不到 three 追加的输出转换代码(#include <colorspace_fragment> 只在 three 自己的材质里出现),所以片元着色器输出的就是最终写入 drawing buffer 的值,浏览器会按 sRGB 解释它。如果整条光照计算是在线性空间做的,就必须自己补一次编码:

vec3 linearToSRGB(vec3 c) {
  vec3 lo = c * 12.92;
  vec3 hi = 1.055 * pow(max(c, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055;
  return mix(lo, hi, step(vec3(0.0031308), c));
}

反过来,uniform 里的颜色值如果是从 CSS 十六进制来的,那是 sRGB,需要先转到线性再参与计算。关于这一点,另有一篇《实时渲染中的色彩空间》专门展开。

7. 错误处理与上下文丢失

three 遇到编译错误只会 console.error,不抛异常。如果要把驱动日志展示给用户(而不是留一块黑屏),需要自己在同一个上下文上先编译一次:

const shader = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(shader, `#version 300 es\n${source}`);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
  throw new Error(gl.getShaderInfoLog(shader) ?? '');
}

注意这里手动补上的 #version 300 es,要和 three 的前缀保持一致,否则报出来的行号会和实际源码对不上。

另外 WebGL 上下文是会被浏览器回收的(GPU 重置、后台标签页过多、显存压力)。生产代码应该监听 webglcontextlost / webglcontextrestored,前者 preventDefault(),后者重建资源。同时别忘了:一个页面能同时持有的活动 WebGL 上下文数量是有限的(各浏览器大致在 8~16 个),做能力探测用的临时上下文要及时 WEBGL_lose_context.loseContext() 释放。

8. 一份可以直接对照的迁移清单

  • 第一行是 #version 300 es,前面没有任何字符;
  • 片元着色器声明了 precision highp float;;
  • attribute → in,varying → out / in;
  • gl_FragColor → 自定义 out vec4;
  • texture2D / textureCube → texture;
  • 用上了 textureSize / texelFetch / dFdx 等新 API,或者确认不需要;
  • three.js 侧用了 RawShaderMaterial + glslVersion: THREE.GLSL3,且源码里没有重复的版本指令;
  • 需要的内建 uniform / attribute 都手动声明了;
  • 法线所在空间与相机位置、光照方向一致;
  • 输出做了正确的 sRGB 编码,且没有和 three 的输出转换重复;
  • 编译失败路径与上下文丢失路径都有处理。