服务端5月29日 22:35
WebGL 缓冲区(Buffer)是什么?VBO 和 VAO 有什么区别?WebGL 缓冲区就是 GPU 显存中的一块区域,用来存顶点数据(位置、颜色、法线、UV 等)。VBO(Vertex Buffer Object)是存数据的容器,VAO(Vertex Array Object)是记录"哪个 VBO 绑到哪个 attribute、偏移量多少、步长多少"的配置快照。WebGL 1 没有 VAO(需扩展 `OES_vertex_array_object`),WebGL 2 原生支持。有了 VAO,切换绘制对象只需 `gl.bindVertexArray(vao)` 一行,不用重复设置一堆 `vertexAttribPointer`。
## 追问
### VAO 具体记录了哪些状态?
每个 attribute 的启用状态(`enableVertexAttribArray`)、绑定的 VBO(`vertexAttribPointer` 时的 `ARRAY_BUFFER`)、数据偏移和步长、以及 `ELEMENT_ARRAY_BUFFER` 的绑定。不记录 `ARRAY_BUFFER` 本身的绑定——这点容易搞混。
### 为什么 WebGL 1 没有 VAO?
OpenGL ES 2.0 规范没包含 VAO,它从 OpenGL ES 3.0 / WebGL 2 才成为标准。WebGL 1 可以用扩展 `OES_vertex_array_object`,但不是所有设备都支持。项目兼容性要求高的话,自己封装一个 VAO 管理器,内部用数组存 attribute 配置,绑定时批量调用 `vertexAttribPointer`。
### EBO(IBO)和 VBO 什么关系?
EBO(Element Buffer Object)也叫 IBO,存索引数据,告诉 GPU 按什么顺序读顶点,实现顶点复用(一个正方体 8 个顶点而非 36 个)。EBO 绑定到 `ELEMENT_ARRAY_BUFFER`,绘制时用 `gl.drawElements` 而非 `gl.drawArrays`。EBO 的绑定状态记录在当前 VAO 里。
### 什么场景必须手动管理 Buffer?
动态更新的数据(粒子系统、变形动画)需要 `gl.bufferData` 分配大小后用 `gl.bufferSubData` 局部更新,避免每帧重新分配显存。静态数据(模型网格)创建一次即可,设 `gl.STATIC_DRAW` 提示驱动放显存。
### 多个 VBO 怎么组织到同一个 VAO?
同一个 VAO 绑定期间,依次 `bindBuffer` + `vertexAttribPointer` 注册每个 VBO 到不同的 attribute location。也可以把所有数据交错打包到一个 VBO 里(interleaved),用 stride 和 offset 描述布局,减少 buffer 切换次数。标签
WebGL
WebGL(Web Graphics Library)是一种 JavaScript API,用于在任何兼容的网页浏览器中不使用插件渲染2D和3D图形。它是基于OpenGL ES的规范,旨在在Web平台上提供OpenGL的性能和功能。通过 WebGL,开发者可以为网页应用程序创建复杂的可视化效果、游戏、可视化数据和各种交互式图形体验。

服务端5月29日 22:35
WebGL 雾效(Fog)是如何实现的?WebGL 雾效的本质就是根据片段到相机的距离,在物体颜色和雾颜色之间做插值:`finalColor = mix(fogColor, objectColor, fogFactor)`。三种计算 fogFactor 的方式:线性雾 `clamp((end - dist) / (end - start), 0, 1)` 需要指定起止距离;指数雾 `exp(-density * dist)` 更自然,一个 density 参数搞定;指数平方雾 `exp(-(density*dist)²)` 过渡更柔和。深度值从视图空间的 `-viewPos.z` 或 `length(viewPos.xyz)` 获取,后者基于实际距离而非仅 Z 值,物体旋转时效果更稳定。
## 追问
### 线性雾和指数雾怎么选?
线性雾可控性强,适合有明确近远边界的场景(如走廊)。指数雾只需一个 density 参数,远处自然消融,户外场景首选。指数平方雾过渡最柔和,但远处会突然消失,实际项目很少用。
### 雾颜色一定要和背景色一致吗?
是的,否则远处的物体会被雾染成另一个颜色,而不是"融入背景"。雾颜色 = 清屏颜色 = 天空盒颜色,三者必须统一。
### 雾效能用来做性能优化吗?
可以。远处的物体被雾覆盖后几乎看不见,可以降低远处物体的 LOD 级别甚至不渲染,雾正好遮住裁剪的接缝——这是开放世界游戏的常用技巧。
### Three.js 里的 Fog 和 FogExp2 有什么区别?
`THREE.Fog(color, near, far)` 是线性雾,`THREE.FogExp2(color, density)` 是指数雾。设置 `scene.fog = new THREE.Fog(...)` 后所有材质自动应用,不需要改着色器。自定义 ShaderMaterial 需要手动读取 `fogColor`/`fogDensity`/`fogFar`/`fogNear` uniform。
### 如何实现高度雾(Height Fog)?
标准雾只看距离,高度雾额外考虑世界空间 Y 坐标:低处雾浓、高处雾淡。片段着色器中用 `worldPos.y` 做第二次混合,两个因子相乘就是最终雾浓度。服务端5月29日 22:14
WebGL Cubemap 立方体贴图是什么?有哪些应用场景?Cubemap 是 6 张正方形图片拼成的纹理盒子,用 3D 方向向量采样——GPU 根据向量哪个分量绝对值最大决定落在哪个面上,再换算成 2D 坐标取色。核心用途:天空盒、环境反射(`reflect`)、环境折射(`refract`)、菲涅尔效果。6 张图必须同尺寸且为 2 的幂次方,采样前务必设 `CLAMP_TO_EDGE` 防接缝。
## 追问
### 天空盒为什么必须去掉视图矩阵的平移分量?
天空盒模拟无限远的环境,如果跟着相机平移,走两步就穿帮了。只保留旋转:`mat4 rotOnly = mat4(mat3(viewMatrix))`。
### reflect 和 refract 的区别?
`reflect(I, N)` 计算反射方向——入射光弹回来,用于镜面/金属。`refract(I, N, eta)` 计算折射方向——光穿过透明介质弯折,用于玻璃/水。真实材质两者同时存在,用菲涅尔公式混合:正面看折射为主,侧面看反射为主。
### 动态环境映射性能开销大怎么办?
每帧渲染 6 个面代价太高。常用优化:降低分辨率(64×64 够了,反射本身就模糊)、降低更新频率(每 5-10 帧更新一次)、只给关键物体开动态反射。静态场景用预过滤环境贴图(Prefiltered Env Map),运行时零计算。
### Cubemap 接缝怎么处理?
99% 是忘了设 `CLAMP_TO_EDGE`。设了还有缝,检查 6 张图边缘像素是否连续——很多在线生成工具会在接缝处偏移 1 像素。+Y 面图片经常上下颠倒,用 `gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true)` 翻转。
### Cubemap 和 Equirectangular(经纬度贴图)怎么选?
Cubemap 6 张图,GPU 采样效率高,PBR 管线原生支持。Equirectangular 一张图,存储方便但两极有拉伸畸变,采样需要三角函数计算,性能差。实际工作流:用 Equirectangular 存储/传输,运行时转换为 Cubemap 使用。服务端5月29日 01:40
WebGL 中的阴影(Shadow)是如何实现的?WebGL 阴影的主流实现方式是阴影贴图(Shadow Mapping):先从光源视角渲染场景生成深度图,再从相机视角渲染时将每个片段变换到光源空间,比较其深度与深度图中的值——若片段深度更大则处于阴影中。软阴影通过 PCF(Percentage Closer Filtering)对深度图多次采样取平均实现。两大典型问题:阴影痤疮(shadow acne)由深度精度误差导致,用基于法线-光线夹角的 bias 修复;彼得潘效应(peter panning)由 bias 过大导致阴影脱离物体,改用正面剔除渲染深度图解决。大场景用级联阴影贴图(CSM)按距离分配不同分辨率。
## 追问
- 为什么阴影贴图会产生阴影痤疮?bias 值如何根据表面法线和光线方向动态计算?
- PCF 软阴影的采样半径越大越模糊,但会带来什么性能问题?Poisson Disk 采样有何优势?
- 级联阴影贴图(CSM)如何划分级联?级联接缝处的接缝问题怎么处理?
- 点光源阴影为什么用立方体贴图?渲染立方体阴影贴图需要几次 draw call?
- WebGL 1.0 没有深度纹理附件,如何用 RGBA 编码深度值?精度损失如何补偿?
## 写段代码
```glsl
// PCF 软阴影核心逻辑
float calcShadow(vec4 lightPos, sampler2D shadowMap, vec3 normal, vec3 lightDir) {
vec3 proj = lightPos.xyz / lightPos.w * 0.5 + 0.5;
float bias = max(0.05 * (1.0 - dot(normal, lightDir)), 0.005);
float shadow = 0.0;
vec2 texel = 1.0 / vec2(textureSize(shadowMap, 0));
for (int x = -1; x <= 1; x++)
for (int y = -1; y <= 1; y++) {
float d = texture(shadowMap, proj.xy + vec2(x,y)*texel).r;
shadow += proj.z - bias > d ? 1.0 : 0.0;
}
return shadow / 9.0;
}
```服务端5月29日 01:39
WebGL 是什么?它与 OpenGL 有什么关系?WebGL 是基于 OpenGL ES 的浏览器端 3D 图形 API,关系链为 OpenGL → OpenGL ES → WebGL。WebGL 继承了 OpenGL ES 的可编程渲染管线,通过 GLSL 编写顶点着色器和片段着色器控制 GPU 渲染,但运行在浏览器沙箱中,用 JavaScript 调用,且去除了 OpenGL 的固定管线。核心渲染流程:顶点数据经顶点着色器变换 → 图元装配 → 光栅化 → 片段着色器着色 → 帧缓冲输出。WebGL 1.0 基于 OpenGL ES 2.0,WebGL 2.0 基于 OpenGL ES 3.0。
## 追问
- WebGL 和 Canvas 2D 的本质区别是什么?Canvas 2D 能调用 GPU 吗?
- 顶点缓冲对象(VBO)的作用是什么?为什么不用 CPU 每帧传顶点数据?
- WebGL 的统一变量(uniform)和属性变量(attribute)分别在什么阶段使用?
- WebGL 2.0 相比 1.0 新增了哪些关键能力?VAO 和 3D 纹理对开发有什么影响?
- 浏览器如何保证 WebGL 的安全性?哪些 OpenGL ES 特性被有意移除了?
## 写段代码
```javascript
// WebGL 初始化:创建着色器程序并绘制三角形
const gl = canvas.getContext('webgl');
const vs = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(vs, 'attribute vec2 a_pos; void main(){ gl_Position=vec4(a_pos,0,1); }');
gl.compileShader(vs);
const fs = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(fs, 'precision mediump float; void main(){ gl_FragColor=vec4(1,0,0,1); }');
gl.compileShader(fs);
const prog = gl.createProgram();
gl.attachShader(prog, vs); gl.attachShader(prog, fs);
gl.linkProgram(prog); gl.useProgram(prog);
const buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0,0.5, -0.5,-0.5, 0.5,-0.5]), gl.STATIC_DRAW);
const loc = gl.getAttribLocation(prog, 'a_pos');
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
gl.drawArrays(gl.TRIANGLES, 0, 3);
```服务端5月28日 02:34
WebGL 渲染管线的工作流程是什么?WebGL 渲染管线是将 3D 顶点数据转化为屏幕像素的一系列处理阶段,分为**应用程序阶段(CPU)**和 **GPU 管线阶段**两大部分。其中 GPU 管线又包含**可编程阶段**和**固定功能阶段**,面试中常围绕"哪些阶段可编程、各阶段输入输出是什么"展开追问。
## 管线总览
```
CPU 应用程序阶段
│ 提交绘制命令、设置状态
▼
顶点着色器(可编程)
▼
图元装配 + 裁剪(固定)
▼
光栅化(固定)
▼
片段着色器(可编程)
▼
逐片段测试与混合(固定)
▼
帧缓冲区
```
关键点:整条管线中**只有顶点着色器和片段着色器是可编程的**,其余阶段由 GPU 硬件固定执行。WebGL 2.0 新增了变换反馈(Transform Feedback),可以将顶点着色器的输出回收到缓冲区,实现 GPU 端的粒子计算等效果。
## 一、应用程序阶段(CPU 端)
这是开发者通过 JavaScript 控制的阶段,主要负责:
- **可见性判断**:视锥体剔除、遮挡剔除,只提交可见物体给 GPU
- **准备几何数据**:将顶点位置、法线、UV、颜色等属性写入缓冲区
- **设置渲染状态**:绑定着色器程序、设置 uniform 变量、切换纹理
- **发起绘制调用**:`gl.drawArrays()` 或 `gl.drawElements()`
这一阶段的性能瓶颈通常在 draw call 数量,合并网格和使用实例化渲染(`gl.drawArraysInstanced`)是核心优化手段。
## 二、顶点着色器(可编程阶段)
顶点着色器对每个顶点执行一次,是管线的第一个可编程阶段。
**输入**:
- 顶点属性(`attribute`):位置、法线、UV、颜色
- 全局变量(`uniform`):变换矩阵、光照参数
**核心处理 — MVP 矩阵变换**:
```glsl
attribute vec3 a_position;
attribute vec2 a_texCoord;
uniform mat4 u_model; // 模型矩阵:模型空间 → 世界空间
uniform mat4 u_view; // 视图矩阵:世界空间 → 观察空间
uniform mat4 u_projection; // 投影矩阵:观察空间 → 裁剪空间
varying vec2 v_texCoord;
void main() {
vec4 worldPos = u_model * vec4(a_position, 1.0);
vec4 viewPos = u_view * worldPos;
gl_Position = u_projection * viewPos;
v_texCoord = a_texCoord;
}
```
**输出**:裁剪空间坐标(Clip Space),`gl_Position` 的四个分量 (x, y, z, w) 中 w 用于后续透视除法。
面试追问:为什么用四维齐次坐标?——因为透视投影需要 w 分量来做透视除法,将裁剪空间转为 NDC;平移变换也需要齐次坐标才能用矩阵乘法表示。
## 三、图元装配与裁剪(固定阶段)
**图元装配**:将顶点按绘制模式(`gl.TRIANGLES`、`gl.LINES`、`gl.POINTS`)组装成图元。
**裁剪**:丢弃完全在视锥体外的图元,裁剪部分在视锥体内的图元(可能产生新顶点)。
**透视除法**:将裁剪坐标除以 w 分量,得到标准化设备坐标(NDC),x/y/z 范围均为 [-1, 1]。
**视口变换**:将 NDC 坐标映射到屏幕坐标,由 `gl.viewport(x, y, width, height)` 控制。
## 四、光栅化(固定阶段)
光栅化是将几何图元转换为片段(Fragment)的过程:
1. **三角形遍历**:检查哪些像素被三角形覆盖
2. **插值计算**:顶点属性(颜色、UV、法线)在片段间线性插值,透视校正插值由硬件自动完成
3. **生成片段**:每个被覆盖的像素生成一个片段,携带插值后的属性和深度值
片段不同于像素——片段是候选像素,还需要通过后续测试才能写入帧缓冲。
## 五、片段着色器(可编程阶段)
片段着色器对每个片段执行一次,是管线的第二个可编程阶段。
**输入**:插值后的顶点属性(`varying`)、纹理采样器(`uniform sampler2D`)
**处理**:纹理采样、光照计算、颜色混合
**输出**:最终颜色值,写入 `gl_FragColor`
```glsl
precision mediump float;
varying vec2 v_texCoord;
uniform sampler2D u_texture;
void main() {
gl_FragColor = texture2D(u_texture, v_texCoord);
}
```
## 六、逐片段操作(固定阶段)
片段着色器输出的颜色需要通过一系列测试才能写入帧缓冲:
| 操作 | 作用 |
|------|------|
| 模板测试 | 用模板缓冲区做掩码,限制绘制区域 |
| 深度测试 | 比较片段深度与深度缓冲区,丢弃被遮挡的片段 |
| 混合 | 将片段颜色与帧缓冲已有颜色按 alpha 值混合,实现半透明 |
| 抖动 | 用有限色深模拟更多颜色,减少色带 |
注意:深度测试默认关闭,需 `gl.enable(gl.DEPTH_TEST)` 开启。混合也需要 `gl.enable(gl.BLEND)` 并设置混合函数。
## 七、帧缓冲输出
通过所有测试的片段颜色被写入帧缓冲区。当一帧所有绘制完成后,前后缓冲区交换(双缓冲),画面显示到屏幕。
## 性能优化要点
- **减少 draw call**:合并静态网格为一次绘制;使用实例化渲染(`gl.drawArraysInstanced`)绘制大量相同几何体
- **优化顶点着色器**:MVP 矩阵在 CPU 端预计算 `projection * view * model`,不要在着色器中逐顶点相乘
- **减少过度绘制**:不透明物体从前到后绘制,利用深度测试提前丢弃被遮挡片段;半透明物体从后到前绘制
- **控制片段着色器复杂度**:移动端 GPU 是 tile-based 架构,片段着色器是性能瓶颈,避免 `discard`、复杂分支和过多纹理采样
- **纹理压缩**:使用 ASTC/ETC2 等压缩格式减少显存带宽
## WebGL 1.0 与 2.0 管线差异
| 特性 | WebGL 1.0 | WebGL 2.0 |
|------|-----------|----------|
| GLSL 版本 | 100 (GLSL ES 1.0) | 300 es (GLSL ES 3.0) |
| 变换反馈 | 不支持 | 支持,顶点着色器输出可回收到缓冲区 |
| 多重渲染目标 | 需扩展 | 原生支持 MRT |
| 3D 纹理 | 需扩展 | 原生支持 |
| 实例化渲染 | 需扩展 | 原生支持 |
| 顶点数组对象 | 需扩展 | 原生支持 VAO |
## 面试追问
**Q: WebGL 管线中哪些阶段可编程?**
顶点着色器和片段着色器。WebGL 2.0 新增变换反馈但不算独立阶段,几何着色器 WebGL 不支持。
**Q: 为什么需要透视除法?**
裁剪空间的齐次坐标 (x, y, z, w) 除以 w 后得到 NDC,使不同深度的物体正确投影到屏幕上。没有透视除法,远处的物体不会变小。
**Q: WebGL 和 OpenGL 管线的主要区别?**
WebGL 基于 OpenGL ES,去掉了几何着色器、曲面细分等着色器;运行在浏览器沙箱中,通过 JavaScript API 调用;着色器编译由浏览器驱动完成,不同浏览器可能有性能差异。服务端5月28日 01:36
WebGL 1.0 和 WebGL 2.0 有什么区别?## 核心答案
WebGL 1.0 基于 OpenGL ES 2.0,WebGL 2.0 基于 OpenGL ES 3.0,这是两者最根本的差异。从面试角度,掌握以下五个关键区别即可覆盖大部分考点:
1. **着色器语言升级**:WebGL 1.0 使用 GLSL ES 1.0(`attribute`/`varying`/`gl_FragColor`),WebGL 2.0 使用 GLSL ES 3.0(`in`/`out`/`自定义输出变量`),必须声明 `#version 300 es`。
2. **关键特性从扩展变原生**:3D 纹理、MRT(多重渲染目标)、实例化渲染、VAO(顶点数组对象)在 WebGL 1.0 中需要扩展支持,WebGL 2.0 全部原生提供。
3. **新增能力**:变换反馈(Transform Feedback)、采样器对象、UBO(统一缓冲区对象)、遮挡查询是 WebGL 2.0 独有的。
4. **纹理限制解除**:WebGL 1.0 中非2的幂次纹理(NPOT)不能使用 mipmap 和重复包裹,WebGL 2.0 完全支持。
5. **向后兼容性**:WebGL 2.0 大部分兼容 WebGL 1.0,但着色器编译规则更严格——保留字不可用作变量名、函数重载被禁止、全局变量初始化必须为常量表达式。
**追问:WebGL 2.0 的实例化渲染有什么实际意义?**
实例化渲染允许一次 Draw Call 绘制大量相同几何体但属性不同的物体。典型场景是绘制森林中的树木、草地、粒子群等。WebGL 1.0 需要通过 ANGLE_instanced_arrays 扩展实现,WebGL 2.0 原生支持 `drawArraysInstanced` 和 `drawElementsInstanced`,配合实例化属性(每实例一个矩阵或颜色),可将数千次 Draw Call 缩减为一次,帧率提升 3-5 倍。
## 版本背景与规范演进
WebGL 1.0 于 2011 年由 Khronos Group 发布,规范基于 OpenGL ES 2.0,为浏览器提供了第一套标准化的 GPU 加速图形接口。WebGL 2.0 于 2017 年正式发布,基于 OpenGL ES 3.0,继承了 ES 3.0 的全部新特性,同时保持与 WebGL 1.0 的高度兼容。
OpenGL ES 3.0 相比 2.0 的改进并非小修小补,而是对渲染管线的全面强化。从着色器语言到纹理系统、从缓冲区管理到帧缓冲操作,几乎每个环节都有提升。Khronos 为 WebGL 2.0 准备了比 1.0 大十倍的合规测试套件,大量图形驱动 bug 在此过程中被发现和修复,这也是 WebGL 2.0 稳定性显著优于 1.0 的重要原因。
## 特性对比总览
| 特性 | WebGL 1.0 | WebGL 2.0 |
|------|-----------|-----------|
| **基础规范** | OpenGL ES 2.0 | OpenGL ES 3.0 |
| **着色器版本** | GLSL ES 1.0 | GLSL ES 3.0 |
| **3D 纹理** | 需扩展 | 原生支持 |
| **2D 纹理数组** | 不支持 | 原生支持 |
| **多重渲染目标(MRT)** | 需扩展 | 原生支持 |
| **实例化渲染** | 需扩展 | 原生支持 |
| **变换反馈** | 不支持 | 支持 |
| **采样器对象** | 不支持 | 支持 |
| **顶点数组对象(VAO)** | 需扩展 | 原生支持 |
| **统一缓冲区对象(UBO)** | 不支持 | 支持 |
| **遮挡查询** | 不支持 | 支持 |
| **非2的幂次纹理** | 受限 | 完全支持 |
| **像素缓冲区对象(PBO)** | 不支持 | 支持 |
## 着色器语言差异
GLSL ES 3.0 是 WebGL 2.0 的着色器语言,相比 1.0 版本变化很大,迁移时需要逐项适配。
### 语法变化对照
| WebGL 1.0 (GLSL ES 1.0) | WebGL 2.0 (GLSL ES 3.0) | 说明 |
|--------------------------|--------------------------|------|
| `attribute` | `in` | 顶点着色器输入 |
| `varying` | `out`(顶点)/ `in`(片段) | 阶段间数据传递 |
| `gl_FragColor` | 自定义 `out` 变量 | 片段着色器输出 |
| `texture2D()` | `texture()` | 2D 纹理采样 |
| `textureCube()` | `texture()` | 立方体纹理采样 |
| 无 | `#version 300 es` | 版本声明(必需) |
### WebGL 1.0 着色器示例
```glsl
// 顶点着色器
attribute vec3 a_position;
attribute vec2 a_texCoord;
uniform mat4 u_mvpMatrix;
varying vec2 v_texCoord;
void main() {
gl_Position = u_mvpMatrix * vec4(a_position, 1.0);
v_texCoord = a_texCoord;
}
```
```glsl
// 片段着色器
precision mediump float;
varying vec2 v_texCoord;
uniform sampler2D u_texture;
void main() {
gl_FragColor = texture2D(u_texture, v_texCoord);
}
```
### WebGL 2.0 着色器示例
```glsl
#version 300 es
// 顶点着色器
in vec3 a_position;
in vec2 a_texCoord;
uniform mat4 u_mvpMatrix;
out vec2 v_texCoord;
void main() {
gl_Position = u_mvpMatrix * vec4(a_position, 1.0);
v_texCoord = a_texCoord;
}
```
```glsl
#version 300 es
precision mediump float;
in vec2 v_texCoord;
uniform sampler2D u_texture;
out vec4 fragColor;
void main() {
fragColor = texture(u_texture, v_texCoord);
}
```
### 编译规则变严的坑
迁移到 WebGL 2.0 时,着色器编译可能报一些在 1.0 中不会出现的错误:
- **保留字冲突**:`sample`、`smooth`、`round`、`inverse` 等在 ES 3.0 中成为保留字,不能用作变量名或函数名。
- **全局初始化必须是常量**:`float a = 1.0, b = a;` 这类写法不再合法,`a` 对 `b` 而言不是常量表达式。
- **函数重载被禁止**:不能定义同名不同参数的函数(如 `min(vec2, vec2)` 和 `min(float, float)` 不允许同时存在)。
- **`precision` 限定更严格**:片段着色器中仍需声明默认精度,但在 ES 3.0 中某些隐式转换不再允许。
## WebGL 2.0 核心新特性详解
### 3D 纹理与纹理数组
3D 纹理是体渲染、医学影像、烟雾模拟等场景的基础能力。WebGL 1.0 只能通过扩展勉强实现,WebGL 2.0 原生提供 `TEXTURE_3D` 目标和 `texImage3D` 接口。
```javascript
const texture3D = gl.createTexture();
gl.bindTexture(gl.TEXTURE_3D, texture3D);
gl.texImage3D(
gl.TEXTURE_3D,
0, // mipmap 级别
gl.RGBA8, // 内部格式
width, height, depth,
0, // 边框
gl.RGBA,
gl.UNSIGNED_BYTE,
volumeData
);
gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_WRAP_R, gl.CLAMP_TO_EDGE);
```
着色器中使用 `sampler3D` 采样:
```glsl
#version 300 es
uniform sampler3D u_volumeData;
void main() {
vec4 value = texture(u_volumeData, vec3(u, v, w));
}
```
纹理数组(`TEXTURE_2D_ARRAY`)则是另一种组织方式,它把多层 2D 纹理打包为一个对象,共享相同的尺寸和格式,但每层可以有不同内容。这在地形渲染(每层一种地表纹理)、精灵图集、动画帧序列中非常实用。
### 多重渲染目标(MRT)
MRT 允许片段着色器一次渲染同时输出到多个颜色附件。这是延迟渲染(Deferred Shading)的基石——一次 Pass 就能输出位置、法线、颜色等多张 G-Buffer 纹理。
```javascript
const fb = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
const attachments = [];
for (let i = 0; i < 4; i++) {
const tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16F, width, height, 0, gl.RGBA, gl.FLOAT, null);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0 + i, gl.TEXTURE_2D, tex, 0);
attachments.push(tex);
}
gl.drawBuffers([
gl.COLOR_ATTACHMENT0,
gl.COLOR_ATTACHMENT1,
gl.COLOR_ATTACHMENT2,
gl.COLOR_ATTACHMENT3
]);
```
着色器中声明多个输出:
```glsl
#version 300 es
layout(location = 0) out vec4 gPosition;
layout(location = 1) out vec4 gNormal;
layout(location = 2) out vec4 gAlbedo;
layout(location = 3) out vec4 gSpecular;
void main() {
gPosition = vec4(fragPos, 1.0);
gNormal = vec4(normalize(fragNormal), 1.0);
gAlbedo = vec4(baseColor, 1.0);
gSpecular = vec4(specular, roughness, metallic, 1.0);
}
```
相比 WebGL 1.0 需要多次渲染 Pass 分别输出,MRT 将延迟着色的效率提升了 40% 以上。
### 实例化渲染
实例化渲染解决的是"相同几何体、不同属性"的批量绘制问题。典型场景包括:大规模植被、粒子系统、建筑群等。
```javascript
// 为每个实例准备独立的模型矩阵
const instanceMatrices = new Float32Array(instanceCount * 16);
// ... 填充每个实例的变换矩阵
const instanceBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, instanceBuffer);
gl.bufferData(gl.ARRAY_BUFFER, instanceMatrices, gl.STATIC_DRAW);
// 为矩阵的4列各设置一个属性(mat4 占4个属性位置)
for (let i = 0; i < 4; i++) {
const loc = 3 + i;
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, 4, gl.FLOAT, false, 64, i * 16);
gl.vertexAttribDivisor(loc, 1); // 每实例更新一次
}
// 一次调用绘制所有实例
gl.drawArraysInstanced(gl.TRIANGLES, 0, vertexCount, instanceCount);
```
`vertexAttribDivisor` 是关键——它告诉 GPU 某个属性是逐顶点更新还是逐实例更新。设为 1 即逐实例,设为 0(默认)即逐顶点。
### 变换反馈(Transform Feedback)
变换反馈允许将顶点着色器的输出捕获到缓冲区对象中,而不经过光栅化阶段。这使得 GPU 端的粒子模拟、布料物理、几何处理成为可能——数据在 GPU 上完成计算后直接用于下一帧渲染,无需回读 CPU。
```javascript
const tf = gl.createTransformFeedback();
gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, tf);
// 绑定输出缓冲区
gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 0, outputBuffer);
gl.beginTransformFeedback(gl.POINTS);
gl.drawArrays(gl.POINTS, 0, particleCount);
gl.endTransformFeedback();
// outputBuffer 中现在包含变换后的顶点数据
// 可直接绑定到顶点属性用于下一帧渲染
```
着色器中声明要捕获的输出变量:
```javascript
// 编译链接前设置
gl.transformFeedbackVaryings(
program,
['v_outPosition', 'v_outVelocity'],
gl.SEPARATE_ATTRIBS
);
```
### 统一缓冲区对象(UBO)
UBO 将一组 uniform 变量打包到缓冲区对象中,可以跨多个着色器程序共享,且更新成本远低于逐个 `gl.uniform*` 调用。对于包含大量材质参数的场景(PBR 渲染中的相机参数、光照参数),UBO 能显著减少状态切换开销。
```javascript
const ubo = gl.createBuffer();
gl.bindBuffer(gl.UNIFORM_BUFFER, ubo);
gl.bufferData(gl.UNIFORM_BUFFER, new Float32Array([
// mat4 projection
...projectionMatrix,
// mat4 view
...viewMatrix,
// vec3 cameraPos + padding
cameraPos[0], cameraPos[1], cameraPos[2], 0
]), gl.DYNAMIC_DRAW);
// 绑定到绑定点
gl.bindBufferBase(gl.UNIFORM_BUFFER, 0, ubo);
```
着色器中通过 layout 绑定点访问:
```glsl
#version 300 es
layout(std140) uniform SceneData {
mat4 projection;
mat4 view;
vec3 cameraPos;
};
void main() {
gl_Position = projection * view * vec4(a_position, 1.0);
}
```
### 采样器对象
采样器对象将纹理采样参数(过滤模式、包裹模式)从纹理对象中分离出来。同一个纹理可以搭配不同的采样器,实现一次上传多种采样方式,避免频繁切换纹理参数。
```javascript
const sampler = gl.createSampler();
gl.samplerParameteri(sampler, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
gl.samplerParameteri(sampler, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.samplerParameteri(sampler, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.samplerParameteri(sampler, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
// 绑定到纹理单元 0
gl.bindSampler(0, sampler);
```
### VAO 原生支持
顶点数组对象(VAO)将顶点属性配置(绑定哪个缓冲区、属性指针、启用状态)集中存储为一个对象。绘制时只需绑定 VAO 即可恢复全部配置,省去了逐个调用 `vertexAttribPointer` 和 `enableVertexAttribArray` 的开销。
```javascript
const vao = gl.createVertexArray();
gl.bindVertexArray(vao);
// 以下配置全部记录在 VAO 中
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(0);
gl.bindBuffer(gl.ARRAY_BUFFER, uvBuffer);
gl.vertexAttribPointer(1, 2, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(1);
gl.bindVertexArray(null); // 解绑
// 绘制时
gl.bindVertexArray(vao);
gl.drawArrays(gl.TRIANGLES, 0, count);
gl.bindVertexArray(null);
```
WebGL 1.0 中需要 OES_vertex_array_object 扩展才能使用 VAO,且扩展在各平台的支持程度不一。WebGL 2.0 将其纳入核心规范,保证了跨平台一致性。
## 纹理系统改进
### 非2的幂次纹理(NPOT)
WebGL 1.0 对 NPOT 纹理施加了严格限制:不能使用 mipmap,包裹模式只能是 `CLAMP_TO_EDGE`,过滤模式只能用 `NEAREST` 或 `LINEAR`。这意味着上传一张 300x200 的图片,要么手动补齐到 512x256,要么接受低质量采样。
WebGL 2.0 完全放开了这些限制。任意尺寸的纹理都可以生成 mipmap、使用 `REPEAT` 包裹、配合 `LINEAR_MIPMAP_LINEAR` 过滤。这对 UI 开发和图片展示场景是实质性的改善。
### 新纹理格式
WebGL 2.0 新增了大量纹理内部格式,包括:
- **浮点纹理**:`RGBA32F`、`RGBA16F`,用于 HDR 渲染和 GPGPU 计算。
- **深度纹理**:`DEPTH24_STENCIL8`、`DEPTH32F_STENCIL8`,阴影映射不再需要扩展。
- **整数纹理**:`RGBA8UI`、`RGBA16I`,支持逐纹素读取精确整数值。
- **压缩纹理**:支持 ETC2/EAC 压缩格式,减少显存占用和上传带宽。
### 像素缓冲区对象(PBO)
PBO 允许异步传输纹理数据。上传纹理时通过 PBO 做中转,GPU 可以在后台完成数据搬运,不阻塞主线程,对动态纹理更新(视频纹理、实时数据可视化)非常有利。
```javascript
const pbo = gl.createBuffer();
gl.bindBuffer(gl.PIXEL_UNPACK_BUFFER, pbo);
gl.bufferData(gl.PIXEL_UNPACK_BUFFER, imageData.byteLength, gl.STREAM_DRAW);
// 填充数据
gl.bufferSubData(gl.PIXEL_UNPACK_BUFFER, 0, imageData);
// 异步上传纹理
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, 0);
// 最后一个参数 0 表示从当前绑定的 PBO 读取偏移量
gl.bindBuffer(gl.PIXEL_UNPACK_BUFFER, null);
```
## 帧缓冲区增强
WebGL 2.0 区分了 `DRAW_FRAMEBUFFER` 和 `READ_FRAMEBUFFER` 两个目标。这意味着可以在一个帧缓冲区上绘制,同时从另一个帧缓冲区读取,实现高效的后处理管线(如多 Pass 模糊、SSAO 等)而无需频繁切换帧缓冲区绑定。
帧缓冲区完整性检查也更加细粒度。WebGL 1.0 只有 `FRAMEBUFFER_INCOMPLETE` 之类的笼统状态,WebGL 2.0 提供了 `FRAMEBUFFER_INCOMPLETE_DIMENSIONS`、`FRAMEBUFFER_UNSUPPORTED` 等具体错误码,调试配置问题时更有方向。
## 浏览器支持与检测
截至 2026 年,所有主流浏览器均已支持 WebGL 2.0:
| 浏览器 | WebGL 1.0 | WebGL 2.0 |
|--------|-----------|-----------|
| Chrome | 全版本 | 56+ |
| Firefox | 全版本 | 51+ |
| Safari | 全版本 | 15+ |
| Edge | 全版本 | 79+ |
| IE 11 | 支持 | 不支持 |
检测代码采用渐进增强策略:
```javascript
function getWebGLContext(canvas) {
const gl2 = canvas.getContext('webgl2');
if (gl2) {
console.log('WebGL 2.0 available');
return gl2;
}
const gl1 = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
if (gl1) {
console.log('Falling back to WebGL 1.0');
return gl1;
}
return null;
}
```
实际项目中更推荐使用特性检测而非版本检测——先尝试创建 WebGL 2.0 上下文,检查所需的具体扩展或功能是否存在,再决定渲染路径。
## 迁移注意事项
从 WebGL 1.0 迁移到 2.0 需要关注以下几点:
**着色器适配**是最常见的工作量来源。`attribute` 换 `in`、`varying` 换 `out`/`in`、`gl_FragColor` 换自定义输出、`texture2D` 换 `texture`,这些都是机械替换,但保留字冲突和编译规则变严导致的错误需要逐个排查。
**扩展降级**需要梳理。原来依赖 `WEBGL_draw_buffers`、`OES_vertex_array_object`、`ANGLE_instanced_arrays` 等扩展的代码,在 WebGL 2.0 中应该切换到原生 API。同时要保留 WebGL 1.0 的回退路径。
**NPOT 纹理处理代码可以简化**。原来为适配 WebGL 1.0 限制而写的纹理尺寸补齐逻辑,在 WebGL 2.0 中可以去掉,但保留对 WebGL 1.0 回退路径的兼容。
**性能优化空间重新评估**。迁移完成后,应重新审视渲染管线:MRT 可以简化延迟渲染的 Pass 数量,实例化渲染可以合并同类 Draw Call,UBO 可以减少 uniform 更新开销,变换反馈可以把 CPU 端的粒子计算搬到 GPU。
WebGL 2.0 已经是成熟稳定的标准,Safari 15+ 的全面支持意味着移动端也基本覆盖。对于新项目,建议直接使用 WebGL 2.0 作为基线,WebGL 1.0 仅做降级兜底。对于已有项目,渐进迁移是最稳妥的路线——先跑通 WebGL 2.0 上下文,再逐步将扩展调用替换为原生 API,最后利用新特性优化渲染性能。服务端5月28日 01:36
WebGL 中的纹理(Texture)如何使用?有哪些纹理参数需要配置?## 纹理是 WebGL 渲染的核心机制
纹理(Texture)是将 2D 图像数据映射到 3D 几何体表面的技术。在 WebGL 中,几乎所有视觉效果——地板砖纹、角色皮肤、天空背景——都依赖纹理实现。理解纹理的使用流程和参数配置,是掌握 WebGL 的关键一步。
## 纹理使用的完整流程
### 1. 创建并绑定纹理对象
```javascript
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
```
`bindTexture` 将纹理对象绑定到当前纹理单元,后续所有纹理操作都针对该绑定对象。
### 2. 翻转 Y 轴(面试高频考点)
WebGL 纹理坐标原点在**左下角**,而图片坐标原点在**左上角**。不翻转会导致纹理倒置:
```javascript
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
```
这行代码必须写在 `texImage2D` 之前。面试中经常追问:为什么 WebGL 纹理坐标系与图片坐标系方向相反?答案在于 OpenGL 传统——纹理坐标沿用数学坐标系(Y 向上),而图片格式遵循扫描线顺序(Y 向下)。
### 3. 上传纹理数据
```javascript
// 从 Image 对象加载
gl.texImage2D(
gl.TEXTURE_2D, // 目标
0, // mipmap 级别
gl.RGBA, // 内部格式
gl.RGBA, // 源格式
gl.UNSIGNED_BYTE, // 数据类型
image // Image 对象
);
// 直接上传像素数据
gl.texImage2D(
gl.TEXTURE_2D, 0, gl.RGBA,
width, height, 0, // 宽、高、边框(必须为0)
gl.RGBA, gl.UNSIGNED_BYTE,
pixels // Uint8Array
);
```
### 4. 配置纹理参数
```javascript
// 环绕方式
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);
// 过滤方式
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
```
### 5. 生成 Mipmap(可选)
```javascript
gl.generateMipmap(gl.TEXTURE_2D);
```
Mipmap 会生成一系列逐级减半的纹理副本,在物体远离相机时使用更小的纹理,既提升渲染质量又节省带宽。内存开销仅增加约 30%。
## 纹理参数详解
### 纹理环绕方式(Texture Wrapping)
控制纹理坐标超出 [0, 1] 范围时的行为:
| 参数值 | 效果 | 典型场景 |
|--------|------|----------|
| `gl.REPEAT` | 重复平铺 | 地板砖纹、墙壁图案 |
| `gl.CLAMP_TO_EDGE` | 边缘像素延伸 | 天空盒、非重复贴图 |
| `gl.MIRRORED_REPEAT` | 镜像重复 | 对称图案贴图 |
```
REPEAT: CLAMP_TO_EDGE: MIRRORED_REPEAT:
|ABCD|ABCD| |AAAA|ABCD|DDDD| |ABCD|DCBA|ABCD|
|ABCD|ABCD| |AAAA|ABCD|DDDD| |ABCD|DCBA|ABCD|
```
**面试追问**:非 2 的幂次(NPOT)纹理只能使用 `CLAMP_TO_EDGE`,不能使用 `REPEAT`,也不能生成 Mipmap。这是 WebGL1 的重要限制,WebGL2 已解除。
### 纹理过滤方式(Texture Filtering)
#### 放大过滤(MAG_FILTER)
纹理被放大时(纹理像素 < 屏幕像素):
| 参数值 | 效果 |
|--------|------|
| `gl.NEAREST` | 最近邻采样,像素化效果,速度快 |
| `gl.LINEAR` | 双线性插值,平滑效果(推荐) |
#### 缩小过滤(MIN_FILTER)
纹理被缩小时(纹理像素 > 屏幕像素):
| 参数值 | 效果 |
|--------|------|
| `gl.NEAREST` | 最近邻采样 |
| `gl.LINEAR` | 双线性插值 |
| `gl.NEAREST_MIPMAP_NEAREST` | 选最近 mipmap 级别 + 最近采样 |
| `gl.LINEAR_MIPMAP_NEAREST` | 选最近 mipmap 级别 + 线性插值 |
| `gl.NEAREST_MIPMAP_LINEAR` | mipmap 间线性过渡 + 最近采样 |
| `gl.LINEAR_MIPMAP_LINEAR` | 三线性过滤,质量最高 |
```javascript
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
```
## 着色器中的纹理使用
### 顶点着色器
```glsl
attribute vec3 a_position;
attribute vec2 a_texCoord;
uniform mat4 u_mvpMatrix;
varying vec2 v_texCoord;
void main() {
gl_Position = u_mvpMatrix * vec4(a_position, 1.0);
v_texCoord = a_texCoord;
}
```
### 片段着色器
```glsl
precision mediump float;
varying vec2 v_texCoord;
uniform sampler2D u_texture;
void main() {
gl_FragColor = texture2D(u_texture, v_texCoord);
}
```
### JavaScript 端绑定
```javascript
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.uniform1i(textureLocation, 0); // 对应 TEXTURE0
```
**面试追问**:`gl.uniform1i` 传入的 0 代表什么?它指定纹理单元的索引,0 对应 `gl.TEXTURE0`,1 对应 `gl.TEXTURE1`,以此类推。
## 纹理坐标系
```
(0, 1) ──────── (1, 1)
│ │
│ 纹理图像 │
│ │
(0, 0) ──────── (1, 0)
```
- 原点在**左下角**(Y 向上),与图片坐标(左上角,Y 向下)相反
- 坐标范围 [0, 1],与纹理实际像素尺寸无关
- 超出 [0, 1] 范围的行为由环绕方式决定
## 多纹理混合
WebGL 支持同时使用多个纹理,通过纹理单元(Texture Unit)管理:
```javascript
// 纹理1 绑定到 TEXTURE0
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture1);
gl.uniform1i(texture1Location, 0);
// 纹理2 绑定到 TEXTURE1
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, texture2);
gl.uniform1i(texture2Location, 1);
```
```glsl
// 片段着色器
uniform sampler2D u_texture1;
uniform sampler2D u_texture2;
varying vec2 v_texCoord;
void main() {
vec4 color1 = texture2D(u_texture1, v_texCoord);
vec4 color2 = texture2D(u_texture2, v_texCoord);
gl_FragColor = mix(color1, color2, 0.5);
}
```
## 立方体纹理(CubeMap)
CubeMap 由 6 个正方形纹理组成,分别对应立方体的 6 个面。与 2D 纹理的关键区别:
| 特性 | 2D 纹理 | CubeMap |
|------|---------|---------|
| 目标 | `gl.TEXTURE_2D` | `gl.TEXTURE_CUBE_MAP` |
| 采样器 | `sampler2D` | `samplerCube` |
| 坐标 | 二维 (s, t) | 三维方向向量 (x, y, z) |
| 采样函数 | `texture2D()` | `textureCube()` |
| 典型用途 | 表面贴图 | 环境反射、天空盒 |
```javascript
// 创建 CubeMap
const cubeTexture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_CUBE_MAP, cubeTexture);
// 为六个面分别设置纹理
const faces = [
gl.TEXTURE_CUBE_MAP_POSITIVE_X,
gl.TEXTURE_CUBE_MAP_NEGATIVE_X,
gl.TEXTURE_CUBE_MAP_POSITIVE_Y,
gl.TEXTURE_CUBE_MAP_NEGATIVE_Y,
gl.TEXTURE_CUBE_MAP_POSITIVE_Z,
gl.TEXTURE_CUBE_MAP_NEGATIVE_Z,
];
faces.forEach((face, i) => {
gl.texImage2D(face, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, images[i]);
});
```
## 完整纹理加载函数
```javascript
function loadTexture(gl, url) {
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
// 临时 1x1 蓝色像素,图片加载完成前使用
gl.texImage2D(
gl.TEXTURE_2D, 0, gl.RGBA,
1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE,
new Uint8Array([0, 0, 255, 255])
);
const image = new Image();
image.onload = function () {
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
gl.texImage2D(
gl.TEXTURE_2D, 0, gl.RGBA,
gl.RGBA, gl.UNSIGNED_BYTE, image
);
if (isPowerOf2(image.width) && isPowerOf2(image.height)) {
gl.generateMipmap(gl.TEXTURE_2D);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER,
gl.LINEAR_MIPMAP_LINEAR);
} else {
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);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
}
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
};
image.src = url;
return texture;
}
function isPowerOf2(value) {
return (value & (value - 1)) === 0;
}
```
## 性能优化要点
- **纹理图集(Texture Atlas)**:将多个小纹理合并为一张大图,用不同 UV 坐标区分,减少纹理切换和绘制调用
- **优先使用 2 的幂次尺寸**:128、256、512、1024、2048,才能启用 Mipmap 和 REPEAT 环绕
- **纹理尺寸上限**:移动端保证支持 2048x2048,通过 `gl.getParameter(gl.MAX_TEXTURE_SIZE)` 查询实际限制
- **压缩纹理格式**:DXT(桌面端)、ETC(Android)、PVRTC(iOS),减少显存占用和加载时间
- **及时释放纹理**:`gl.deleteTexture(texture)` 回收 GPU 资源,避免内存泄漏
- **Mipmap 内存开销**:仅增加约 30%,但在远距离渲染时显著提升质量和性能服务端5月28日 01:35
WebGL 中的 Shader 是什么?顶点着色器和片段着色器有什么区别?## Shader 是什么
Shader(着色器)是运行在 GPU 上的小型程序,负责控制图形渲染管线的各个阶段。WebGL 使用 GLSL(OpenGL Shading Language)编写着色器代码,一个最小的 WebGL 渲染程序必须包含两个着色器:顶点着色器和片段着色器。
理解 Shader 的关键在于搞清楚渲染管线的数据流向:JavaScript 将顶点数据传入 GPU → 顶点着色器逐顶点处理 → 图元装配与光栅化 → 片段着色器逐像素着色 → 最终输出到屏幕。
## 顶点着色器(Vertex Shader)
顶点着色器是渲染管线的第一个可编程阶段,对每个顶点执行一次。它的核心职责是坐标变换——将模型空间的 3D 坐标经过一系列矩阵变换映射到裁剪空间。
### 输入与输出
- **attribute**:每个顶点独有的数据,如位置(`a_position`)、法线(`a_normal`)、纹理坐标(`a_texCoord`)。只在顶点着色器中可用。
- **uniform**:对所有顶点统一的只读数据,如变换矩阵、光照参数。两种着色器都能访问。
- **输出 `gl_Position`**:裁剪空间中的顶点位置,这是顶点着色器必须设置的内置变量。
- **varying**:向片段着色器传递的插值数据,如颜色、纹理坐标。GPU 会在顶点之间自动对 varying 变量做线性插值。
### 代码示例
```glsl
attribute vec3 a_position;
attribute vec2 a_texCoord;
attribute vec3 a_normal;
uniform mat4 u_modelMatrix;
uniform mat4 u_viewMatrix;
uniform mat4 u_projectionMatrix;
varying vec2 v_texCoord;
varying vec3 v_normal;
void main() {
// MVP 变换:模型空间 → 世界空间 → 观察空间 → 裁剪空间
mat4 mvp = u_projectionMatrix * u_viewMatrix * u_modelMatrix;
gl_Position = mvp * vec4(a_position, 1.0);
// 传递插值数据给片段着色器
v_texCoord = a_texCoord;
v_normal = mat3(u_modelMatrix) * a_normal;
}
```
### 关键细节
- `gl_Position` 是 `vec4` 类型,第四个分量 `w` 用于透视除法,硬件会自动执行 `gl_Position.xyz / gl_Position.w` 得到 NDC 坐标。
- 法线变换不能直接用模型矩阵,应使用模型矩阵的逆转置矩阵(`transpose(inverse(mat3(u_modelMatrix)))`),在存在非均匀缩放时尤其重要。
- 如果不需要某些顶点,可以将 `gl_Position.w` 设为负值使其被裁剪掉,这比条件分支效率更高。
## 片段着色器(Fragment Shader)
片段着色器是渲染管线的最后一个可编程阶段,对光栅化后产生的每个片段(可理解为潜在像素)执行一次。它的核心职责是计算每个像素的最终颜色。
### 输入与输出
- **varying**:从顶点着色器插值而来的数据。同名 varying 变量在两个着色器中声明后,GPU 会自动完成插值传递。
- **uniform**:纹理采样器(`sampler2D`)、材质参数、光照信息等。
- **输出**:WebGL 1.0 中使用内置变量 `gl_FragColor`(`vec4`);WebGL 2.0 中可自定义输出变量 `out vec4 fragColor`。
### 代码示例
```glsl
precision mediump float;
varying vec2 v_texCoord;
varying vec3 v_normal;
uniform sampler2D u_texture;
uniform vec3 u_lightDir;
uniform float u_opacity;
void main() {
// 纹理采样
vec4 texColor = texture2D(u_texture, v_texCoord);
// 简单的漫反射光照
vec3 normal = normalize(v_normal);
float diff = max(dot(normal, normalize(u_lightDir)), 0.0);
vec3 diffuse = diff * texColor.rgb;
// 环境光 + 漫反射
vec3 ambient = 0.1 * texColor.rgb;
vec3 finalColor = ambient + diffuse;
gl_FragColor = vec4(finalColor, texColor.a * u_opacity);
}
```
### 关键细节
- `precision mediump float` 声明浮点精度,移动端推荐 `mediump` 或 `lowp` 以提升性能,桌面端可用 `highp`。
- 片段着色器执行次数远多于顶点着色器(一个三角形 3 个顶点可能产生数千个片段),因此片段着色器的性能优化通常影响更大。
- WebGL 2.0 支持多渲染目标(MRT),可同时输出多个颜色附件,用于延迟渲染等高级技术。
## 顶点着色器 vs 片段着色器
| 维度 | 顶点着色器 | 片段着色器 |
|------|-----------|----------|
| 执行粒度 | 每个顶点执行一次 | 每个片段执行一次 |
| 核心职责 | 坐标变换、顶点属性计算 | 像素颜色计算、纹理采样 |
| 必需输出 | `gl_Position` | `gl_FragColor`(WebGL1)/ 自定义 out(WebGL2) |
| 典型操作 | 矩阵乘法、顶点光照 | 纹理采样、逐像素光照、Alpha 混合 |
| 性能影响 | 顶点数多时显著 | 通常性能瓶颈所在 |
| 精度建议 | 通常使用 `highp` | 移动端优先 `mediump` |
| 数据来源 | attribute + uniform | varying + uniform |
## varying 变量的插值机制
varying 是两个着色器之间的核心通信桥梁,理解它的插值机制对正确使用 Shader 至关重要。
GPU 在光栅化阶段对顶点着色器输出的 varying 值做重心坐标插值。例如一个三角形三个顶点输出 `v_color` 分别为红、绿、蓝,那么三角形内部任意一点的 `v_color` 就是三个顶点颜色的加权混合,权重由该点的重心坐标决定。
需要注意:
- **透视校正插值**:WebGL 默认做透视正确的插值,这是硬件自动完成的,开发者无需手动处理。但在某些特殊场景(如屏幕空间效果)下需要理解这一机制。
- **整型 varying**:WebGL 2.0 支持 `flat` 限定符,禁用插值,只取provoking vertex的值,适用于传递整型数据或不需要平滑过渡的场景。
## 着色器编译与链接流程
WebGL 中使用着色器的完整流程如下:
```javascript
// 1. 创建着色器对象
const vertexShader = gl.createShader(gl.VERTEX_SHADER);
const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
// 2. 绑定源码
gl.shaderSource(vertexShader, vertexSource);
gl.shaderSource(fragmentShader, fragmentSource);
// 3. 编译(必须检查编译错误)
gl.compileShader(vertexShader);
gl.compileShader(fragmentShader);
if (!gl.getShaderParameter(vertexShader, gl.COMPILE_STATUS)) {
console.error(gl.getShaderInfoLog(vertexShader));
}
// 4. 创建程序并附加着色器
const program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
// 5. 链接程序(也需检查错误)
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error(gl.getProgramInfoLog(program));
}
// 6. 使用程序
gl.useProgram(program);
```
实际开发中务必检查 `COMPILE_STATUS` 和 `LINK_STATUS`,否则着色器错误会被静默吞掉,极难排查。
## 常见面试追问及解答
**Q:为什么说片段着色器通常是性能瓶颈?**
因为片段数量远大于顶点数量。一个 1920x1080 的屏幕有约 200 万像素,而一个场景的顶点数通常在几千到几万。片段着色器每帧执行的次数可能是顶点着色器的上百倍,因此减少片段着色器的计算量对整体性能影响更大。
**Q:attribute 和 uniform 有什么区别?**
attribute 是逐顶点变化的数据,每个顶点可以有不同的值(如位置、颜色),通过顶点缓冲区传入。uniform 是全局统一的数据,所有顶点或片段共享同一个值(如变换矩阵、光照方向),通过 `gl.uniform*()` 设置。attribute 只能在顶点着色器中声明,uniform 在两种着色器中都能使用。
**Q:WebGL 1.0 和 WebGL 2.0 的着色器有什么主要区别?**
WebGL 2.0 基于 OpenGL ES 3.0,着色器语言从 GLSL ES 1.0 升级到 GLSL ES 3.0。主要变化包括:`attribute`/`varying` 改为 `in`/`out` 语法;支持多渲染目标(MRT);新增 transform feedback 可将顶点着色器输出回写到缓冲区;支持 3D 纹理和更多纹理单元;`gl_FragColor` 替换为自定义输出变量。编写 WebGL 2.0 着色器需在首行声明 `#version 300 es`。
**Q:如何在着色器中调试?**
GPU 着色器无法直接打断点调试。常用的调试方法有:将变量值映射到颜色输出可视化;使用 `gl.getShaderInfoLog` 检查编译错误;借助浏览器的 Spector.js 扩展捕获和分析每一帧的 WebGL 调用;对于复杂逻辑,先在 CPU 端用 JavaScript 实现验证后再移植到 GLSL。服务端5月28日 01:34
WebGL 性能优化有哪些常用技巧?## 减少 Draw Call
Draw Call 是 CPU 向 GPU 提交绘制命令的过程,每次调用都有固定开销,是最常见的性能瓶颈。
**批量绘制(Batching)**:将使用相同着色器的多个网格合并到一个缓冲区,用一次 `drawArrays` 替代多次调用。适合静态场景中的同类物体。
**实例化渲染(Instanced Rendering)**:WebGL 2.0 原生支持,适合渲染大量相同几何体(如森林中的树木、粒子系统):
```javascript
gl.drawArraysInstanced(gl.TRIANGLES, 0, vertexCount, instanceCount);
```
实例化通过 `gl.vertexAttribDivisor` 让每个实例读取不同的属性(位置、颜色等),一次 Draw Call 完成。
**关键指标**:移动端 Draw Call 超过 100 次就可能卡顿,桌面端建议控制在 2000 次以内。
## 减少状态切换
每次切换着色器程序、纹理、Blend 模式等状态都会触发 GPU 管线刷新。优化思路:
- **按状态排序**:先按着色器分组,再按纹理分组,减少 `gl.useProgram` 和 `gl.bindTexture` 调用次数
- **纹理图集(Texture Atlas)**:将多张小纹理合并为一张大图,通过 UV 偏移采样,只需绑定一次纹理
- **VAO(Vertex Array Object)**:WebGL 2.0 原生支持,将顶点属性配置缓存到 VAO 中,切换时只需 `gl.bindVertexArray(vao)`,替代逐个设置 `vertexAttribPointer`
## 着色器优化
**CPU 预计算**:将 `projection * view * model` 矩阵在 JS 端乘好再传入着色器,避免每个顶点重复计算:
```glsl
uniform mat4 u_mvpMatrix;
void main() {
gl_Position = u_mvpMatrix * vec4(a_position, 1.0);
}
```
**精度控制**:移动端 GPU 对精度敏感,合理使用精度修饰符可提升 2-5 倍性能:
```glsl
highp mat4 u_mvpMatrix; // 变换矩阵必须高精度
mediump vec2 a_texCoord; // 纹理坐标中精度足够
lowp vec4 v_color; // 颜色低精度即可
```
移动端片段着色器默认 `mediump float` 即可,盲目使用 `highp` 会显著拖慢渲染。
**将计算移至顶点着色器**:光照计算从片段着色器移到顶点着色器,利用硬件插值,减少逐像素计算量。
**减少分支**:GPU 是 SIMT 架构,动态分支(`if/else`)会导致同一 warp 内线程分叉执行,性能骤降。用 `mix`、`step` 等内置函数替代条件判断。
## 几何与缓冲区优化
**索引绘制**:用 `gl.drawElements` + 索引缓冲区复用顶点。一个立方体从 36 个顶点减少到 8 个顶点 + 36 个索引,顶点数据减少约 75%。
**交错顶点数据(Interleaved Arrays)**:将 position、color、texCoord 打包到一个缓冲区,比分离缓冲区缓存命中率更高:
```javascript
// x,y,z, r,g,b, u,v 交错排列
const vertices = new Float32Array([
0,0,0, 1,0,0, 0,0,
1,0,0, 0,1,0, 1,0,
]);
```
**Draco 压缩**:对 glTF 模型使用 Google Draco 压缩,典型压缩率可达 10:1,大幅减少加载时间和内存占用。
## 纹理优化
**纹理压缩**:使用 GPU 原生支持的压缩格式(S3TC/DXT for 桌面、ETC2 for Android、ASTC for iOS),减少 75% 显存占用:
```javascript
const ext = gl.getExtension("WEBGL_compressed_texture_s3tc");
gl.compressedTexImage2D(gl.TEXTURE_2D, 0, ext.COMPRESSED_RGBA_S3TC_DXT5_EXT, w, h, 0, data);
```
**Mipmap**:对会缩小的纹理开启 Mipmap,`LINEAR_MIPMAP_LINEAR` 过滤既提升渲染质量又减少纹理采样带宽。注意 2 的幂次尺寸才能自动生成 Mipmap。
**LOD(Level of Detail)**:根据物体与相机距离切换不同精度的纹理和模型,远距离用低精度资源,近处用高精度。
## 剔除策略
**视锥体剔除(Frustum Culling)**:用包围盒做快速判定,完全在视锥外的物体直接跳过,避免提交 GPU:
```javascript
for (const obj of scene.objects) {
if (isInFrustum(obj.boundingBox, vpMatrix)) {
obj.render();
}
}
```
**背面剔除**:`gl.enable(gl.CULL_FACE)` 一行代码即可剔除背向相机的三角形,减少约 50% 的片元处理量。
**遮挡查询**:WebGL 2.0 的 `gl.beginQuery(gl.ANY_SAMPLES_PASSED, query)` 可判断物体是否被遮挡,被遮挡则跳过精细渲染。
**从前往后排序**:不透明物体按距相机由近到远绘制,利用 Early-Z 测试减少 overdraw。
## 分辨率与帧缓冲区
**DPR 限制**:高 DPI 屏幕上不必按完整 `devicePixelRatio` 渲染:
```javascript
const dpr = Math.min(window.devicePixelRatio, 2); // 上限 2x
canvas.width = canvas.clientWidth * dpr;
```
**动态降分辨率**:帧率低于阈值时自动降低渲染分辨率,用 CSS 缩放回原始尺寸,用户几乎无感知但帧率可提升 30-50%。
**延迟渲染 G-Buffer 精度**:位置用 RGBA16F,法线用 RGB10_A2,材质用 RGBA8,避免全部高精度浪费带宽。
## JavaScript 层优化
**避免 GC**:渲染循环内不创建新对象,预分配 `Float32Array` 复用:
```javascript
const matrix = new Float32Array(16);
function update() {
mat4.identity(matrix); // 复用
}
```
**OffscreenCanvas + WebWorker**:将渲染逻辑移至 Worker 线程,避免阻塞主线程 UI 响应:
```javascript
const offscreen = canvas.transferControlToOffscreen();
const worker = new Worker("renderer.js");
worker.postMessage({ canvas: offscreen }, [offscreen]);
```
**及时释放资源**:不再使用的纹理、缓冲区立即 `gl.deleteTexture` / `gl.deleteBuffer`,避免显存泄漏。
## 性能监控
**GPU 计时**:`EXT_disjoint_timer_query` 扩展可精确测量 GPU 执行时间,定位渲染瓶颈:
```javascript
const ext = gl.getExtension("EXT_disjoint_timer_query");
const query = ext.createQueryEXT();
ext.beginQueryEXT(ext.TIME_ELAPSED_EXT, query);
drawScene();
ext.endQueryEXT(ext.TIME_ELAPSED_EXT);
```
**Chrome DevTools**:Performance 面板可分析帧时间分布,判断瓶颈在 CPU(JS 逻辑)还是 GPU(渲染管线)。
**Spector.js**:浏览器扩展,逐帧记录所有 WebGL 调用,直观发现冗余状态切换和 Draw Call 问题。
## 面试回答要点
回答 WebGL 性能优化时,按优先级组织:先说 Draw Call 和状态切换(最常见瓶颈),再说着色器优化和几何优化(GPU 侧),最后说 JS 层和监控工具。关键是展示系统性思维——知道瓶颈在哪、用什么工具定位、用什么方案解决。实际项目中,90% 的性能问题来自 Draw Call 过多和 overdraw,优先解决这两个问题通常就能获得显著提升。服务端5月28日 01:34
WebGL 帧缓冲区和离屏渲染的原理是什么?## 核心回答
帧缓冲区(Framebuffer Object, FBO)是 WebGL 中用于离屏渲染的机制。它允许将渲染结果输出到纹理或渲染缓冲区,而非直接显示到屏幕上。离屏渲染则是利用 FBO 将场景先渲染到纹理,再对纹理做二次处理或合成的技术。
一个完整的 FBO 由三种附件组成:
- **颜色附件**:存储颜色信息,通常绑定纹理(以便后续采样)
- **深度附件**:存储深度值,通常绑定渲染缓冲区(写入性能更优)
- **模板附件**:存储模板值,用于遮罩测试
## 创建帧缓冲区的完整步骤
创建一个可用的 FBO 分为四步:创建帧缓冲区对象、创建颜色附件(纹理)、创建深度附件(渲染缓冲区)、绑定并检查完整性。
```javascript
// 1. 创建帧缓冲区
const fbo = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
// 2. 创建颜色附件 — 绑定纹理(后续需要采样)
const colorTex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, colorTex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_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);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, colorTex, 0);
// 3. 创建深度附件 — 绑定渲染缓冲区(写入更快)
const depthRb = gl.createRenderbuffer();
gl.bindRenderbuffer(gl.RENDERBUFFER, depthRb);
gl.renderbufferStorage(gl.RENDERBUFFER, gl.DEPTH_COMPONENT16, width, height);
gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, depthRb);
// 4. 检查完整性
const status = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
if (status !== gl.FRAMEBUFFER_COMPLETE) {
console.error('Framebuffer 不完整:', status);
}
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
```
`checkFramebufferStatus` 返回的可能值包括:`FRAMEBUFFER_INCOMPLETE_ATTACHMENT`(附件格式不支持)、`FRAMEBUFFER_INCOMPLETE_MISSING_ATTACHMENT`(没有任何附件)、`FRAMEBUFFER_INCOMPLETE_DIMENSIONS`(附件尺寸不一致)、`FRAMEBUFFER_UNSUPPORTED`(格式组合不被支持)。生产环境务必处理这些异常。
## 纹理与渲染缓冲区如何选择
| 特性 | 纹理(Texture) | 渲染缓冲区(Renderbuffer) |
|------|-----------------|---------------------------|
| 可采样 | 可以作为纹理在 shader 中读取 | 不能直接读取 |
| 写入性能 | 稍慢 | 更快,专门为写入优化 |
| 典型用途 | 颜色附件(需要后续采样) | 深度/模板附件(不需要读取) |
选择原则很简单:如果渲染结果需要在后续 pass 中采样,用纹理;如果只是写入不读取,用渲染缓冲区。因此颜色附件几乎总是纹理,深度附件几乎总是渲染缓冲区。
## 离屏渲染的基本流程
离屏渲染的核心是"先渲染到纹理,再用纹理渲染到屏幕"的两 pass 模式:
```javascript
// Pass 1: 渲染到 FBO
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
gl.viewport(0, 0, fboWidth, fboHeight);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
drawScene();
// Pass 2: 用 FBO 的颜色纹理渲染到屏幕
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.viewport(0, 0, canvas.width, canvas.height);
gl.bindTexture(gl.TEXTURE_2D, colorTex);
drawFullscreenQuad();
```
关键点:每次切换渲染目标时必须调用 `gl.viewport` 对齐视口,否则渲染结果会被裁剪或拉伸。
## 多重渲染目标(MRT)
WebGL 2.0 支持同时输出到多个颜色附件,这是延迟渲染(Deferred Rendering)的基础:
```javascript
// 创建 FBO 并附加多个颜色纹理
for (let i = 0; i < 4; i++) {
const tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0 + i, gl.TEXTURE_2D, tex, 0);
}
// 指定要绘制的附件
gl.drawBuffers([
gl.COLOR_ATTACHMENT0,
gl.COLOR_ATTACHMENT1,
gl.COLOR_ATTACHMENT2,
gl.COLOR_ATTACHMENT3,
]);
```
对应的 fragment shader 使用 `layout(location)` 输出:
```glsl
#version 300 es
layout(location = 0) out vec4 gPosition;
layout(location = 1) out vec4 gNormal;
layout(location = 2) out vec4 gAlbedo;
layout(location = 3) out vec4 gMaterial;
void main() {
gPosition = vec4(worldPos, 1.0);
gNormal = vec4(normal, 0.0);
gAlbedo = texture(u_diffuseMap, texCoord);
gMaterial = vec4(roughness, metallic, ao, 1.0);
}
```
注意 WebGL 1.0 不支持 MRT,只有一个 `COLOR_ATTACHMENT0`。
## 常见应用场景
**后期处理**:先将场景渲染到纹理,再对该纹理做模糊、色调映射、Bloom 等效果。这是 FBO 最常见的用途。
**阴影贴图**:从光源视角渲染深度到 FBO 的深度纹理,然后在主渲染 pass 中采样该深度纹理判断像素是否在阴影中。
**反射与折射**:将环境渲染到立方体贴图的 6 个面(每个面对应一个 FBO),再在反射物体上采样该环境贴图。
**延迟渲染**:利用 MRT 将位置、法线、材质参数写入 G-Buffer(多个颜色附件),然后在光照 pass 中逐像素计算,避免对每个光源重复渲染几何体。
## 性能优化要点
- 复用 FBO 而非每帧创建销毁,创建和销毁是昂贵的 GPU 操作
- 颜色附件尺寸够用即可,过大的纹理浪费显存和带宽
- 多个 FBO 可以共享同一个深度渲染缓冲区,减少内存占用
- 深度附件用渲染缓冲区而非纹理,除非需要采样深度值(如阴影贴图)
- 优先在初始化阶段创建所有 FBO,运行时只做 bind/unbind
## 封装帧缓冲区管理类
在实际项目中建议将 FBO 操作封装成类,管理生命周期和资源释放:
```javascript
class Framebuffer {
constructor(gl, width, height, options = {}) {
this.gl = gl;
this.width = width;
this.height = height;
this.fbo = gl.createFramebuffer();
this.textures = {};
this.renderbuffers = {};
gl.bindFramebuffer(gl.FRAMEBUFFER, this.fbo);
if (options.color !== false) {
this._addColorAttachment(0);
}
if (options.depth) {
this._addDepthAttachment();
}
const status = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
if (status !== gl.FRAMEBUFFER_COMPLETE) {
throw new Error(`Framebuffer 不完整: ${status}`);
}
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
}
_addColorAttachment(index) {
const gl = this.gl;
const tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, this.width, this.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_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);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0 + index, gl.TEXTURE_2D, tex, 0);
this.textures[`color${index}`] = tex;
}
_addDepthAttachment() {
const gl = this.gl;
const rb = gl.createRenderbuffer();
gl.bindRenderbuffer(gl.RENDERBUFFER, rb);
gl.renderbufferStorage(gl.RENDERBUFFER, gl.DEPTH_COMPONENT16, this.width, this.height);
gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, rb);
this.renderbuffers.depth = rb;
}
bind() {
this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, this.fbo);
this.gl.viewport(0, 0, this.width, this.height);
}
unbind() {
this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, null);
}
getTexture(name = 'color0') {
return this.textures[name];
}
resize(width, height) {
this.destroy();
this.width = width;
this.height = height;
this.fbo = this.gl.createFramebuffer();
this.textures = {};
this.renderbuffers = {};
this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, this.fbo);
this._addColorAttachment(0);
this._addDepthAttachment();
this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, null);
}
destroy() {
const gl = this.gl;
gl.deleteFramebuffer(this.fbo);
Object.values(this.textures).forEach(t => gl.deleteTexture(t));
Object.values(this.renderbuffers).forEach(r => gl.deleteRenderbuffer(r));
}
}
```
## 追问
**Q: FBO 的颜色附件能绑定渲染缓冲区吗?**
可以。如果颜色结果不需要在 shader 中采样(比如只做中间验证),绑定渲染缓冲区性能更好。但绝大多数场景下颜色附件需要采样,所以通常绑定纹理。
**Q: WebGL 1.0 和 2.0 在 FBO 上有哪些关键差异?**
WebGL 1.0 只支持一个颜色附件(`COLOR_ATTACHMENT0`),不支持 MRT;WebGL 2.0 通过 `drawBuffers` 支持多个颜色附件。此外 WebGL 2.0 支持更多纹理格式作为附件,如浮点纹理和整数纹理。
**Q: FBO 切换是否有性能开销?**
有。每次 `bindFramebuffer` 都会导致 GPU 状态切换,频繁切换会降低性能。建议按渲染 pass 分组,尽量减少 bind/unbind 次数,或在同一 FBO 中用 MRT 替代多 pass。服务端5月28日 01:33
WebGL 中的光照模型有哪些?如何实现 Phong 光照模型?## WebGL 光照模型有哪些?
WebGL 中常用的光照模型分为两类:**局部光照模型**和**全局光照模型**。面试中重点考察的是局部光照模型,核心有三种:
| 光照模型 | 特点 | 典型用途 |
|---------|------|---------|
| Lambert(漫反射) | 仅计算漫反射,无高光 | 粗糙表面如墙壁、布料 |
| Phong | 环境光 + 漫反射 + 镜面反射 | 通用物体渲染 |
| Blinn-Phong | Phong 改进版,用半角向量替代反射向量 | 实际项目首选 |
此外还有基于物理的 PBR(Physically Based Rendering)模型,它用粗糙度、金属度等物理参数替代 Phong 的经验参数,渲染结果更真实,是现代引擎的主流方案。
## Phong 光照模型的三个分量
Phong 模型的核心公式:**最终颜色 = 环境光 + 漫反射 + 镜面反射**
### 环境光(Ambient)
模拟间接光照,不依赖光源位置,给场景一个基础亮度:
```glsl
vec3 ambient = ambientStrength * lightColor;
```
### 漫反射(Diffuse)
遵循**朗伯余弦定律**:表面接收的光量与光线和法线的夹角余弦成正比:
```glsl
float diff = max(dot(normal, lightDir), 0.0);
vec3 diffuse = diff * lightColor;
```
当光线与法线垂直时 `dot` 为 0,背面为负数,所以用 `max(..., 0.0)` 截断。
### 镜面反射(Specular)
模拟光滑表面的高光,取决于观察方向与反射方向的接近程度:
```glsl
// Phong 模型:用反射向量
vec3 reflectDir = reflect(-lightDir, normal);
float spec = pow(max(dot(viewDir, reflectDir), 0.0), shininess);
// Blinn-Phong 模型:用半角向量(更高效)
vec3 halfwayDir = normalize(lightDir + viewDir);
float spec = pow(max(dot(normal, halfwayDir), 0.0), shininess);
```
`shininess` 越大高光越集中,一般取 32、64、128 等 2 的幂次。
## Phong 与 Blinn-Phong 的区别
这是面试高频追问点:
- **Phong** 用反射向量 R,计算 `dot(V, R)`,高光在掠射角时会出现截断瑕疵
- **Blinn-Phong** 用半角向量 H = normalize(L + V),计算 `dot(N, H)`,避免了反射向量计算(省去 `reflect`),高光过渡更自然
- 性能上 Blinn-Phong 更优,视觉质量也更好,**实际项目中几乎都用 Blinn-Phong**
## 完整的 Phong 着色器实现
### 顶点着色器
```glsl
attribute vec3 a_position;
attribute vec3 a_normal;
uniform mat4 u_modelMatrix;
uniform mat4 u_viewMatrix;
uniform mat4 u_projectionMatrix;
uniform mat3 u_normalMatrix;
uniform vec3 u_lightPosition;
uniform vec3 u_cameraPosition;
varying vec3 v_normal;
varying vec3 v_fragPos;
void main() {
vec4 worldPos = u_modelMatrix * vec4(a_position, 1.0);
gl_Position = u_projectionMatrix * u_viewMatrix * worldPos;
// 法线变换必须用 normalMatrix,不能用 modelMatrix
v_normal = normalize(u_normalMatrix * a_normal);
v_fragPos = worldPos.xyz;
}
```
### 片段着色器
```glsl
precision mediump float;
varying vec3 v_normal;
varying vec3 v_fragPos;
uniform vec3 u_lightPosition;
uniform vec3 u_cameraPosition;
uniform vec3 u_lightColor;
uniform float u_ambientStrength;
uniform float u_specularStrength;
uniform float u_shininess;
void main() {
vec3 normal = normalize(v_normal);
vec3 lightDir = normalize(u_lightPosition - v_fragPos);
vec3 viewDir = normalize(u_cameraPosition - v_fragPos);
// 环境光
vec3 ambient = u_ambientStrength * u_lightColor;
// 漫反射
float diff = max(dot(normal, lightDir), 0.0);
vec3 diffuse = diff * u_lightColor;
// 镜面反射(Blinn-Phong)
vec3 halfwayDir = normalize(lightDir + viewDir);
float spec = pow(max(dot(normal, halfwayDir), 0.0), u_shininess);
vec3 specular = u_specularStrength * spec * u_lightColor;
vec3 result = (ambient + diffuse + specular);
gl_FragColor = vec4(result, 1.0);
}
```
## 为什么法线变换要用 normalMatrix 而不是 modelMatrix?
这是面试中容易被追问的关键点。直接用 modelMatrix 变换法线,在非均匀缩放下会导致法线不再垂直于表面。正确做法是使用**模型矩阵的逆转置矩阵的左上 3x3 部分**:
```javascript
const normalMatrix = mat3.create();
mat3.fromMat4(normalMatrix, modelMatrix);
mat3.invert(normalMatrix, normalMatrix);
mat3.transpose(normalMatrix, normalMatrix);
```
数学原理:法线是表面的梯度方向,梯度变换的矩阵是坐标变换矩阵的逆转置。只有当 modelMatrix 只有旋转和均匀缩放时,normalMatrix 才等于 modelMatrix 的 3x3 部分。
## 多光源与衰减
实际场景中往往需要多个光源,并处理光照随距离的衰减:
```glsl
// 点光源衰减公式
float distance = length(lightPosition - fragPos);
float attenuation = 1.0 / (1.0 + 0.09 * distance + 0.032 * distance * distance);
```
多个光源时,环境光只算一次,漫反射和镜面反射逐光源累加后再乘以衰减值。移动端建议不超过 2 个动态光源,桌面端 4-8 个。光源更多时应考虑延迟渲染(Deferred Shading),将几何处理与光照计算分离。
## Gouraud 着色与 Phong 着色
| 对比项 | Gouraud | Phong |
|-------|---------|-------|
| 计算位置 | 顶点着色器 | 片段着色器 |
| 效果 | 插值导致高光不准确 | 逐像素计算,高光精确 |
| 性能 | 更快 | 较慢 |
| 适用场景 | 低端设备或顶点密度高的模型 | 追求渲染质量 |
Gouraud 着色在顶点处计算光照然后插值,当三角形覆盖范围大时会丢失高光细节。Phong 着色逐片段计算,是现代 WebGL 的主流选择。
## 常见面试追问
**Q:Phong 模型和 PBR 的本质区别是什么?**
Phong 是经验模型,参数(环境光强度、高光强度、shininess)没有物理意义,调参靠感觉。PBR 基于微表面理论和能量守恒,参数(粗糙度、金属度)有明确的物理含义,同一组参数在不同光照环境下结果一致。
**Q:为什么 Blinn-Phong 在掠射角效果更好?**
Phong 的反射向量 R 在掠射角时计算出的高光会出现不对称的截断,因为 `dot(V, R)` 在某些角度会突然变为 0。Blinn-Phong 的半角向量 H 总是在 L 和 V 之间,`dot(N, H)` 的过渡更平滑自然。
**Q:法线不归一化会导致什么问题?**
法线长度不等于 1 时,`dot(N, L)` 的结果会偏大或偏小,漫反射亮度出错;插值后的法线尤其容易变短(两个单位法线的插值不一定是单位向量),所以片段着色器中必须重新 `normalize`。
服务端5月28日 01:32
WebGL 中的后期处理(Post-processing)是如何实现的?## 为什么面试会问后期处理
后期处理是 WebGL 从「能渲染」到「能做出好效果」的关键一步。面试官问这个问题,本质上是想确认你是否理解渲染管线的完整流程,以及是否具备在帧缓冲区和着色器层面解决实际问题的能力。
## 后期处理的核心原理
后期处理的核心思路只有三步:
1. 把场景渲染到纹理(离屏渲染),而不是直接渲染到屏幕
2. 用着色器对这张纹理做图像处理
3. 把处理后的结果画到屏幕上
整个流程可以理解为:
```
场景渲染 → 颜色纹理(FBO) → 后期处理着色器链 → 屏幕
↓
深度/法线纹理(可选)
```
这里的关键概念是**帧缓冲区对象(FBO)**。默认情况下,WebGL 渲染到屏幕缓冲区;绑定 FBO 后,渲染结果写入绑定的纹理,供后续着色器采样。
## 从零搭建后期处理框架
### 创建 FBO 和全屏四边形
```javascript
class PostProcess {
constructor(gl, width, height) {
this.gl = gl;
// 创建帧缓冲区
this.framebuffer = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, this.framebuffer);
// 颜色纹理:存储场景渲染结果
this.colorTexture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, this.colorTexture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0,
gl.RGBA, gl.UNSIGNED_BYTE, null);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_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);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0,
gl.TEXTURE_2D, this.colorTexture, 0);
// 深度渲染缓冲区
this.depthBuffer = gl.createRenderbuffer();
gl.bindRenderbuffer(gl.RENDERBUFFER, this.depthBuffer);
gl.renderbufferStorage(gl.RENDERBUFFER, gl.DEPTH_COMPONENT16,
width, height);
gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT,
gl.RENDERBUFFER, this.depthBuffer);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
// 全屏四边形:覆盖整个视口的矩形,用于后期处理着色器绘制
this.quadVertices = new Float32Array([
-1, 1, 0, 1, // 左上:位置 + 纹理坐标
-1, -1, 0, 0,
1, 1, 1, 1,
1, -1, 1, 0
]);
}
// 开始离屏渲染
begin() {
this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, this.framebuffer);
this.gl.viewport(0, 0, this.width, this.height);
this.gl.clear(this.gl.COLOR_BUFFER_BIT | this.gl.DEPTH_BUFFER_BIT);
}
// 结束离屏渲染
end() {
this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, null);
}
// 用指定着色器处理纹理
apply(program) {
const gl = this.gl;
gl.useProgram(program);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.colorTexture);
gl.uniform1i(gl.getUniformLocation(program, u_texture), 0);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
}
}
```
### 全屏四边形的顶点着色器
后期处理着色器的顶点部分几乎总是相同的——把全屏四边形的顶点映射到裁剪空间,并传递纹理坐标:
```glsl
attribute vec2 a_position;
attribute vec2 a_texCoord;
varying vec2 v_texCoord;
void main() {
gl_Position = vec4(a_position, 0.0, 1.0);
v_texCoord = a_texCoord;
}
```
## 面试高频:6 种经典后期处理效果
### 灰度效果
最简单的入门效果,核心是对 RGB 三通道做加权平均,权重来自人眼对不同颜色的敏感度:
```glsl
precision mediump float;
varying vec2 v_texCoord;
uniform sampler2D u_texture;
void main() {
vec4 color = texture2D(u_texture, v_texCoord);
// ITU-R BT.601 标准权重
float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114));
gl_FragColor = vec4(vec3(gray), color.a);
}
```
### 高斯模糊
高斯模糊是后期处理的基石——Bloom、景深、SSAO 都依赖它。关键优化是**双通道分离**:将二维卷积拆成水平 + 垂直两次一维卷积,采样次数从 N*N 降到 2N。
```glsl
precision mediump float;
varying vec2 v_texCoord;
uniform sampler2D u_texture;
uniform vec2 u_texelSize; // = 1.0 / textureSize
uniform vec2 u_direction; // 水平 (1,0),垂直 (0,1)
void main() {
// 5 点高斯核权重
float w[5] = float[](0.227027, 0.1945946, 0.1216216, 0.054054, 0.016216);
vec4 color = texture2D(u_texture, v_texCoord) * w[0];
for (int i = 1; i < 5; i++) {
vec2 offset = u_direction * u_texelSize * float(i);
color += texture2D(u_texture, v_texCoord + offset) * w[i];
color += texture2D(u_texture, v_texCoord - offset) * w[i];
}
gl_FragColor = color;
}
```
JavaScript 端依次调用水平和垂直两个 pass:
```javascript
// 水平模糊
horizontalProgram.setUniform(u_direction, [1, 0]);
postProcess.apply(horizontalProgram);
// 垂直模糊(把上一步的输出作为输入)
verticalProgram.setUniform(u_direction, [0, 1]);
postProcess.apply(verticalProgram);
```
### 边缘检测(Sobel 算子)
边缘检测在非真实感渲染和描边效果中常用。Sobel 算子通过两个 3x3 卷积核分别计算水平和垂直方向的梯度:
```glsl
precision mediump float;
varying vec2 v_texCoord;
uniform sampler2D u_texture;
uniform vec2 u_texelSize;
void main() {
// 3x3 采样
float texel[9];
for (int y = -1; y <= 1; y++) {
for (int x = -1; x <= 1; x++) {
vec2 coord = v_texCoord + vec2(x, y) * u_texelSize;
float gray = dot(texture2D(u_texture, coord).rgb,
vec3(0.299, 0.587, 0.114));
texel[(y + 1) * 3 + (x + 1)] = gray;
}
}
// Sobel 水平核和垂直核
float edgeX = texel[2] + 2.0*texel[5] + texel[8]
- texel[0] - 2.0*texel[3] - texel[6];
float edgeY = texel[0] + 2.0*texel[1] + texel[2]
- texel[6] - 2.0*texel[7] - texel[8];
float edge = sqrt(edgeX * edgeX + edgeY * edgeY);
gl_FragColor = vec4(vec3(edge), 1.0);
}
```
### 辉光效果(Bloom)
Bloom 是面试最爱考的后期效果之一。它由三步组成:
1. **亮度提取**:把超过阈值的亮区提取出来
2. **模糊**:对提取的亮区做高斯模糊
3. **合成**:把模糊结果叠加回原始场景
亮度提取着色器:
```glsl
precision mediump float;
varying vec2 v_texCoord;
uniform sampler2D u_texture;
uniform float u_threshold;
void main() {
vec4 color = texture2D(u_texture, v_texCoord);
float brightness = dot(color.rgb, vec3(0.2126, 0.7152, 0.0722));
gl_FragColor = brightness > u_threshold ? color : vec4(0.0);
}
```
合成着色器:
```glsl
precision mediump float;
varying vec2 v_texCoord;
uniform sampler2D u_sceneTexture;
uniform sampler2D u_bloomTexture;
uniform float u_bloomIntensity;
void main() {
vec4 scene = texture2D(u_sceneTexture, v_texCoord);
vec4 bloom = texture2D(u_bloomTexture, v_texCoord);
gl_FragColor = scene + bloom * u_bloomIntensity;
}
```
### 色调映射
当场景使用 HDR 渲染时,需要色调映射将高动态范围压缩到 [0,1] 显示。两种常见算法:
- **Reinhard**:简单但高亮区细节丢失较多
- **ACES**:工业标准,高光过渡更自然
```glsl
precision mediump float;
varying vec2 v_texCoord;
uniform sampler2D u_texture;
uniform float u_exposure;
// ACES 色调映射(推荐)
vec3 aces(vec3 x) {
return clamp(
(x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14),
0.0, 1.0
);
}
void main() {
vec4 color = texture2D(u_texture, v_texCoord);
vec3 mapped = aces(color.rgb * u_exposure);
// Gamma 校正
mapped = pow(mapped, vec3(1.0 / 2.2));
gl_FragColor = vec4(mapped, color.a);
}
```
### 色彩调整(亮度/对比度/饱和度)
这是最实用的后期效果之一,几乎所有游戏和应用都会用到:
```glsl
precision mediump float;
varying vec2 v_texCoord;
uniform sampler2D u_texture;
uniform float u_brightness; // 通常 [-1, 1]
uniform float u_contrast; // 通常 [0, 2],1 为原始
uniform float u_saturation; // 通常 [0, 2],1 为原始
void main() {
vec4 color = texture2D(u_texture, v_texCoord);
// 亮度:直接偏移
color.rgb += u_brightness;
// 对比度:以 0.5 为中心缩放
color.rgb = (color.rgb - 0.5) * u_contrast + 0.5;
// 饱和度:向灰度混合
float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114));
color.rgb = mix(vec3(gray), color.rgb, u_saturation);
gl_FragColor = color;
}
```
## 多效果链式处理:乒乓缓冲
当需要叠加多个后期效果时,需要两个 FBO 交替作为输入和输出,这就是**乒乓缓冲(Ping-Pong Buffering)**:
```javascript
class PostProcessChain {
constructor(gl, width, height) {
this.readFBO = new PostProcess(gl, width, height);
this.writeFBO = new PostProcess(gl, width, height);
this.effects = [];
}
addEffect(effect) {
this.effects.push(effect);
}
render(sceneRenderFunc) {
// 1. 渲染场景到 readFBO
this.readFBO.begin();
sceneRenderFunc();
this.readFBO.end();
// 2. 逐效果处理
for (const effect of this.effects) {
this.writeFBO.begin();
effect.apply(this.readFBO.colorTexture);
this.writeFBO.end();
// 交换读写缓冲区
[this.readFBO, this.writeFBO] = [this.writeFBO, this.readFBO];
}
// 3. 最终输出到屏幕
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
finalPass.apply(this.readFBO.colorTexture);
}
}
```
## 面试常考的进阶话题
### SSAO 需要什么额外信息
SSAO(屏幕空间环境光遮蔽)不能只用颜色纹理,还需要**深度纹理**和**法线纹理**。这意味着你在场景渲染阶段需要额外输出这些信息,通常通过 MRT(Multiple Render Targets)一次完成。
### WebGL2 带来的改进
WebGL2 对后期处理有几个重要提升:
- `gl.blitFramebuffer()`:可以在帧缓冲区之间快速拷贝,无需着色器
- MRT(Multiple Render Targets):一次渲染同时输出颜色、深度、法线到不同纹理
- `texStorage2D`:不可变纹理,驱动层可以提前优化内存布局
- 3D 纹理:可用于体积雾等高级后期效果
### 与 Three.js 后处理的对比
面试中可能追问:Three.js 的 EffectComposer 做了什么?本质上是把上面的框架封装了——它管理 RenderPass、ShaderPass 和乒乓缓冲,但底层原理完全相同。理解原生实现后,使用任何引擎的后处理系统都能快速上手。
## 性能优化的五个要点
1. **降分辨率渲染**:模糊、Bloom 等效果对分辨率不敏感,可以用半分辨率甚至 1/4 分辨率,帧时间减少 60% 以上
2. **合并简单效果**:亮度/对比度/饱和度可以合并在一个着色器中,减少一个 pass
3. **利用 Mipmap**:模糊效果可以先生成 mipmap,再从低层级采样,大幅减少采样次数
4. **按需更新**:静态场景不需要每帧都跑后期处理,可以缓存上一帧的结果
5. **移动端降级**:移动端 GPU 带宽有限,减少采样次数(如用 3 点高斯替代 5 点),或直接禁用 SSAO 等高开销效果服务端5月28日 01:32
WebGL 中的矩阵变换有哪些?MVP 矩阵是什么?## WebGL 中的矩阵变换
3D 渲染的本质是将顶点从模型局部坐标一步步变换到屏幕像素坐标,而矩阵就是描述这些变换的数学工具。WebGL 中所有坐标变换都通过 4×4 齐次矩阵完成,理解这些矩阵的含义和组合方式是掌握 3D 图形编程的基础。
## 三种基本变换矩阵
### 平移矩阵
平移将物体沿 X、Y、Z 轴移动指定距离,是最直观的变换:
```
| 1 0 0 tx |
| 0 1 0 ty |
| 0 0 1 tz |
| 0 0 0 1 |
```
由于 3×3 矩阵无法表示平移(线性变换不包含偏移),所以必须引入齐次坐标——用 4D 向量 `(x, y, z, 1)` 表示 3D 点,`w=1` 使平移分量 `tx, ty, tz` 参与运算。
```javascript
function createTranslationMatrix(tx, ty, tz) {
return new Float32Array([
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
tx, ty, tz, 1
]);
}
```
注意 WebGL 使用列主序存储,所以矩阵在数组中的排列与数学书写不同——第一列先填,再填第二列。
### 缩放矩阵
沿各轴独立缩放物体大小:
```
| sx 0 0 0 |
| 0 sy 0 0 |
| 0 0 sz 0 |
| 0 0 0 1 |
```
当 `sx = sy = sz` 时为等比缩放。如果某个分量为负值,会产生镜像翻转效果——这在法线计算时需要特别处理。
### 旋转矩阵
旋转矩阵比平移和缩放复杂,因为绕不同轴旋转的矩阵形式不同:
绕 X 轴旋转 θ:
```
| 1 0 0 0 |
| 0 cosθ -sinθ 0 |
| 0 sinθ cosθ 0 |
| 0 0 0 1 |
```
绕 Y 轴旋转 θ:
```
| cosθ 0 sinθ 0 |
| 0 1 0 0 |
| -sinθ 0 cosθ 0 |
| 0 0 0 1 |
```
绕 Z 轴旋转 θ:
```
| cosθ -sinθ 0 0 |
| sinθ cosθ 0 0 |
| 0 0 1 0 |
| 0 0 0 1 |
```
任意轴的旋转可以通过绕三个基本轴旋转的组合实现,但更优雅的方式是使用四元数(Quaternion),它避免了万向锁(Gimbal Lock)问题,且插值更平滑。面试中如果被追问"如何避免万向锁",四元数是标准答案。
## MVP 矩阵详解
MVP 矩阵是渲染管线中最核心的概念,它将顶点从模型空间一路变换到裁剪空间:
**MVP = P × V × M**
关键点在于**乘法顺序**——右侧的矩阵先作用于顶点。所以实际变换过程是:顶点先被 M 变换,再被 V 变换,最后被 P 变换。写成作用于顶点的形式就是 `P × V × M × vertex`。
### M - 模型矩阵(Model Matrix)
模型矩阵将顶点从**模型局部空间**变换到**世界空间**。它描述的是物体在世界中的摆放方式——放在哪里、朝向哪个方向、多大尺寸。
```javascript
const modelMatrix = mat4.create();
mat4.translate(modelMatrix, modelMatrix, [x, y, z]);
mat4.rotateX(modelMatrix, modelMatrix, angleX);
mat4.rotateY(modelMatrix, modelMatrix, angleY);
mat4.scale(modelMatrix, modelMatrix, [sx, sy, sz]);
```
模型矩阵通常是平移、旋转、缩放的组合。变换的顺序影响最终结果:先缩放再旋转再平移,和先平移再旋转再缩放,得到的是完全不同的效果。一般推荐 SRT 顺序(Scale → Rotate → Translate),即先缩放调整大小,再旋转调整朝向,最后平移到目标位置。
### V - 视图矩阵(View Matrix)
视图矩阵将顶点从**世界空间**变换到**相机空间**(观察空间)。它的本质是设置观察者的视角——相机在哪里、看向哪里、哪个方向朝上。
```javascript
const viewMatrix = mat4.create();
mat4.lookAt(
viewMatrix,
[0, 0, 5], // 相机位置(eye)
[0, 0, 0], // 观察目标点(center)
[0, 1, 0] // 上方向向量(up)
);
```
理解视图矩阵的一个思路:它并不移动相机,而是反向移动整个世界。将世界坐标系的原点搬到相机位置,并旋转坐标系使相机朝向 -Z 方向。这就是为什么 lookAt 矩阵是相机变换的逆矩阵。
### P - 投影矩阵(Projection Matrix)
投影矩阵将顶点从**相机空间**变换到**裁剪空间**,是 3D 到 2D 映射的关键一步。
**透视投影**模拟人眼近大远小的效果,是 3D 游戏和可视化的默认选择:
```javascript
mat4.perspective(
projectionMatrix,
Math.PI / 4, // 视野角度 FOV
canvas.width / canvas.height, // 宽高比
0.1, // 近裁剪面
100.0 // 远裁剪面
);
```
FOV(Field of View)越大,看到的范围越广,但畸变越明显;近裁剪面和远裁剪面定义了可见深度范围,超出范围的片元会被剔除。注意近裁剪面不能设为 0,否则会导致深度缓冲精度问题。
**正交投影**没有近大远小的透视效果,物体大小不随距离变化,常用于 2D 游戏、UI 渲染和 CAD 软件:
```javascript
mat4.ortho(
projectionMatrix,
-2, 2, // 左右边界
-2, 2, // 下上边界
0.1, 100 // 近远裁剪面
);
```
## 坐标空间变换全流程
一次完整的顶点变换经历以下坐标空间:
```
模型空间(局部空间)
↓ 模型矩阵 M
世界空间
↓ 视图矩阵 V
相机空间(观察空间)
↓ 投影矩阵 P
裁剪空间
↓ 透视除法(除以 w 分量)
标准化设备坐标 NDC(-1 到 1)
↓ 视口变换 gl.viewport
屏幕空间
```
其中 MVP 矩阵负责前三步变换,裁剪空间之后由 GPU 硬件自动完成透视除法和视口变换。透视除法是理解投影的关键:裁剪空间的齐次坐标 `(x, y, z, w)` 除以 `w` 后得到 NDC 坐标,正是这个除法产生了近大远小的透视效果。
## 顶点着色器中的 MVP 应用
```glsl
attribute vec3 a_position;
uniform mat4 u_mvpMatrix;
void main() {
gl_Position = u_mvpMatrix * vec4(a_position, 1.0);
}
```
实际开发中推荐在 JavaScript 端预计算 MVP 矩阵再传入着色器,而不是在着色器中分别接收三个矩阵相乘。原因有两点:一是减少 GPU 端的计算量(顶点着色器每个顶点执行一次),二是减少 uniform 变量的数量和上传次数。
## 常见面试追问
**Q: 为什么 MVP 的乘法顺序是 P × V × M,而不是 M × V × P?**
因为矩阵作用于顶点的顺序是从右到左。P × V × M × vertex 等价于先 M 再 V 再 P,即先从模型空间到世界空间,再到相机空间,最后到裁剪空间。这是渲染管线的自然顺序。
**Q: WebGL 中矩阵是行主序还是列主序?**
WebGL 使用列主序。`gl.uniformMatrix4fv` 的 `transpose` 参数必须为 `false`(WebGL 1 不支持转置上传)。这意味着在 Float32Array 中,矩阵的第一列四个元素排在最前面。
**Q: 齐次坐标中 w 分量的作用是什么?**
`w=1` 表示点(受平移影响),`w=0` 表示方向向量(不受平移影响)。方向向量只需要旋转和缩放,如法线向量和光照方向。透视除法中 `w` 还承担了产生近大远小效果的关键角色。
**Q: 法线变换为什么不能直接用 MVP 矩阵?**
法线是方向向量不是位置向量,且存在非等比缩放时直接用模型矩阵会导致法线不再垂直于表面。正确做法是使用模型矩阵左上 3×3 子矩阵的逆转置矩阵,即 `(M⁻¹)ᵀ`。gl-matrix 中的 `mat3.normalFromMat4` 就是做这件事。