之前在业务中使用原生 WebGPU 做渲染实验时,一直想把各种材质逐步实现一遍。常规的光照材质相对容易搞定,但一到玻璃这种同时包含反射、折射、透射和菲涅尔效应的物体,就发现多数资料停留在 Three.js 的MeshPhysicalMaterial配置层,真正从渲染管线层面讲清楚原理、并用 WGSL 从零实现的内容非常少。
这篇文章就基于原生 WebGPU API,完整实现一个带有环境贴图的玻璃小球。你会看到环境贴图如何构造、反射与折射如何计算、菲涅尔效应如何让玻璃边缘更亮,以及最终的完整代码如何编译运行。读完以后,你不仅能调出一个能看的玻璃材质,还能理解实时渲染中玻璃类材质的设计思路。
1. 为什么要用 WebGPU 实现玻璃材质
1.1 玻璃材质的技术难点
玻璃在实时渲染中是比较特殊的一类材质。普通物体的表面只反射光线,而玻璃表面既反射一部分光线,又折射另一部分光线,甚至在不同角度下,反射和折射的比例还会发生变化。
从物理角度看,光线进入玻璃时需要满足斯涅尔定律:
n1 * sin(θ1) = n2 * sin(θ2)其中n1和n2是两种介质的折射率,θ1是入射角,θ2是折射角。空气的折射率大约是 1.0,普通玻璃的折射率在 1.5 左右。这就意味着光线从空气进入玻璃后,传播方向会发生明显偏折。
在光栅化渲染管线下,我们不会像光线追踪那样逐根光线去追踪光路,所以需要采用近似方案来模拟这种效果。常见做法是:
- 把环境信息烘焙到一张环境贴图中。
- 根据视线方向算出反射方向,从环境贴图中采样反射颜色。
- 根据折射率算出折射方向,从环境贴图中采样透射颜色。
- 用菲涅尔公式混合两者。
这个方案在实时渲染领域已经非常成熟,也是 WebGL、Unity、Unreal 中实现玻璃材质的常见思路。
1.2 WebGPU 相比 WebGL 的优势
WebGPU 作为下一代 Web 图形 API,解决了 WebGL 2 的不少痛点:
- 更接近现代图形 API 的设计,如 Vulkan、Metal、Direct3D 12。
- 着色器语言从 GLSL 改成了 WGSL,语法更接近 Rust,类型约束更严格。
- 支持 Compute Shader,可以做 GPU 通用计算。
- 渲染管线状态管理更显式,减少了隐式状态切换带来的性能损耗。
- 资源绑定模型更灵活,通过 Group 和 Binding 组织纹理、缓冲区和采样器。
对于玻璃材质这种需要大量纹理采样和数学计算的场景,WebGPU 的约束和性能模型反而更有利。
1.3 本文适合的读者
- 有 WebGL 或 Three.js 基础,想了解 WebGPU 原生开发的开发者。
- 需要实现自定义材质效果,但不想完全依赖 Three.js 等库的开发者。
- 正在学习 WGSL,想通过一个完整案例理解片元着色器工作原理的同学。
本文默认你已经知道 WebGPU 的基本概念,比如 Adapter、Device、Pipeline、BindGroup 等。如果完全没接触过,可以先跑一遍 WebGPU 官方示例再回来。
2. 玻璃材质的渲染原理
2.1 玻璃的光学特征
玻璃材质给人的视觉印象主要来自三点:
- 透射:光线穿过玻璃,背景会发生偏移。
- 反射:玻璃表面会反射周围环境,尤其在掠射角方向。
- 菲涅尔效应:垂直观察玻璃时,透射强、反射弱;斜着看时,反射越来越强。
玻璃球还有一个有趣的属性:当光线从玻璃内部射向表面,入射角超过临界角时会发生全反射,玻璃球边缘会显得特别亮。
2.2 反射与折射计算
在 WGSL 中,reflect和refract是两个内置函数。
反射方向的计算公式是:
R = I - 2 * dot(N, I) * N其中I是入射方向,N是表面法线。WGSL 的reflect函数直接实现了这个公式。
折射方向的计算稍复杂,WGSL 内置refract(e, i, eta)函数,其中:
e是入射方向。i是法线方向。eta是入射介质折射率与折射介质折射率之比。
如果把场景统一为“空气进入玻璃”,那么eta就是1.0 / 1.52。注意,当入射角超过临界角时,refract会返回零向量,这正好可以用来模拟全反射现象。
2.3 菲涅尔效应
菲涅尔效应描述了反射率随观察角度变化的现象。完整的菲涅尔公式计算量比较大,实时渲染中通常使用 Schlick 近似:
F = F0 + (1 - F0) * pow(1 - dot(V, N), 5)其中:
F0是表面在垂直观察时的反射率,玻璃大约 0.04。V是视线方向。N是表面法线。
当视线方向与法线重合时,dot(V, N)接近 1,F接近F0,表现为透射为主。当视线方向与法线接近垂直时,F接近 1,反射为主。
这个公式简单、稳定,而且是完全确定性的,非常适合在片元着色器中逐像素计算。
2.4 环境贴图的作用
玻璃材质需要知道“反射到哪”和“折射看到什么”,所以环境贴图是玻璃渲染中不可缺少的输入。
环境贴图最常用的是立方体贴图。把相机放在场景中心,向六个方向分别拍摄一张图片,然后按立方体六个面存储。采样时只需要提供一个方向向量,驱动就会自动从对应的面上取出颜色。
在本文的示例中,为了不依赖外部图片,我会用代码生成一个简单的六面色块环境贴图。这样代码可以在本地直接运行,后续再替换成真实 HDR 环境贴图即可。
3. 环境准备与项目初始化
3.1 浏览器与工具准备
运行本文的代码需要满足以下条件:
- Chrome 或 Edge 浏览器,版本需支持 WebGPU。
- 本地静态文件服务器。
- 代码编辑器,比如 VS Code。
浏览器兼容性方面,目前 Chrome 和 Edge 已经默认支持 WebGPU,Safari 在较新版本中也逐步支持。由于浏览器版本迭代较快,建议以当前自己浏览器版本是否支持为准。
判断方法很简单,在浏览器控制台执行:
console.log(navigator.gpu);如果输出了一个对象,说明浏览器支持 WebGPU。如果输出undefined,说明当前浏览器不支持,或者需要在浏览器设置中打开相关实验开关。
3.2 项目结构
我们只需要一个 HTML 文件,所有代码都写在里面,方便复制和运行:
webgpu-glass-material/ └── index.html在实际项目中,更合理的做法是把 WGSL 代码提取为.wgsl文件,用构建工具或fetch加载。但为了演示方便,本文把它们放在同构的 WebGPU WGSL 字符串中。
3.3 引入 gl-matrix
矩阵运算是图形学中绕不开的部分。WebGPU 本身不提供矩阵工具,这里使用 gl-matrix 库来处理mat4和vec3。
在 HTML 中通过 CDN 引入:
<script src="https://cdn.jsdelivr.net/npm/gl-matrix@3.4.3/gl-matrix-min.js"></script>如果项目需要离线运行,可以把gl-matrix-min.js下载到本地,再通过<script>标签引入,效果相同。
4. 核心代码实现
4.1 创建 WebGPU 设备和上下文
先从浏览器拿到 WebGPU 设备。WebGPU 的初始化流程是固定的:
- 判断
navigator.gpu是否存在。 - 调用
requestAdapter()获取适配器。 - 调用
requestDevice()获取设备。 - 获取 canvas 的
webgpu上下文,并配置格式。
async function initWebGPU(canvas) { if (!navigator.gpu) { throw new Error('当前浏览器不支持 WebGPU'); } const adapter = await navigator.gpu.requestAdapter(); if (!adapter) { throw new Error('无法获取 WebGPU 适配器'); } const device = await adapter.requestDevice(); const context = canvas.getContext('webgpu'); const format = navigator.gpu.getPreferredCanvasFormat(); context.configure({ device, format, alphaMode: 'opaque', }); return { device, context, format }; }这里有一点值得注意:navigator.gpu.getPreferredCanvasFormat()会返回当前平台最合适的纹理格式。在桌面端通常是bgra8unorm,移动端可能是rgba8unorm。直接把这个格式传给context.configure是兼容性最好的做法。
4.2 生成球体顶点数据
玻璃物质用球体来演示最直观,因为球体的法线处处连续,能够充分展示反射和折射的渐变效果。
生成球体的思路是:把球体按经纬度切成网格,每个网格点对应一个顶点位置和法线方向。
function generateSphere(radius = 1, widthSegments = 32, heightSegments = 16) { const vertices = []; const normals = []; const indices = []; for (let y = 0; y <= heightSegments; y++) { for (let x = 0; x <= widthSegments; x++) { const u = x / widthSegments; const v = y / heightSegments; const theta = u * Math.PI * 2; const phi = v * Math.PI; const px = radius * Math.sin(phi) * Math.cos(theta); const py = radius * Math.cos(phi); const pz = radius * Math.sin(phi) * Math.sin(theta); vertices.push(px, py, pz); normals.push(px / radius, py / radius, pz / radius); } } for (let y = 0; y < heightSegments; y++) { for (let x = 0; x < widthSegments; x++) { const a = y * (widthSegments + 1) + x; const b = y * (widthSegments + 1) + x + 1; const c = (y + 1) * (widthSegments + 1) + x; const d = (y + 1) * (widthSegments + 1) + x + 1; indices.push(a, c, b, b, c, d); } } return { vertices, normals, indices }; }这里把位置和法线打印到两个数组里。为了让 GPU 一次性读取,我们可以在写入缓冲时交错排布,也就是每个顶点连续存储position.x, position.y, position.z, normal.x, normal.y, normal.z。
4.3 创建环境纹理
没有外部图片的情况下,可以用代码生成一个六面色块的环境贴图。每个面颜色不同,这样能直观看到反射和折射方向的变化。
function createEnvironmentTexture(device) { const size = 2; const faceColors = [ [200, 100, 80], // +X [80, 160, 200], // -X [120, 220, 120], // +Y [220, 220, 100], // -Y [100, 120, 220], // +Z [220, 140, 100], // -Z ]; const data = new Uint8Array(6 * size * size * 4); for (let face = 0; face < 6; face++) { for (let i = 0; i < size * size; i++) { const offset = (face * size * size + i) * 4; data[offset] = faceColors[face][0]; data[offset + 1] = faceColors[face][1]; data[offset + 2] = faceColors[face][2]; data[offset + 3] = 255; } } const texture = device.createTexture({ size: [size, size, 6], dimension: '2d', format: 'rgba8unorm', usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST, }); device.queue.writeTexture( { texture }, data, { bytesPerRow: size * 4, rowsPerImage: size }, [size, size, 6] ); return texture; }注意,这里创建的是一个拥有 6 个 layer 的 2D 纹理数组,而在采样时我们希望把它当作立方体贴图来用。因此后续创建视图时需要显式指定维度:
const envView = envTexture.createView({ dimension: 'cube' });这是很多新手容易忽略的地方,如果漏掉这一句,着色器里用texture_cube采样时会报绑定错误。
4.4 编写 WGSL 着色器
WGSL 是 WebGPU 的着色器语言。我们的着色器分为两个部分:
- 顶点着色器:计算顶点在裁剪空间中的位置,并输出世界坐标和法线。
- 片元着色器:逐像素计算反射、折射和菲涅尔混合。
先看顶点着色器:
struct Uniforms { viewProj: mat4x4<f32>, model: mat4x4<f32>, cameraPos: vec4<f32>, }; @group(0) @binding(0) var<uniform> u: Uniforms; struct VertexOutput { @builtin(position) clipPos: vec4<f32>, @location(0) worldPos: vec3<f32>, @location(1) normal: vec3<f32>, }; @vertex fn vs_main( @location(0) position: vec3<f32>, @location(1) normal: vec3<f32>, ) -> VertexOutput { var out: VertexOutput; let worldPos = (u.model * vec4<f32>(position, 1.0)).xyz; out.worldPos = worldPos; out.normal = normal; out.clipPos = u.viewProj * vec4<f32>(worldPos, 1.0); return out; }这里把法线直接透传给片元着色器。由于本示例的模型只有旋转,没有非均匀缩放,法线方向可以直接使用顶点法线。如果以后加入缩放变换,就需要用法线矩阵进行修正。
再看片元着色器:
@group(0) @binding(1) var envTexture: texture_cube<f32>; @group(0) @binding(2) var envSampler: sampler; @fragment fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> { let viewDir = normalize(u.cameraPos.xyz - in.worldPos); let n = normalize(in.normal); // 反射方向 let reflectDir = reflect(-viewDir, n); // 折射方向:空气进入玻璃,eta = 1.0 / ior let ior = 1.52; let eta = 1.0 / ior; let refractDir = refract(-viewDir, n, eta); // 采样环境贴图 let reflectColor = textureSample(envTexture, envSampler, reflectDir).rgb; var refractColor: vec3<f32>; if (any(refractDir == vec3<f32>(0.0))) { refractColor = vec3<f32>(0.0); } else { refractColor = textureSample(envTexture, envSampler, refractDir).rgb; } // Schlick 菲涅尔近似 let f0 = 0.04; let fresnel = f0 + (1.0 - f0) * pow(1.0 - dot(viewDir, n), 5.0); let color = mix(refractColor, reflectColor, fresnel); return vec4<f32>(color, 1.0); }这一段是整个材质效果的核心。
先解释几个关键点:
viewDir是从物体表面指向相机的方向。reflect(-viewDir, n)计算反射方向,等价于光线到达表面后弹走的方向。refract(-viewDir, n, eta)计算折射方向,这里eta = 1.0 / 1.52表示从空气近似进入玻璃。textureSample用方向向量采样立方体贴图。pow(1.0 - dot(viewDir, n), 5.0)是菲涅尔效应的核心部分,角度越大,反射占比越高。
当视线方向与法线非常接近时,fresnel接近0.04,所以玻璃主体以透射颜色为主;越靠近球体边缘,fresnel越接近 1,反射颜色越来越明显。这正是真实玻璃的视觉特征。
4.5 编写渲染管线
渲染管线的创建逻辑主要包含以下几部分:
- 创建着色器模块。
- 创建绑定组布局。
- 创建渲染管线。
- 创建绑定组。
- 创建顶点缓冲和索引缓冲。
先看着色器模块和绑定组布局:
const shaderModule = device.createShaderModule({ code: shaderCode, }); const bindGroupLayout = device.createBindGroupLayout({ entries: [ { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' }, }, { binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'float', viewDimension: 'cube' }, }, { binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: { type: 'filtering' }, }, ], });这里viewDimension: 'cube'必须和之前创建的texture.createView({ dimension: 'cube' })对应。
然后是渲染管线:
const pipelineLayout = device.createPipelineLayout({ bindGroupLayouts: [bindGroupLayout], }); const pipeline = device.createRenderPipeline({ layout: pipelineLayout, vertex: { module: shaderModule, entryPoint: 'vs_main', buffers: [ { arrayStride: 6 * 4, attributes: [ { shaderLocation: 0, offset: 0, format: 'float32x3', }, { shaderLocation: 1, offset: 3 * 4, format: 'float32x3', }, ], }, ], }, fragment: { module: shaderModule, entryPoint: 'fs_main', targets: [{ format }], }, primitive: { topology: 'triangle-list', }, });arrayStride表示每个顶点的字节数,这里一个顶点包含 3 个 float 位置和 3 个 float 法线,一共 6 个 float,也就是 24 字节。
创建顶点缓冲和索引缓冲的代码如下:
function createGeometryBuffers(device, geometry) { const interleaved = []; for (let i = 0; i < geometry.vertices.length / 3; i++) { interleaved.push( geometry.vertices[i * 3], geometry.vertices[i * 3 + 1], geometry.vertices[i * 3 + 2], geometry.normals[i * 3], geometry.normals[i * 3 + 1], geometry.normals[i * 3 + 2], ); } const vertexBuffer = device.createBuffer({ size: interleaved.length * 4, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, mappedAtCreation: true, }); new Float32Array(vertexBuffer.getMappedRange()).set(interleaved); vertexBuffer.unmap(); const indexBuffer = device.createBuffer({ size: geometry.indices.length * 4, usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST, mappedAtCreation: true, }); new Uint32Array(indexBuffer.getMappedRange()).set(geometry.indices); indexBuffer.unmap(); return { vertexBuffer, indexBuffer, indexCount: geometry.indices.length }; }mappedAtCreation: true配合getMappedRange()可以在创建缓冲后直接写入数据,写完记得调用unmap()。
4.6 动画循环
为了让效果更直观,让玻璃球围绕 Y 轴缓慢旋转。每次动画帧做三件事:
- 更新 uniform 数据。
- 清屏并绘制球体。
- 请求下一帧。
function frame(device, context, pipeline, bindGroup, vertexBuffer, indexBuffer, indexCount, uniformBuffer) { const now = performance.now() / 1000; const angle = now * 0.5; const model = mat4.create(); mat4.fromRotation(model, angle, [0, 1, 0