当前位置: 首页 > news >正文

WebGPU玻璃材质渲染:反射、折射与菲涅尔效应的完整实现

之前在业务中使用原生 WebGPU 做渲染实验时,一直想把各种材质逐步实现一遍。常规的光照材质相对容易搞定,但一到玻璃这种同时包含反射、折射、透射和菲涅尔效应的物体,就发现多数资料停留在 Three.js 的MeshPhysicalMaterial配置层,真正从渲染管线层面讲清楚原理、并用 WGSL 从零实现的内容非常少。

这篇文章就基于原生 WebGPU API,完整实现一个带有环境贴图的玻璃小球。你会看到环境贴图如何构造、反射与折射如何计算、菲涅尔效应如何让玻璃边缘更亮,以及最终的完整代码如何编译运行。读完以后,你不仅能调出一个能看的玻璃材质,还能理解实时渲染中玻璃类材质的设计思路。

1. 为什么要用 WebGPU 实现玻璃材质

1.1 玻璃材质的技术难点

玻璃在实时渲染中是比较特殊的一类材质。普通物体的表面只反射光线,而玻璃表面既反射一部分光线,又折射另一部分光线,甚至在不同角度下,反射和折射的比例还会发生变化。

从物理角度看,光线进入玻璃时需要满足斯涅尔定律:

n1 * sin(θ1) = n2 * sin(θ2)

其中n1n2是两种介质的折射率,θ1是入射角,θ2是折射角。空气的折射率大约是 1.0,普通玻璃的折射率在 1.5 左右。这就意味着光线从空气进入玻璃后,传播方向会发生明显偏折。

在光栅化渲染管线下,我们不会像光线追踪那样逐根光线去追踪光路,所以需要采用近似方案来模拟这种效果。常见做法是:

  1. 把环境信息烘焙到一张环境贴图中。
  2. 根据视线方向算出反射方向,从环境贴图中采样反射颜色。
  3. 根据折射率算出折射方向,从环境贴图中采样透射颜色。
  4. 用菲涅尔公式混合两者。

这个方案在实时渲染领域已经非常成熟,也是 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 玻璃的光学特征

玻璃材质给人的视觉印象主要来自三点:

  1. 透射:光线穿过玻璃,背景会发生偏移。
  2. 反射:玻璃表面会反射周围环境,尤其在掠射角方向。
  3. 菲涅尔效应:垂直观察玻璃时,透射强、反射弱;斜着看时,反射越来越强。

玻璃球还有一个有趣的属性:当光线从玻璃内部射向表面,入射角超过临界角时会发生全反射,玻璃球边缘会显得特别亮。

2.2 反射与折射计算

在 WGSL 中,reflectrefract是两个内置函数。

反射方向的计算公式是:

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 库来处理mat4vec3

在 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 的初始化流程是固定的:

  1. 判断navigator.gpu是否存在。
  2. 调用requestAdapter()获取适配器。
  3. 调用requestDevice()获取设备。
  4. 获取 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 编写渲染管线

渲染管线的创建逻辑主要包含以下几部分:

  1. 创建着色器模块。
  2. 创建绑定组布局。
  3. 创建渲染管线。
  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 轴缓慢旋转。每次动画帧做三件事:

  1. 更新 uniform 数据。
  2. 清屏并绘制球体。
  3. 请求下一帧。
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
http://www.cnnetsun.cn/news/4266516.html

相关文章:

  • Adafruit TinyS3实战:u.FL天线与ESP32-S3极限小尺寸开发板全解析
  • MATLAB GUI实现图论路径规划:Floyd算法与SVM在俄尔普斯问题中的应用
  • Python-100-Days:Python学习路径,100天从新手到全栈实战的完整地图
  • Hermes Agent自定义工具怎么开发:3步注册一个能用的自定义工具集
  • 空间智能决策引擎 × 智能决策:空间会思考,安全有答案
  • 大模型接入编程工具链:从报错排查到OpenAI兼容接口配置指南
  • 工程视角解读最优Agnostic PAC算法:样本复杂度与模型选择
  • 如何用AI进行高校教材编写?6个步骤+实用工具,轻松搞定教材!
  • 上岸学姐真心话❗90%同学查重降重全做错!OKBIYE双功能才是合规通关关键
  • Skills3:给 Claude 装的「技能包」,如何让它产出能打开的文档
  • Spring Boot校园兼职小程序后端实战:从架构设计到部署优化
  • SWE Refactor Bench:用全仓库栈迁移评测Coding Agent的长期任务能力
  • Superpowers:5分钟给AI编程助手装上一套完整的开发流程
  • 专用Agent开发实战:从零构建安全审查智能体
  • 棋盘覆盖问题:递归分治算法详解与Python实现
  • 改进灰狼优化算法(I-GWO)原理与Python实现:提升多元函数寻优性能
  • 数据科学与大数据技术毕设2026开题帮助
  • 生产级部署:企业内网Codex CLI 批量部署与权限管控方案
  • C++四大排序算法实现与优化:从原理到工程实践
  • 蓝桥杯国赛算法实战:从DP、搜索到工程优化的Java解题全解析
  • AI办公工具怎么选?从工作流与Agent能力判断订阅价值
  • 孟加拉语场景文本识别评估指南:从基准构建到模型实战
  • 斯坦福Rad229 MRI仿真代码:从原理到实践的磁共振成像数字实验室
  • AI Coding落地后,如何重建代码验证与治理体系?
  • 动态规划解决资源分配问题:从理论到代码实战
  • 数学建模竞赛论文格式规范全解析:从底层逻辑到实战指南
  • 2026全网AI论文工具排行榜[特殊字符]上岸学长学姐实测公正排名!
  • 英语教学成果评估数据集:多源学习绩效记录
  • OTLesMix实战:用Wasserstein Barycenter与最优传输合成医学病灶
  • Hacker News 发帖失败排查:从 Show HN 到 Ask HN 的规则与 API 验证