
在 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 材質系統了。