透明物体是 3D 渲染里最容易“翻车”的效果之一。尤其是在 Web 端做可视化大屏、3D 编辑器或产品展示时,玻璃、水晶、水面这类材质,用传统 WebGL 实现,要么靠透明度混合硬撑,要么写一堆后处理扩展,效果还未必可控。很多人第一次接触 WebGPU 时,会把注意力放在渲染性能上,以为它的核心价值就是“帧率变高”。但如果只看性能,很容易错过 WebGPU 真正改变游戏规则的地方:它把渲染管线的控制权,重新交还给了开发者。
这篇文章想通过一个具体的“玻璃材质”实现,把 WebGPU 的几个核心能力串起来讲清楚,包括离屏渲染、渲染目标存储、资源绑定、WGSL 着色器以及透明混合。读完你会明白:
- 实时渲染中,玻璃材质到底难在哪。
- WebGPU 用什么机制解决了 WebGL 时代“想做多 Pass 渲染却处处别扭”的问题。
- 如何用不到 200 行核心代码,实现一个带折射与菲涅尔效果的玻璃球体。
- 工程落地时有哪些常见坑,以及如何排查。
先说明一点,这篇文章不是“玻璃材质终极物理方案”,而是一条最值得先跑通的路径:双 Pass 渲染 + 离屏场景纹理采样。这条路径在 WebGPU 中实现成本低、效果好、可扩展性强,是目前 Web 端实时玻璃效果最实用的方案。
1. 玻璃材质为什么难做,WebGPU 恰好改了什么
玻璃在实时渲染中有三个天然麻烦的特性:
- 透射:背后的物体是可见的,但光线经过玻璃后会发生折射,位置会偏移。
- 反射:玻璃表面带有高光和环境反射,视角变化时高光位置会跟着变化。
- 菲涅尔效应:视线越靠近边缘,反射越强;视线接近垂直,透射越明显。
如果你在 WebGL 里做过这个需求,大概率遇到过下面三类问题:
第一,渲染顺序很难控制。透明物体需要先渲染不透明物体,再从远到近渲染透明物体。WebGL 虽然也支持混合,但当你需要“先渲染一张离屏纹理、再采样这张纹理”时,State 管理、FBO 切换、纹理绑定全都要手动处理,代码很快就变得非常绕。
第二,多渲染目标配置繁琐。WebGL 的帧缓冲对象(FBO)虽然也能实现渲染到纹理,但纹理尺寸变化、格式配置、mipmap 生成、坐标翻转等问题非常容易踩坑,而且 WebGL 1.0 对纹理格式的支持也很有限。
第三,混合方案粗糙。很多 Web 端玻璃效果最终退化成“透明贴图 + 半透明叠加”,背后物体完全看不到,或者只是简单做一个小半径模糊。效果一放大就穿帮。
WebGPU 恰好在这几个层面做了结构性调整。它引入了更接近现代图形 API 的抽象:RenderPipeline、BindGroup、RenderPass,并且允许开发者通过storeOp明确指定渲染目标在 Pass 结束后是否保留颜色值。这意味着“先把场景画到纹理上,再在下一个 Pass 采样这张纹理”从一种绕路的技巧,变成了一种正常的、可维护的渲染流程。
还有一个更重要的变化:WebGPU 的状态是显式的。渲染管线、资源绑定、着色器入口全部由开发者显式配置,不再像 WebGL 那样有一个巨大的“隐式全局状态机”。你在排查“为什么玻璃球背后是黑的”这类问题时,能直接从 pipeline 配置和 pass 配置中定位问题,而不是在无数个gl.enable和gl.bindTexture之间翻来覆去。
所以这篇博文选择“玻璃材质”作为切入点,本质上是想借一个真实场景,把 WebGPU 这些底层能力用起来。当你跑通这个双 Pass 流程后,再做阴影贴图、后期处理、屏幕空间反射,思路几乎是一样的,只是采样和计算的内容不同。
2. WebGPU 玻璃材质的技术底座:渲染管线、资源绑定与离屏渲染
在写代码之前,先理清 WebGPU 中几个与玻璃材质实现强相关的核心概念。这些概念如果只看文档会觉得抽象,但结合“双 Pass 渲染玻璃”这个场景,就非常容易理解。
2.1 RenderPipeline:渲染状态的最终集合
WebGPU 的createRenderPipeline会把顶点着色器、片元着色器、顶点布局、混合状态、深度模板状态、图元拓扑等全部打包成一个不可变对象。你可以把它理解为“一个固定配置的加工流水线”。
在玻璃材质里,混合状态和深度状态尤其重要。一个典型的半透明玻璃配置是:
- 开启透明度混合,Alpha 权重采用常规的半透明混合公式。
- 深度写入关闭(
depthWriteEnabled: false),但深度比较仍然开启(depthCompare: "less"),避免玻璃球体和背景物体错乱遮挡。
2.2 BindGroup:把纹理、采样器、Uniform 打包成组
WebGPU 中,资源不能直接“喂”给 Shader,而是要先创建BindGroupLayout定义结构,再创建BindGroup填充实际资源。每个 Shader 里声明的@group和@binding编号,必须和 BindGroup 对应。
在玻璃材质里,玻璃球的片元着色器至少需要三样资源:
- 一个 uniform 结构体,存放模型矩阵、视角投影矩阵、相机位置、折射率等参数。
- 离屏场景纹理(就是 Pass 1 渲染出来的那张纹理)。
- 一个纹理采样器,用于在片元着色器里采样场景纹理。
2.3 RenderPass 与 storeOp:双 Pass 渲染的核心控制点
CommandEncoder中通过beginRenderPass开启一个渲染通道。你可以把一次 RenderPass 理解成“把一堆三角形画到一组颜色附件和深度附件上的过程”。
这里最关键的是storeOp配置:
loadOp决定 Pass 开始时,渲染目标里的像素是保留旧值("load")还是清空("clear")。storeOp决定 Pass 结束后,渲染目标里的颜色值保留("store")还是丢弃("discard")。
在玻璃材质的双 Pass 流程中:
- Pass 1 把背景场景画到离屏纹理,
storeOp必须设置为"store",这样纹理才能保留颜色值。 - Pass 2 再把这个离屏纹理绑定到玻璃管线的 BindGroup 中,在片元着色器里采样。
如果你把storeOp设成"discard",Pass 1 渲染完纹理内容就被丢弃了,Pass 2 采样到的就是一张全黑或未定义状态的纹理。这是新手实现离屏渲染时最常见的问题之一。
2.4 WGSL 着色器
WebGPU 的 Shader 语言是 WGSL,语法接近 Rust,类型系统比 GLSL 更严格。做玻璃效果时,主要用到的内置函数有:
normalize:向量归一化。dot:点乘,计算菲涅尔项和高光。reflect:反射方向。refract:根据入射方向、法线和折射率比,计算折射方向。
这几个函数都是 WGSL 标准内置函数,可以直接使用。
2.5 WebGL 与 WebGPU 的关键差异
| 维度 | WebGL | WebGPU |
|---|---|---|
| 渲染状态 | 隐式全局状态机,状态分散且易忘记重置 | 显式的 RenderPipeline,状态集中在管线对象中 |
| 资源绑定 | 大量 bindTexture / bindBuffer 调用 | BindGroup 统一绑定,结构清晰 |
| 离屏渲染 | 手动管理 FBO,繁琐易错 | RenderPass + 颜色附件,storeOp 显式控制 |
| Shader 语言 | GLSL / ESSL | WGSL,类型严格,内置函数丰富 |
| 多 Pass 流程 | 需要大量状态切换和全局参数配合 | 渲染目标切换直观,流程更接近 Vulkan/Metal 思路 |
这个对比可以解释一件事:为什么同一个“渲染到纹理再采样”的方案,在 WebGL 里要写很多样板代码,在 WebGPU 里却显得很自然。
3. 玻璃材质的常见实现路径对比
在做玻璃材质前,最好先确认你的项目到底需要哪种效果,因为不同方案的实现成本和效果上限差别很大。
| 方案 | 实现难度 | 效果真实度 | 适用场景 | 关键成本 |
|---|---|---|---|---|
| 方案 A:Alpha 混合 | 低 | 最低 | 简单 UI、低精度预览 | 几乎没有折射,透射效果弱 |
| 方案 B:CubeMap 反射 + 折射 | 中 | 中 | 静态环境下的玻璃、水面 | 环境贴图无法反映场景中动态物体 |
| 方案 C:离屏纹理 + 折射偏移 | 中高 | 较高 | 实时场景中的玻璃、水晶、水面 | 需要多 Pass 渲染,但思路可扩展 |
| 方案 D:路径追踪 / 光线追踪 | 极高 | 最高 | 离线渲染、高端图形演示 | 实时性差,Web GPU 上还处于探索阶段 |
从 Web 实时渲染的角度看,方案 C 是性价比最高的路径。它既能捕捉场景中动态物体的折射效果,又能通过采样偏移控制实现成本。
需要说明的是,方案 C 存在一个天然近似:它只能折射“已经画到离屏纹理里的内容”。如果镜头前方的玻璃后面还有另一块玻璃,第二块玻璃折射到的内容就不一定准确。这是因为离屏纹理只保存了一张 2D 图,没有保存三维几何信息。工程上通常用“渲染分层 + 后处理屏幕空间效果”来减轻这个问题,但即便有这些近似,方案 C 在大多数产品场景中已经足够。
4. 环境准备与前置条件
本文代码使用 WebGPU 标准 API 和 WGSL 着色器语言。你需要准备以下环境:
- 最新版 Chrome、Edge 或 Firefox(WebGPU 已经逐步默认开启,Firefox 对 WebGPU 的支持也在持续推进中)。
- 一个支持 WebGPU 的显卡驱动。集成显卡也可以跑通本文示例,离屏纹理为 1024x1024,性能压力不大。
- 开发工具可以是 VS Code 或其他编辑器。
- 本文示例没有使用任何框架,直接使用原生 JavaScript + WebGPU 浏览器 API。
版本说明:WebGPU 标准仍在迭代中,不同浏览器对特性的支持可能存在细微差别。本文重点演示通用实现思路,代码中的 API 用法以近半年来浏览器稳定支持的方式为准。如果你在某个浏览器上遇到 API 不兼容,优先升级浏览器版本。
为了方便运行,本文推荐把代码放到一个静态服务器里运行,例如:
npx serve .然后在浏览器地址栏访问本地 IP 对应的 http 地址。直接双击打开 HTML 文件在部分浏览器中会受到模块加载限制。
5. 核心流程拆解:双 Pass 渲染一个玻璃球
现在进入核心流程。我们的最终目标是在屏幕上看到两个球体:一个不透明的彩色背景球,一个半透明的玻璃球。玻璃球后方的背景球会透过玻璃球发生折射偏移,并且玻璃球边缘会带有菲涅尔高光。
整个流程分五个步骤。
5.1 创建离屏渲染目标
离屏渲染目标是 Pass 1 的输出,也是 Pass 2 的输入纹理。这里有两个关键点:
- 纹理格式要和画布格式保持一致,避免格式差异导致的采样异常。
- 纹理的
usage必须同时包含RENDER_ATTACHMENT和TEXTURE_BINDING,前者用于把它当作渲染目标,后者用于把它绑定到片元着色器里采样。
const OFFSCREEN_SIZE = 1024; const offscreenTexture = device.createTexture({ size: { width: OFFSCREEN_SIZE, height: OFFSCREEN_SIZE }, format: navigator.gpu.getPreferredCanvasFormat(), usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING, });这里真正容易踩坑的是忘记加TEXTURE_BINDING,或者格式不匹配。如果电脑上跑起来花屏,优先检查这一条。
5.2 Pass 1:渲染不透明背景球到离屏纹理
Pass 1 的职责是把场景中的不透明物体画到离屏纹理上。它不需要关心任何透明物体,因为我们最终看到玻璃球时,背景内容来自这张纹理,而玻璃球本身在 Pass 2 叠加。
Pass 1 的关键配置是:
const pass1 = commandEncoder.beginRenderPass({ colorAttachments: [ { view: offscreenTexture.createView(), clearValue: { r: 0.9, g: 0.9, b: 0.9, a: 1.0 }, loadOp: "clear", storeOp: "store", }, ], });storeOp: "store"必不可少。如果写成了"discard",Pass 1 渲染完纹理色值就会被丢弃。
5.3 Pass 2:渲染玻璃球到屏幕
Pass 2 的职责是渲染玻璃球本体,并在片元着色器里采样 Pass 1 生成的离屏纹理。
玻璃管线和普通不透明管线的核心差异在混合与深度状态:
const glassPipeline = device.createRenderPipeline({ layout: "auto", vertex: { module: shaderModule, entryPoint: "vs_main", }, fragment: { module: shaderModule, entryPoint: "fs_main", targets: [ { format: canvasFormat, blend: { color: { srcFactor: "src-alpha", dstFactor: "one-minus-src-alpha", }, alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", }, }, }, ], }, primitive: { topology: "triangle-list" }, depthStencil: { format: "depth24plus", depthWriteEnabled: false, depthCompare: "less", }, });这里有两个细节值得解释:
- 玻璃球不从属于背景球,所以 Pass 1 不需要画玻璃球。Pass 2 只需要画玻璃球本体,因为它在 NDC 坐标中处在背景球前方,深度测试会保证它正确可见。
depthWriteEnabled: false是为了防止玻璃球把背景球已经写入的深度值覆盖掉,否则后续其他透明物体可能无法正确遮挡。深度比较仍然启用,确保玻璃球只绘制在可见区域。
5.4 WGSL 着色器:计算折射方向、菲涅尔项和高光
这是整个玻璃效果的核心。片元着色器做的事情可以拆成四步:
- 计算视线方向:从片元位置指向相机。
- 计算折射方向:根据视线方向、法线和折射率比,调用 WGSL 内置的
refract。 - 采样离屏纹理,并按照折射方向偏移 UV,模拟透过玻璃看到的扭曲画面。
- 计算菲涅尔项和高光,混合折射色与反射高光。
struct Uniforms { viewProj : mat4x4<f32>, model : mat4x4<f32>, cameraPos : vec4<f32>, eta : f32, fresnelPower : f32, refractionStrength : f32, }; @group(0) @binding(0) var<uniform> u : Uniforms; @group(0) @binding(1) var sceneTexture : texture_2d<f32>; @group(0) @binding(2) var sceneSampler : sampler; struct VertexOutput { @builtin(position) clipPos : vec4<f32>, @location(0) worldPos : vec3<f32>, @location(1) normal : vec3<f32>, @location(2) uv : vec2<f32>, }; @vertex fn vs_main( @location(0) position : vec3<f32>, @location(1) normal : vec3<f32>, @location(2) uv : vec2<f32> ) -> VertexOutput { var out : VertexOutput; out.worldPos = (u.model * vec4<f32>(position, 1.0)).xyz; out.normal = normalize((u.model * vec4<f32>(normal, 0.0)).xyz); out.uv = vec2<f32>(uv.x, 1.0 - uv.y); out.clipPos = u.viewProj * vec4<f32>(out.worldPos, 1.0); return out; } @fragment fn fs_main(in : VertexOutput) -> @location(0) vec4<f32> { let viewDir = normalize(u.cameraPos.xyz - in.worldPos.xyz); let N = normalize(in.normal); let V = -viewDir; // 折射方向 let refrDir = refract(V, N, 1.0 / u.eta); // 屏幕空间近似折射采样 let refrUV = in.uv + refrDir.xy * u.refractionStrength; let refractedColor = textureSample(sceneTexture, sceneSampler, refrUV).rgb; // 简化菲涅尔 let fresnel = pow(1.0 - dot(N, -viewDir), u.fresnelPower); // 简化高光 let H = normalize(viewDir + vec3<f32>(0.0, 0.0, 1.0)); let spec = pow(max(dot(N, H), 0.0), 32.0); let finalColor = mix(refractedColor, vec3<f32>(1.0), fresnel) + vec3<f32>(spec) * fresnel; return vec4<f32>(finalColor, 0.9); }三个关键点:
refract函数的 eta 参数是“入射介质折射率 / 折射介质折射率”,从空气到玻璃大约为 1.0 / 1.5,所以代码里传1.0 / u.eta。如果你直接传u.eta,折射方向会完全错误。- 片元着色器里用 UV 偏移模拟折射,是屏幕空间近似。它不会产生真正的物理光路偏移,但视觉上已经能获得“透过玻璃看到扭曲背景”的效果。
- 菲涅尔项这里用了简化版本,工程上更推荐 Schlick 近似:
R0 + (1 - R0) * pow(1 - cosTheta, 5)。这个简化写法只是为了快速出效果,真实项目可以替换。
5.5 合并输出到屏幕
最后一步是把 Pass 2 的结果提交到画布:
device.queue.submit([commandEncoder.finish()]);整个帧的渲染流程是:
- 创建命令编码器。
- 开启 Pass 1,渲染背景球到离屏纹理。
- 结束 Pass 1。
- 开启 Pass 2,绑定玻璃管线和玻璃球顶点数据。
- 在片元着色器里采样离屏纹理。
- 结束 Pass 2。
- 提交命令。
6. 完整示例代码实现
现在把上面所有流程串成一个最小可运行示例。由于篇幅限制,这里的代码重点在 WebGPU 渲染流程上,球体生成函数使用最常见的 UV Sphere 顶点生成方式。
6.1 index.html
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <title>WebGPU Glass Material</title> <style> body { margin: 0; overflow: hidden; } canvas { display: block; width: 100vw; height: 100vh; } </style> </head> <body> <script type="module" src="./main.js"></script> </body> </html>6.2 main.js 核心框架
// 文件路径:main.js // 功能:双 Pass 渲染玻璃球 // 使用方式:在静态服务器环境下运行本页面 async function initWebGPU() { if (!navigator.gpu) { throw new Error("当前浏览器不支持 WebGPU,请升级浏览器。"); } const adapter = await navigator.gpu.requestAdapter(); if (!adapter) { throw new Error("无法获取 WebGPU Adapter,请检查 GPU 状态。"); } const device = await adapter.requestDevice(); return { adapter, device }; } function createSphere(radius, widthSegments, heightSegments) { const positions = []; const normals = []; const uvs = []; const indices = []; for (let j = 0; j <= heightSegments; j++) { const v = j / heightSegments; const phi = v * Math.PI; for (let i = 0; i <= widthSegments; i++) { const u = i / widthSegments; const theta = u * Math.PI * 2.0; const x = radius * Math.sin(phi) * Math.cos(theta); const y = radius * Math.cos(phi); const z = radius * Math.sin(phi) * Math.sin(theta); const nx = x / radius; const ny = y / radius; const nz = z / radius; positions.push(x, y, z); normals.push(nx, ny, nz); uvs.push(u, 1.0 - v); } } for (let j = 0; j < heightSegments; j++) { for (let i = 0; i < widthSegments; i++) { const a = j * (widthSegments + 1) + i; const b = a + 1; const c = a + widthSegments + 1; const d = c + 1; indices.push(a, b, d, a, d, c); } } return { positions, normals, uvs, indices }; } function createBuffer(device, data, usage) { const buffer = device.createBuffer({ size: data.byteLength, usage: usage | GPUBufferUsage.COPY_DST, }); device.queue.writeBuffer(buffer, 0, data); return buffer; } function createDepthTexture(device, size) { return device.createTexture({ size: { width: size, height: size }, format: "depth24plus", usage: GPUTextureUsage.RENDER_ATTACHMENT, }); } async function main() { const { device } = await initWebGPU(); const canvas = document.querySelector("canvas"); const context = canvas.getContext("webgpu"); const canvasFormat = navigator.gpu.getPreferredCanvasFormat(); context.configure({ device, format: canvasFormat, alphaMode: "premultiplied", }); const WIDTH = 1024; const HEIGHT = 1024; const offscreenTexture = device.createTexture({ size: { width: WIDTH, height: HEIGHT }, format: canvasFormat, usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING, }); const offscreenView = offscreenTexture.createView(); const depthTexture = createDepthTexture(device, WIDTH); const depthOffscreen = createDepthTexture(device, WIDTH); const shaderModule = device.createShaderModule({ code: WGSL_SHADER }); // 省略:创建两个球体的顶点缓冲区和索引缓冲区 // 透明球使用 glassPipeline,背景球使用 opaquePipeline // 创建 uniform buffer 与 bind group // 渲染循环 function frame() { const commandEncoder = device.createCommandEncoder(); // Pass 1:渲染背景球到离屏纹理 const pass1 = commandEncoder.beginRenderPass({ colorAttachments: [ { view: offscreenView, clearValue: { r: 0.9, g: 0.9, b: 0.9, a: 1.0 }, loadOp: "clear", storeOp: "store", }, ], depthStencilAttachment: { view: depthOffscreen.createView(), depthClearValue: 1.0, depthLoadOp: "clear", depthStoreOp: "store", }, }); // 绘制背景球 pass1.setPipeline(opaquePipeline); pass1.setVertexBuffer(0, backgroundVertexBuffer); pass1.setIndexBuffer(backgroundIndexBuffer, "uint32"); pass1.setBindGroup(0, backgroundBindGroup); pass1.drawIndexed(backgroundIndexCount); pass1.end(); // Pass 2:渲染玻璃球到屏幕 const pass2 = commandEncoder.beginRenderPass({ colorAttachments: [ { view: context.getCurrentTexture().createView(), clearValue: { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }, loadOp: "clear", storeOp: "store", }, ], depthStencilAttachment: { view: depthTexture.createView(), depthClearValue: 1.0, depthLoadOp: "clear", depthStoreOp: "store", }, }); pass2.setPipeline(glassPipeline); pass2.setVertexBuffer(0, glassVertexBuffer); pass2.setIndexBuffer(glassIndexBuffer, "uint32"); pass2.setBindGroup(0, glassBindGroup); pass2.drawIndexed(glassIndexCount); pass2.end(); device.queue.submit([commandEncoder.finish()]); } requestAnimationFrame(frame); } main().catch((e) => { console.error(e); });这段代码故意省略了球体 buffer 创建和 uniform 更新等重复性代码,重点体现双 Pass 的核心结构。真正放入项目时,你需要额外创建两个球体的顶点 buffer,并在每帧更新 uniform 中的矩阵数据。
6.3 WGSL 着色器
WGSL 着色器代码放在main.js顶部的WGSL_SHADER字符串中,也可以在工程中拆分为独立的.wgsl文件,通过 fetch 加载。
struct Uniforms { viewProj : mat4x4<f32>, model : mat4x4<f32>, cameraPos : vec4<f32>, eta : f32, fresnelPower : f32, refractionStrength : f32, }; @group(0) @binding(0) var<uniform> u : Uniforms; @group(0) @binding(1) var sceneTexture : texture_2d<f32>; @group(0) @binding(2) var sceneSampler : sampler; struct VertexOutput { @builtin(position) clipPos : vec4<f32>, @location(0) worldPos : vec3<f32>, @location(1) normal : vec3<f32>, @location(2) uv : vec2<f32>, }; @vertex fn vs_main( @location(0) position : vec3<f32>, @location(1) normal : vec3<f32>, @location(2) uv : vec2<f32> ) -> VertexOutput { var out : VertexOutput; out.worldPos = (u.model * vec4<f32>(position, 1.0)).xyz; out.normal = normalize((u.model * vec4<f32>(normal, 0.0)).xyz); out.uv = vec2<f32>(uv.x, 1.0 - uv.y); out.clipPos = u.viewProj * vec4<f32>(out.worldPos, 1.0); return out; } @fragment fn fs_main(in : VertexOutput) -> @location(0) vec4<f32> { let viewDir = normalize(u.cameraPos.xyz - in.worldPos.xyz); let N = normalize(in.normal); let V = -viewDir; // 折射方向,eta 为入射介质折射率 / 折射介质折射率 let refrDir = refract(V, N, 1.0 / u.eta); // 屏幕空间近似折射采样 let refrUV = in.uv + refrDir.xy * u.refractionStrength; let refractedColor = textureSample(sceneTexture, sceneSampler, refrUV).rgb; // 简化菲涅尔 let fresnel = pow(1.0 - dot(N, -viewDir), u.fresnelPower); // 简化高光 let H = normalize(viewDir + vec3<f32>(0.0, 0.0, 1.0)); let spec = pow(max(dot(N, H), 0.0), 32.0); let finalColor = mix(refractedColor, vec3<f32>(1.0), fresnel) + vec3<f32>(spec) * fresnel; return vec4<f32>(finalColor, 0.9); }7. 运行结果与效果验证
运行代码后,预期看到的现象是:
- 背景球呈现清晰的漫反射颜色。
- 玻璃球体半透明,背后背景球颜色在玻璃区域发生偏移,偏移方向会随视角变化。
- 玻璃球边缘出现明显的菲涅尔高光,中心区域则偏透射。
- 玻璃球体的 Alpha 透明度约 0.9,隐约能透过玻璃看到背景细节。
如果玻璃效果不明显,按以下顺序检查:
- 调整
refractionStrength,这个值控制折射偏移的强度,一般从 0.05 起步尝试。 - 调整
eta,玻璃常见折射率在 1.45 到 1.5 之间,太小会导致几乎没有折射方向变化。 - 调整
fresnelPower,这个值越小,边缘反射越强。
一个更直观的验证方法是:在 Pass 1 中画一个颜色对比强烈的不透明球体,比如一面是红色、一面是蓝色。这样折射偏移是否生效一目了然。
如果运行后直接黑屏或白屏,先检查浏览器控制台有没有 WebGPU 相关的报错。最常见的几个报错包括:
GPUPipelineError:Shader 编译失败,需要检查 WGSL 里是否用了不支持的语法。GPUBufferUsage错误:Buffer 的 usage 和创建时的用途不匹配。- 纹理格式不匹配:离屏纹理格式和画布格式不一致。
8. 常见问题与排查思路
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 玻璃球背后显示全黑 | Pass 1 的storeOp设置成了discard,或离屏纹理没有绑定到玻璃管线 | 检查 Pass 1 的storeOp是否"store";检查 BindGroup 是否绑定了offscreenTexture | 将storeOp改为"store";确保纹理的 usage 包含TEXTURE_BINDING |
| 玻璃球没有折射效果 | UV 偏移量太小,或eta参数不正确 | 调大refractionStrength;检查refract的传参方向 | 将refract的 eta 参数改为1.0 / u.eta |
| 纹理上下颠倒 | 离屏纹理坐标与 NDC 坐标定义不同 | 在顶点着色器输出 UV 时做一次纵向翻转 | 在 vs_main 中输出vec2<f32>(uv.x, 1.0 - uv.y) |
| 玻璃球边缘发黑 | 混合模式或菲涅尔计算错误 | 检查片段着色器返回的 Alpha 值;检查混合系数 | 确认 Alpha 值不为 0;调整菲涅尔公式为 Schlick 近似 |
| 玻璃球遮挡了后续物体 | depthWriteEnabled未关闭 | 检查玻璃管线的 depthStencil 配置 | 设置depthWriteEnabled: false |
浏览器报错Adapter is not available | 浏览器自动请求到了不可用的 GPU 适配器 | 打开chrome://gpu查看 WebGPU 状态;切换浏览器 | 更新浏览器;重启 GPU 驱动;在无 GPU 环境使用软件渲染作为兜底 |
| 帧率下降明显 | 离屏纹理尺寸过大,或每帧创建大量临时对象 | 检查离屏尺寸是否合理;避免每帧重复创建 pipeline 和 buffer | 降低离屏纹理尺寸;复用 pipeline、BindGroup、Buffer 和 CommandEncoder 相关对象 |
9. 最佳实践与工程建议
9.1 命名规范与配置管理
Shader 中的 uniform 字段名要和代码中匹配,建议把折射率、菲涅尔幂次、折射强度等物理参数统一集中到一个常量对象中管理,方便美术同学调整,而不需要修改 Shader 代码。
const glassParams = { ior: 1.5, fresnelPower: 2.0, refractionStrength: 0.08, };9.2 双 Pass 渲染的顺序
双 Pass 渲染中,Pass 1 应该只渲染不透明物体,Pass 2 渲染透明物体并按深度排序。如果场景中有多个透明物体,玻璃球前面还有别的透明物体,排序错误会导致混合结果不正确。工程上通常会在场景管理器中维护一个“不透明物体列表”和一个“透明物体列表”,并在渲染循环中分别处理。
9.3 性能优化
离屏纹理的尺寸不需要和屏幕一样大。1024 或 2048 通常足够,过大的离屏纹理在移动端会有明显显存和带宽压力。折射采样是高频操作,Shader 里尽量避免在循环内做纹理采样。
9.4 内存与对象复用
WebGPU 中创建 pipeline、BindGroup、Buffer 的成本比 WebGL 高。不要在动画循环里反复创建这些对象。更合理的做法是在初始化阶段创建好所有可复用的对象,动画循环内只更新 uniform 数据。
9.5 安全与权限
WebGPU 在 Web 应用中会受到浏览器安全策略约束。页面必须运行在安全上下文(HTTPS 或 localhost)中。私有网络或跨域环境需要确保服务器配置正确,否则requestAdapter可能失败。生产环境上线前,建议在低端设备和常见移动浏览器上做兼容性验证。
9.6 调试建议
Chrome DevTools 中已经支持 WebGPU 的 error scope 功能,可以为关键操作包一层device.pushErrorScope/device.popErrorScope,快速定位是管线问题、资源问题还是着色器问题。对于 WGSL Shader 的调试,建议先用最小三角形场景验证渲染管线正常,再加入玻璃材质逻辑。
10. 总结与后续学习方向
这篇文章从“玻璃为什么难渲染”这个实际问题出发,走通了 WebGPU 中最典型的双 Pass 渲染流程:先用 Pass 1 把背景场景渲染到离屏纹理,再在 Pass 2 中通过 WGSL 片元着色器采样这张纹理,配合折射方向偏移、菲涅尔项和简化高光,实现一个可以动态响应背景的玻璃球体。
这个示例真正的价值不在于玻璃球本身,而在于它把 WebGPU 的离屏渲染、资源绑定、RenderPass 配置和 WGSL 着色器完整串了起来。你掌握了这条链路后,再去做阴影贴图、反射探针、屏幕空间反射、后处理模糊,基本思路都是同一个模式,只是采样内容和计算逻辑不同。
建议你下一步做两件事:第一,把这个玻璃球放到一个更复杂的场景里测试,比如在背景中增加纹理贴图、多光源和多个透明物体;第二,把版图中的简化菲涅尔换成 Schlick 近似,并尝试用多档纹理偏移做更真实的折射模糊。
如果你想深入底层,可以继续研究 WebGPU 的计算着色器(Compute Shader)和更高效的资源绑定策略,这些都是 Web 端高级图形效果的基础。先把这篇里的双 Pass 渲染跑通,再往前走,你会觉得 WebGPU 的世界比 WebGL 开阔很多。