WebGPU玻璃材质渲染:从光路原理到WGSL实践
2026/8/28 10:09:41 网站建设 项目流程

在 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很重要,不同平台合成器对bgra8unormrgba8unorm的处理效率不同,不要写死格式。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 把视觉现象拆成可编程参数

视觉现象物理来源实时近似方式关键参数
反射表面 FresnelSchlick 近似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 顺序,再写代码

玻璃材质的正确渲染顺序是:

  1. Opague Pass:渲染场景中所有不透明物体到sceneTexture
  2. Glass Pass:渲染玻璃球,采样sceneTexture获得透射颜色,采样环境立方体贴图获得反射颜色,最后输出到 canvas。

如果只有一个 pass,玻璃没有背景可采样,折射无从谈起。如果先渲染玻璃后渲染背景,玻璃会被背景覆盖。Pass 顺序本身就是透明材质渲染约束的一部分。

4.2 创建离屏颜色纹理

sceneTexture必须同时具备RENDER_ATTACHMENTTEXTURE_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 开销。
  • 错误处理:requestAdapterrequestDevice都可能失败,要给出明确反馈。
  • 性能监控:fragment shader 里同时采样sceneTextureenvTexture,移动端开销明显,要做 draw call 和 shader 成本预算。

6. 常见问题排查:从现象倒推根因

6.1 全屏黑屏

  • 现象:页面能加载,但 canvas 一直黑屏。
  • 可能原因:WebGPU 初始化失败、Render Pass 代码没有执行、clear value 全部为 0。
  • 检查方式:控制台查看是否有navigator.gpu报错;在renderOpaquePassrenderGlassPass里分别加日志,确认两个 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 材质系统了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询