在 Web 端做玻璃材质,表面看只是把透明度调低,实际上要同时处理透射、折射、菲涅尔反射、吸收和环境反射,任何一环简化过头,物体看起来就像塑料或者金属。WebGPU 作为新一代 Web Graphics API,把渲染管线、着色器、资源和同步方式交给开发者显式控制,配套使用 WGSL 着色语言,这让 Web 端渲染玻璃材质时,比 WebGL 时代有了更清晰的实现路径。这篇文章会从一个最小可运行的 WebGPU 场景入手,实现一个玻璃球的效果,并解释背后的光路计算、颜色缓冲重采样、参数调节和常见问题排查方式。
这篇文章适合已经了解 WebGL 或图形学基础、想迁移到 WebGPU 的开发者。读完可以亲手跑通一个包含折射与菲涅尔反射的示例,也能把同样的思路迁移到更完整的场景中。
1. 先搞清楚玻璃材质在实时渲染里到底难点在哪
1.1 玻璃的视觉特征不是单一属性
玻璃给人的视觉印象由四件事共同决定:
- 透射:光线穿过玻璃继续传播,背景物体仍然可见。
- 折射:光线进入玻璃后方向改变,背景被扭曲。
- 菲涅尔反射:视线与玻璃表面夹角越平,反射越强。
- 吸收:光线穿过有一定厚度的玻璃时,某些波长被吸收,玻璃呈现颜色。
这四件事不能分开处理。只做透射不处理折射,玻璃就是一个透明色块;只做反射不处理折射,玻璃就变成镜子;不处理菲涅尔,反射强度和视角无关,画面会非常假。
1.2 实时渲染通常用近似,而不是完整模拟
真实玻璃需要求解折射积分、多次内反射和散射,成本极高。实时渲染采用一组工程近似:
- 背景先渲染成一张颜色纹理,玻璃片元采样这张纹理,就得到透射颜色。
- 用 Snell 折射定律算一个偏移方向,采样背景纹理时加上偏移,就得到折射扭曲。
- 用 Schlick 菲涅尔近似计算反射占比,在反射颜色和折射颜色之间混合。
- 厚度相关的吸收用简单的指数衰减模拟。
这套组合在离线渲染和实时渲染里方向一致,区别只在数值精度和实现边界。WebGPU 里可以用 texture_2d 保存背景颜色,用 texture_cube 保存环境反射,用 WGSL 在片元着色器里完成上述计算。
1.3 WebGL 时代的痛点在哪里
WebGL 2 已经能实现类似效果,但工程上很别扭:
- 渲染管线的状态分散在大量全局 API 调用里,难以组织。
- 平台扩展差异大,例如 float 纹理、MRT、离屏渲染在不同设备上表现不一致。
- 没有统一的计算着色器抽象,需要各种 hack。
- 资源的生命周期、缓冲和纹理同步需要开发者自己维护,缺少 bind group 这样的结构化绑定方式。
WebGPU 把渲染逻辑拆成 Pipeline、BindGroup、RenderPass 等可独立描述的对象,GPU 驱动可以更充分地重排和复用资源。对玻璃材质这种需要多 pass 配合、离屏纹理采样和反射贴图查询的场景,代码组织方式清晰很多。
2. 环境准备:特性检测、浏览器支持和最小工程
2.1 浏览器支持和特性检测
WebGPU 的 API 形态已经比较稳定,但不同浏览器仍在持续演进。以近年来的主流浏览器状态看:Chrome 和 Edge 已经默认开启 WebGPU,Safari 在较新版本中也提供了支持,Firefox 仍在实验通道中。这个结论会随浏览器版本变化,所以代码里永远应该做特性检测,而不是默认浏览器一定支持。
async function initWebGPU(canvas: HTMLCanvasElement) { if (!navigator.gpu) { throw new Error("当前浏览器不支持 WebGPU,请升级浏览器或开启实验特性"); } const adapter = await navigator.gpu.requestAdapter(); if (!adapter) { throw new Error("没有可用的 GPUAdapter,请检查浏览器和显卡驱动"); } const device = await adapter.requestDevice(); const context = canvas.getContext("webgpu") as GPUCanvasContext; const format = navigator.gpu.getPreferredCanvasFormat(); context.configure({ device, format, alphaMode: "premultiplied", }); return { device, context, format }; }getPreferredCanvasFormat很重要,不同平台合成器对bgra8unorm和rgba8unorm的处理效率不同,不要写死格式。alphaMode如果使用 premultiplied,后续纹理和混合系数也要按预乘 alpha 的思路处理。
2.2 浏览器端支持矩阵参考
| 浏览器 | 状态 | 使用建议 |
|---|---|---|
| Chrome / Edge | 默认支持 | 开发调试首选 |
| Safari 较新版本 | 已提供 WebGPU | 发布前必须真机验证 |
| Firefox | 实验通道 | 不能作为唯一目标环境 |
特性检测代码放在项目入口最前面。如果检测失败,直接展示降级页面,不要让它带着错误继续渲染。
2.3 最小工程结构
使用 TypeScript 和 Vite 是最快捷的组合,不需要引入任何 WebGPU 封装库,先体验原生 API。
npm create vite@latest webgpu-glass -- --template vanilla-ts cd webgpu-glass npm install npm run dev工程里核心文件只有两个:
index.html放 canvas。src/main.ts负责初始化、创建流水线、启动渲染循环。
<!doctype html> <html lang="zh-CN"> <body> <canvas id="gpu-canvas" width="1024" height="768"></canvas> <script type="module" src="/src/main.ts"></script> </body> </html>不要急着写玻璃材质。先确保 WebGPU 设备能创建、render pass能执行、三角形能画出来,否则后续排查时无法判断是图形逻辑问题还是环境问题。
3. 玻璃材质背后的三块核心计算
3.1 把视觉现象拆成可编程参数
| 视觉现象 | 物理来源 | 实时近似方式 | 关键参数 |
|---|---|---|---|
| 反射 | 表面 Fresnel | Schlick 近似 | f0,粗糙度 |
| 折射 | Snell 定律 | 偏移 UV 采样背景 | eta |
| 透射 | 光线穿透 | 采样背景纹理 | 背景纹理 |
| 吸收 | 介质厚度 | 指数衰减 | absorption 系数 |
| 环境反射 | 远距离环境光 | Cube Map 采样 | 环境贴图 |
这套参数是后续着色器代码的骨架。把参数列清楚,写 WGSL 时只是把公式填进去。
3.2 Fresnel 的 Schlick 近似
真实世界中的 Fresnel 随入射角变化,但精确求解非常昂贵。Schlick 给出一个五阶多项式近似:
F(cosTheta) = F0 + (1 - F0) * pow(1 - cosTheta, 5)其中F0是垂直入射时的反射率。玻璃这类电介质的F0大约在0.04左右。金属的F0带颜色且数值高,这就是为什么玻璃材质不能直接用金属那套 PBR 参数。
WGSL 实现:
fn fresnelSchlick(cosTheta: f32, f0: vec3f) -> vec3f { return f0 + (vec3f(1.0) - f0) * pow(1.0 - cosTheta, 5.0); }cosTheta应该使用max(dot(normal, viewDir), 0.0)做一次截断,避免背面光照产生负值。
3.3 折射方向的 Snell 定律
折射方向可以用eta * viewDir - (eta * cosI + sqrt(k)) * normal计算,其中eta是入射介质折射率除以折射介质折射率。空气折射率约1.0,玻璃折射率约1.5,所以光线从空气进入玻璃时eta = 1.0 / 1.5。
fn refractDirection(viewDir: vec3f, normal: vec3f, eta: f32) -> vec3f { let cosI = clamp(dot(viewDir, normal), -1.0, 1.0); let k = 1.0 - eta * eta * (1.0 - cosI * cosI); if (k < 0.0) { return vec3f(0.0); // 全反射,方向非法 } return eta * viewDir - (eta * cosI + sqrt(k)) * normal; }当k < 0.0时发生全反射,此时不应该采样折射,应该直接返回反射颜色。这个分支在玻璃边缘和扁平表面尤其重要。
3.4 Screen-Space Refraction:把背景当作折射源
透射和折射都需要读取背景颜色。实时渲染里最直接的方法是把不透明场景先渲染到一张纹理,玻璃片元按折射方向偏移采样坐标,再乘上 Fresnel 权重。
这个方法叫 Screen-Space Refraction,优点是成本低、效果好,缺点是需要额外一次 Render Pass,而且偏移过大时会出现背景边缘缺失。偏移量需要根据eta和物体表面到背景的距离做保守控制。
4. 实现一个可运行的玻璃球示例
4.1 先规划 Pass 顺序,再写代码
玻璃材质的正确渲染顺序是:
- Opague Pass:渲染场景中所有不透明物体到
sceneTexture。 - Glass Pass:渲染玻璃球,采样
sceneTexture获得透射颜色,采样环境立方体贴图获得反射颜色,最后输出到 canvas。
如果只有一个 pass,玻璃没有背景可采样,折射无从谈起。如果先渲染玻璃后渲染背景,玻璃会被背景覆盖。Pass 顺序本身就是透明材质渲染约束的一部分。
4.2 创建离屏颜色纹理
sceneTexture必须同时具备RENDER_ATTACHMENT和TEXTURE_BINDING两种 usage,前者用于不透明 pass 写入,后者用于玻璃 pass 读取。
const sceneTexture = device.createTexture({ size: { width: canvas.width, height: canvas.height }, format: "rgba16float", usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING, }); const sceneTextureView = sceneTexture.createView();这里使用rgba16float而不是普通 8 位纹理,是为了保留更高的颜色精度。玻璃折射会压缩或拉伸颜色范围,低精度纹理容易出现色带。
4.3 不透明 Pass:写背景进纹理
function renderOpaquePass( commandEncoder: GPUCommandEncoder, pipeline: GPURenderPipeline, uniformBindGroup: GPUBindGroup, sceneTextureView: GPUTextureView ) { const passEncoder = commandEncoder.beginRenderPass({ colorAttachments: [ { view: sceneTextureView, clearValue: { r: 0.12, g: 0.18, b: 0.25, a: 1.0 }, loadOp: "clear", storeOp: "store", }, ], }); passEncoder.setPipeline(pipeline); passEncoder.setBindGroup(0, uniformBindGroup); passEncoder.draw(36); // 示例中使用简单立方体或几何体 passEncoder.end(); }这个 pass 不需要写玻璃球,只负责给玻璃提供背景。实际项目中可以放一张桌子、几本书,或者任意有细节的模型。
4.4 玻璃球的 WGSL 着色器
先定义 uniform 结构,存放矩阵和材质参数:
struct SceneUniform { viewProjection: mat4x4f, worldMatrix: mat4x4f, cameraPosition: vec3f, eta: f32, // 折射率比,空气/玻璃 thickness: f32, // 吸收厚度 padding: vec2f, };顶点着色器负责计算世界坐标、法线、裁剪坐标和屏幕 UV:
struct VSInput { @location(0) position: vec3f, @location(1) normal: vec3f, }; struct VSOutput { @builtin(position) clipPosition: vec4f, @location(0) worldPosition: vec3f, @location(1) worldNormal: vec3f, @location(2) screenUV: vec2f, }; @group(0) @binding(0) var<uniform> scene: SceneUniform; @vertex fn vs_main(in: VSInput) -> VSOutput { var out: VSOutput; out.worldPosition = (scene.worldMatrix * vec4f(in.position, 1.0)).xyz; out.clipPosition = scene.viewProjection * vec4f(out.worldPosition, 1.0); out.worldNormal = mat3x3f(scene.worldMatrix) * in.normal; out.screenUV = out.clipPosition.xy / out.clipPosition.w * 0.5 + 0.5; out.screenUV.y = 1.0 - out.screenUV.y; return out; }注意屏幕 UV 的 Y 轴翻转。WebGPU 的 NDC 坐标和 UV 坐标方向不同,不翻转会导致折射采样上下颠倒。
片元着色器是玻璃材质核心:
@group(0) @binding(1) var sceneTexture: texture_2d<f32>; @group(0) @binding(2) var sceneSampler: sampler; @group(0) @binding(3) var envTexture: texture_cube<f32>; @group(0) @binding(4) var envSampler: sampler; fn fresnelSchlick(cosTheta: f32, f0: vec3f) -> vec3f { return f0 + (vec3f(1.0) - f0) * pow(1.0 - cosTheta, 5.0); } fn refractDirection(viewDir: vec3f, normal: vec3f, eta: f32) -> vec3f { let cosI = clamp(dot(viewDir, normal), -1.0, 1.0); let k = 1.0 - eta * eta * (1.0 - cosI * cosI); if (k < 0.0) { return vec3f(0.0); } return eta * viewDir - (eta * cosI + sqrt(k)) * normal; } @fragment fn fs_main(in: VSOutput) -> @location(0) vec4f { let viewDir = normalize(scene.cameraPosition - in.worldPosition); let normal = normalize(in.worldNormal); let cosTheta = clamp(dot(viewDir, normal), 0.0, 1.0); let f0 = vec3f(0.04); let fresnel = fresnelSchlick(cosTheta, f0); // 折射采样 let refractDir = refractDirection(-viewDir, normal, scene.eta); let uvOffset = refractDir.xy * 0.06; let refractedColor = textureSample(sceneTexture, sceneSampler, in.screenUV + uvOffset).rgb; // 反射采样 let reflectDir = reflect(-viewDir, normal); let reflectedColor = textureSample(envTexture, envSampler, reflectDir).rgb; // 吸收 let absorption = exp(-vec3f(0.6, 0.3, 0.1) * scene.thickness); let mixedColor = mix(refractedColor * absorption, reflectedColor, fresnel); // 玻璃透明度:Fresnel 越高,反射占比越大 let alpha = clamp(max(fresnel.r, max(fresnel.g, fresnel.b)) + 0.2, 0.0, 1.0); return vec4f(mixedColor, alpha); }fresnelSchlick的结果是vec3f,因为它支持彩色 F0。玻璃的 F0 接近无色,但扩展成彩色 F0 后可以表现有色玻璃。
alpha的计算在这里是简化处理,让玻璃的轮廓不完全透明。真正要精确控制,应该引入透射率模型。
4.5 玻璃 Pass 输出到 canvas
玻璃 pass 直接写入 canvas 的当前纹理:
function renderGlassPass( commandEncoder: GPUCommandEncoder, pipeline: GPURenderPipeline, bindGroup: GPUBindGroup, canvasTexture: GPUTextureView ) { const passEncoder = commandEncoder.beginRenderPass({ colorAttachments: [ { view: canvasTexture, clearValue: { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }, loadOp: "clear", storeOp: "store", }, ], }); passEncoder.setPipeline(pipeline); passEncoder.setBindGroup(0, bindGroup); passEncoder.draw(vertexCount); passEncoder.end(); }玻璃 pass 采样的是sceneTexture,写入的是canvasTexture,两者是不同纹理,不会产生读写冲突。
4.6 主循环串联
function frame() { const commandEncoder = device.createCommandEncoder(); renderOpaquePass( commandEncoder, opaquePipeline, opaqueBindGroup, sceneTextureView ); const currentTexture = context.getCurrentTexture(); renderGlassPass( commandEncoder, glassPipeline, glassBindGroup, currentTexture.createView() ); device.queue.submit([commandEncoder.finish()]); requestAnimationFrame(frame); }为什么getCurrentTexture要放在每个帧渲染时调用,而不是初始化时调用一次?因为每个帧 canvas 背后的纹理都会由浏览器的交换链重新分配,提前保存的纹理 view 会指向已经失效或不属于当前帧的资源。
5. 关键参数调节与生产环境注意点
5.1 参数速查表
| 参数 | 含义 | 推荐起始值 | 调大效果 | 调小效果 |
|---|---|---|---|---|
| eta | 折射率比 | 0.67 | 折射弯曲更小 | 折射弯曲更强 |
| f0 | 垂直反射率 | 0.04 | 玻璃更亮更硬 | 玻璃更透明 |
| thickness | 吸收厚度 | 0.5 | 玻璃颜色更深 | 玻璃更通透 |
| uvOffset 系数 | 折射偏移强度 | 0.05 | 背景扭曲更夸张 | 背景扭曲更弱 |
eta是入射介质与折射介质的折射率比值,不是玻璃绝对折射率。如果玻璃环境变为水下场景,空气和水的折射率就要一起更换。
5.2 环境立方体贴图的两种来源
玻璃反射效果依赖环境贴图。生产项目可以从 HDR 环境贴图转换出 Cube Map 六面图,再上传为texture_cube。学习项目可以先用程序生成渐变贴图:
const envTexture = device.createTexture({ size: { width: 256, height: 256, depthOrArrayLayers: 6 }, format: "rgba8unorm", usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST, dimension: "2d", });上传时每个 face 对应一个depthOrArrayLayers层。需要处理六张图的 Y 轴朝向,否则反射会上下颠倒。
5.3 从学习环境切到生产环境要补什么
学习示例通常只有一颗玻璃球、一张背景纹理、一个固定光源。生产环境要额外补:
- MSAA 抗锯齿:玻璃边缘和透明区域是锯齿重灾区。
- 深度处理:玻璃和不透明物体之间要有正确的深度遮挡关系。
- 物体排序:多个透明物体会相互影响,需要按距离排序。
- Render Bundle:把静态场景的绘制指令缓存起来,减少 CPU 开销。
- 错误处理:
requestAdapter、requestDevice都可能失败,要给出明确反馈。 - 性能监控:fragment shader 里同时采样
sceneTexture和envTexture,移动端开销明显,要做 draw call 和 shader 成本预算。
6. 常见问题排查:从现象倒推根因
6.1 全屏黑屏
- 现象:页面能加载,但 canvas 一直黑屏。
- 可能原因:WebGPU 初始化失败、Render Pass 代码没有执行、clear value 全部为 0。
- 检查方式:控制台查看是否有
navigator.gpu报错;在renderOpaquePass和renderGlassPass里分别加日志,确认两个 pass 都执行了。 - 解决:先只跑不透明 pass,确认背景能显示,再接入玻璃 pass。
6.2 折射采样上下颠倒
- 现象:玻璃球里的背景倒立。
- 原因:顶点着色器没有翻转 screenUV.y,或者 cube map face 方向不一致。
- 检查方式:把折射偏移改成 0,看背景是否居中;对比 canvas 的背景方向。
- 解决:顶点着色器里
out.screenUV.y = 1.0 - out.screenUV.y,同时检查环境贴图上传时的 face 朝向。
6.3 玻璃边缘反射太亮或太暗
- 现象:球体边缘一片纯白,或者几乎看不到反射。
- 原因:
cosTheta没有 clamp,或者 f0 设置过大。 - 检查方式:把
fresnelSchlick的输出接到return vec4f(fresnel, 1.0),单独观察 Fresnel 权重。 - 解决:确保
cosTheta = clamp(dot(viewDir, normal), 0.0, 1.0),玻璃 f0 用 0.04 起步。
6.4 Bind Group 匹配报错
- 现象:创建 pipeline 后报
bind group layout不匹配,或运行时报Buffer binding size mismatch。 - 原因:uniform 结构体字节对齐不符合 WGSL 规则,或者 bind group layout 与 pipeline layout 不一致。
- 检查方式:对比
createBindGroupLayout和 pipeline 的layout配置,检查 uniform 成员是否加了@align或补齐 padding。 - 解决:统一用同一份 layout 描述创建 pipeline 和 bind group,不要分别手写两份。
6.5 玻璃遮蔽关系错误
- 现象:玻璃后面的物体显示在玻璃前面。
- 原因:玻璃 pass 没有参与深度测试,或两个 pass 使用不同 depth texture。
- 检查方式:打开 WebGPU 的验证层输出,检查 depth attachment 配置。
- 解决:不透明 pass 和玻璃 pass 使用同一个 depth texture,玻璃 pass 开启 depth test,关闭 depth write 或按语义决定。
7. 可复用检查清单与扩展方向
7.1 玻璃材质上线前检查清单
- 浏览器特性检测是否在页面入口执行。
- canvas 和纹理尺寸是否匹配,
getCurrentTexture是否在每帧获取。 sceneTexture是否同时具备 RENDER_ATTACHMENT 和 TEXTURE_BINDING。- screenUV 是否翻转。
cosTheta是否 clamp。eta是否是折射率比,而不是单一折射率。- Fresnel 输出是否单独验证过。
- 吸收系数是否与玻璃厚度成正比。
- 玻璃和不透明物体是否共享深度。
- 移动端是否测试过真机性能和能耗。
- 环境贴图是否包含反射方向正确的六面。
7.2 扩展方向
真正的高质量玻璃材质会继续加这些能力:
- 多次折射与内反射:用 compute shader 模拟光在玻璃内部的多次弹射,适合离线或局部场景。
- 粗糙玻璃:对折射采样使用不同 mip level,粗糙度越高,采样越模糊。
- 焦散:光线穿过玻璃后在地面形成亮斑,需要额外的光子或 shadow pass 配合。
- 包含折射的 PBR 管线:把玻璃的透射和反射参数接入标准 PBR 材质输入。
对于初学者,最有价值的练习不是直接抄代码,而是调参数。每改一个值,观察它对 Fresnel、折射、吸收的影响。能把一个玻璃球的参数调到视觉上线,再看懂这些公式在 WGSL 里的位置,就可以去挑战完整的 PBR 材质系统了。