Hello WebGPU —— 加载3D模型 & 基础光照模型

「这是我参与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 来进行绘制。那么这两者之间有什么区别呢?

image.png 如上图所示,如果我们需要绘制一个正方形,一个正方形是由两个三角形拼接而成。draw 的API要求我们提供两个三角形的顶点,那我们需要传入6个顶点信息。我们可以发现,这种方式,会有2个顶点的信息是一样的,这样就造成了顶点信息的冗余。

drawIndexed 的意思是根据顶点的索引来进行绘制。所以这里我们只需要传入4个顶点信息,然后再提供另外一个Buffer,告诉GPU按照 (0, 1, 3, 1, 2, 3) 的顺序来读取顶点信息进行绘制即可。这样我们可以减少一些顶点信息。

往WebGPU中传入 VertexBuffer 的部分此处就不再赘述了,如果你已经忘记了大致的渲染流程,可以回到之前的文章进行复习。

基础光照模型

现在我们来实现一个最为基本的光照模型。我们现在只为物体增加一个漫反射的效果。

image.png

如上图所示,一束光线打在一个物体上发生漫反射时,出射光线会向四面八方散开。不管我们从哪个角度去观察物体,物体的亮度应该都是不变的。

我们不难想象,如果一束光打向物体的角度约接近于垂直,则物体接受到的能量越多,物体也就越亮。

还有,如果物体距离光源越近,接受的能量越多,物体也就越亮。

所以,综上所述,我们的着色模型跟光线入射角度和光源距离物体的位置有关。此处为了简单起见,我们只考虑光线的入射角度。

那么,我们在着色器中需要一些场景的信息,还需要往片元着色器中传入每个片元的位置信息、法线信息。

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 表示环境光照,这样可以使物体的暗部看起来没那么的黑。

最终的效果如下:

image.png

总结

今天我们学习了:

  1. 如何加载一个3D模型
  2. 学习了使用drawIndexed这个API来进行绘制可以减少冗余的顶点数据。
  3. 学习了一个基本的光照模型。

总的来说,今天的内容还是比较少的。希望你能够自己动手实践一番~ 如果你觉得本文有用,不妨点个赞~ 你的支持就是作者更新的动力。感谢阅读,Happy Coding~!

Guess you like

Origin juejin.im/post/7062672237924450311