尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

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

WebGPU玻璃材质渲染:反射、折射与菲涅尔效应的完整实现 之前在业务中使用原生 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)接近 1F接近F0表现为透射为主。当视线方向与法线接近垂直时F接近 1反射为主。这个公式简单、稳定而且是完全确定性的非常适合在片元着色器中逐像素计算。2.4 环境贴图的作用玻璃材质需要知道“反射到哪”和“折射看到什么”所以环境贴图是玻璃渲染中不可缺少的输入。环境贴图最常用的是立方体贴图。把相机放在场景中心向六个方向分别拍摄一张图片然后按立方体六个面存储。采样时只需要提供一个方向向量驱动就会自动从对应的面上取出颜色。在本文的示例中为了不依赖外部图片我会用代码生成一个简单的六面色块环境贴图。这样代码可以在本地直接运行后续再替换成真实 HDR 环境贴图即可。3. 环境准备与项目初始化3.1 浏览器与工具准备运行本文的代码需要满足以下条件Chrome 或 Edge 浏览器版本需支持 WebGPU。本地静态文件服务器。代码编辑器比如 VS Code。浏览器兼容性方面目前 Chrome 和 Edge 已经默认支持 WebGPUSafari 在较新版本中也逐步支持。由于浏览器版本迭代较快建议以当前自己浏览器版本是否支持为准。判断方法很简单在浏览器控制台执行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 srchttps://cdn.jsdelivr.net/npm/gl-matrix3.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: mat4x4f32, model: mat4x4f32, cameraPos: vec4f32, }; group(0) binding(0) varuniform u: Uniforms; struct VertexOutput { builtin(position) clipPos: vec4f32, location(0) worldPos: vec3f32, location(1) normal: vec3f32, }; vertex fn vs_main( location(0) position: vec3f32, location(1) normal: vec3f32, ) - VertexOutput { var out: VertexOutput; let worldPos (u.model * vec4f32(position, 1.0)).xyz; out.worldPos worldPos; out.normal normal; out.clipPos u.viewProj * vec4f32(worldPos, 1.0); return out; }这里把法线直接透传给片元着色器。由于本示例的模型只有旋转没有非均匀缩放法线方向可以直接使用顶点法线。如果以后加入缩放变换就需要用法线矩阵进行修正。再看片元着色器group(0) binding(1) var envTexture: texture_cubef32; group(0) binding(2) var envSampler: sampler; fragment fn fs_main(in: VertexOutput) - location(0) vec4f32 { 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: vec3f32; if (any(refractDir vec3f32(0.0))) { refractColor vec3f32(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 vec4f32(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
返回列表