标签

WebGL

WebGL(Web Graphics Library)是一种 JavaScript API,用于在任何兼容的网页浏览器中不使用插件渲染2D和3D图形。它是基于OpenGL ES的规范,旨在在Web平台上提供OpenGL的性能和功能。通过 WebGL,开发者可以为网页应用程序创建复杂的可视化效果、游戏、可视化数据和各种交互式图形体验。

WebGL
服务端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 切换次数。
服务端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` 就是做这件事。