ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

WebGPU玻璃材质渲染:从光路原理到WGSL实践

WebGPU玻璃材质渲染:从光路原理到WGSL实践 在 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 已经默认开启 WebGPUSafari 在较新版本中也提供了支持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 vitelatest webgpu-glass -- --template vanilla-ts cd webgpu-glass npm install npm run dev工程里核心文件只有两个index.html放 canvas。src/main.ts负责初始化、创建流水线、启动渲染循环。!doctype html html langzh-CN body canvas idgpu-canvas width1024 height768/canvas script typemodule 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 顺序再写代码玻璃材质的正确渲染顺序是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, };顶点着色器负责计算世界坐标、法线、裁剪坐标和屏幕 UVstruct 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) varuniform 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_2df32; group(0) binding(2) var sceneSampler: sampler; group(0) binding(3) var envTexture: texture_cubef32; 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 材质系统了。
返回列表