Hello WebGPU —— 加载3D模型 & 基础光照模型
前言
今天让我们继续回到渲染的话题当中来。今天我们要学习的内容是如何加载一个3D模型并且编写一个基本的光照模型shader程序。
加载3D模型
首先,我们需要一个3D模型,这里我们采用了一个很出名的模型:它叫做 stanford-dragon,并且它还是一个NPM包。我们可以通过 yarn add stanford-dragon
在项目中引入这个3D模型,注意这里,它不是一个常见的3D模型格式,它是封装好的数据。常见的3D模型格式有: .obj
, .gltf
, fbx
等,你如果要加载这些类型的3D模型,那么你需要一个分析3D文件的解析器。如何解析3D文件中的数据不在本文的讨论范围之内就不过多赘述了。
这里,我们对数据进行一下简单的封装:
export const mesh = { positions: dragonRawData.positions as [number, number, number][], triangles: dragonRawData.cells as [number, number, number][], normals: [] as [number, number, number][], }; // Compute surface normals mesh.normals = computeSurfaceNormals(mesh.positions, mesh.triangles); export function computeSurfaceNormals( positions: [number, number, number][], triangles: [number, number, number][] ): [number, number, number][] { const normals: [number, number, number][] = positions.map(() => { // Initialize to zero. return [0, 0, 0]; }); triangles.forEach(([i0, i1, i2]) => { const p0 = positions[i0]; const p1 = positions[i1]; const p2 = positions[i2]; const v0 = vec3.subtract(vec3.create(), p1, p0); const v1 = vec3.subtract(vec3.create(), p2, p0); vec3.normalize(v0, v0); vec3.normalize(v1, v1); const norm = vec3.cross(vec3.create(), v0, v1); // Accumulate the normals. vec3.add(normals[i0], normals[i0], norm); vec3.add(normals[i1], normals[i1], norm); vec3.add(normals[i2], normals[i2], norm); }); normals.forEach((n) => { // Normalize accumulated normals. vec3.normalize(n, n); }); return normals; } 复制代码
由于原始数据中没有提供法线信息,所以这里我们需要手动的计算一下模型的法线信息(计算方式:计算相邻两条边的叉积再归一化即可)。
接在为3D模型的数据创建Buffer用于传递顶点数据:
// Create the model vertex buffer. const vertexBuffer = device.createBuffer({ size: mesh.positions.length * 3 * 2 * Float32Array.BYTES_PER_ELEMENT, usage: GPUBufferUsage.VERTEX, mappedAtCreation: true, }); { const mapping = new Float32Array(vertexBuffer.getMappedRange()); for (let i = 0; i < mesh.positions.length; ++i) { mapping.set(mesh.positions[i], 6 * i); mapping.set(mesh.normals[i], 6 * i + 3); } vertexBuffer.unmap(); } 复制代码
接着,为顶点的索引创建Buffer
// Create the model index buffer. const indexCount = mesh.triangles.length * 3; const indexBuffer = device.createBuffer({ size: indexCount * Uint16Array.BYTES_PER_ELEMENT, usage: GPUBufferUsage.INDEX, mappedAtCreation: true, }); { const mapping = new Uint16Array(indexBuffer.getMappedRange()); for (let i = 0; i < mesh.triangles.length; ++i) { mapping.set(mesh.triangles[i], 3 * i); } indexBuffer.unmap(); } 复制代码
这里,为 indexBuffer
作出一些说明。后续我们不会使用 renderPass.draw
来进行绘制,而是采用 renderPass.drawIndexed
来进行绘制。那么这两者之间有什么区别呢?
如上图所示,如果我们需要绘制一个正方形,一个正方形是由两个三角形拼接而成。draw
的API要求我们提供两个三角形的顶点,那我们需要传入6个顶点信息。我们可以发现,这种方式,会有2个顶点的信息是一样的,这样就造成了顶点信息的冗余。
而drawIndexed
的意思是根据顶点的索引来进行绘制。所以这里我们只需要传入4个顶点信息,然后再提供另外一个Buffer,告诉GPU按照 (0, 1, 3, 1, 2, 3) 的顺序来读取顶点信息进行绘制即可。这样我们可以减少一些顶点信息。
往WebGPU中传入 VertexBuffer
的部分此处就不再赘述了,如果你已经忘记了大致的渲染流程,可以回到之前的文章进行复习。
基础光照模型
现在我们来实现一个最为基本的光照模型。我们现在只为物体增加一个漫反射的效果。
如上图所示,一束光线打在一个物体上发生漫反射时,出射光线会向四面八方散开。不管我们从哪个角度去观察物体,物体的亮度应该都是不变的。
我们不难想象,如果一束光打向物体的角度约接近于垂直,则物体接受到的能量越多,物体也就越亮。
还有,如果物体距离光源越近,接受的能量越多,物体也就越亮。
所以,综上所述,我们的着色模型跟光线入射角度和光源距离物体的位置有关。此处为了简单起见,我们只考虑光线的入射角度。
那么,我们在着色器中需要一些场景的信息,还需要往片元着色器中传入每个片元的位置信息、法线信息。
struct Scene { cameraViewProjMatrix: mat4x4<f32>; lightPos: vec3<f32>; }; struct VertexOutput { [[location(0)]] fragPos: vec3<f32>; [[location(1)]] fragNorm: vec3<f32>; [[builtin(position)]] Position: vec4<f32>; }; [[stage(vertex)]] fn main([[location(0)]] position: vec3<f32>, [[location(1)]] normal: vec3<f32>) -> VertexOutput { var output : VertexOutput; output.Position = scene.cameraViewProjMatrix * model.modelMatrix * vec4<f32>(position, 1.0); output.fragPos = output.Position.xyz; output.fragNorm = normal; return output; } 复制代码
对于片元着色器,我们同样需要场景的信息,然后就是刚刚顶点着色器中计算好的信息:
struct Scene { cameraViewProjMatrix : mat4x4<f32>; lightPos : vec3<f32>; }; let ambientFactor : f32 = 0.2; [[stage(fragment)]] fn main(input : FragmentInput) -> [[location(0)]] vec4<f32> { let lambertFactor : f32 = max(dot(normalize(scene.lightPos - input.fragPos), input.fragNorm), 0.0); let lightingFactor : f32 = min(ambientFactor + lambertFactor, 1.0); return vec4<f32>(lightingFactor, 1.0); } 复制代码
这里其中的核心代码就在于: dot(normalize(scene.lightPos - input.fragPos), input.fragNorm)
这里 dot 表示求入射光线与物体法线的投影,也就是表示入射光线与物体法线的相近程度。后续的 max
表示,如果是物体背面则为0。
这里我们还使用了一个常量 ambientFactor
表示环境光照,这样可以使物体的暗部看起来没那么的黑。
最终的效果如下:
总结
今天我们学习了:
如何加载一个3D模型
学习了使用
drawIndexed
这个API来进行绘制可以减少冗余的顶点数据。学习了一个基本的光照模型。
总的来说,今天的内容还是比较少的。希望你能够自己动手实践一番~ 如果你觉得本文有用,不妨点个赞~ 你的支持就是作者更新的动力。感谢阅读,Happy Coding~!
作者:玄玄子
链接:https://juejin.cn/post/7062672237924450311