「这是我参与2022首次更文挑战的第18天,活动详情查看:2022首次更文挑战」。
前言
今天让我们继续回到渲染的话题当中来。今天我们要学习的内容是如何加载一个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~!