如果你是一名3D游戏开发者, 那么你一定知道glTF这个标准文件格式。glTF(一种全新的3D格式)是近年来挑战行业标准的新兴格式。它现在已经成为三维图形方面的新贵,越来越多的公司、工具和引擎都支持它。
那么,glTF究竟是什么?glTF是一种开放标准的3D文件格式,它可用于在Web上交换3D内容和场景。我们可以将其看做是传统3D文件格式(如OBJ和FBX)的替代品。
在本文中,我们将从零开始渲染一个glTF场景。我们将涵盖以下工具和API:
1. glTF文件的读取
2. 利用WebGPU API来实现渲染这些文件。
3. 可以将glTF场景呈现到屏幕上。
一、准备工作
在我们开始编写代码之前,首先需要确保你已经有了一个WebGPU实现。由于WebGPU目前仍然处于开发阶段,因此现有的实现可能不适用于某些浏览器和硬件,我们可以使用官方提供的WebGPU样例来测试(WebGPU HorrorDemo:https://austin-eng.com/webgpu-samples/src/apps/horror-demo/ )。或者直接使用Chrome浏览器,打开”chrome://flags/#enable-unsafe-webgpu”开启WebGPU即可。
接下来,我们需要考虑如何加载glTF文件。glTF文件是一种包含3D场景的JSON格式文件。我们可以使用类似three.js之类的库来加载和解析它,但是本文将不涉及这些库。相反,我们将手动提取文件中的信息和数据,并直接将其传递给WebGPU API进行呈现。
二、加载glTF文件和渲染
开发调试工具,自然少不了大量的调试。下面我就试着将调试工具动起来。
让我们开始渲染glTF文件。为了避免混淆,我们将首先只呈现一个文件中的单个网格,然后再渲染整个场景。我们使用Promise来异步加载文件:
async function loadGLB(url) {
const response = await fetch(url);
const data = await response.arrayBuffer();
const glb = new Uint8Array(data);
const json = extractJSON(glb);
const buffers = extractBuffers(glb);
const images = extractImages(glb);
return { json, buffers, images };
}
我们使用fetch来加载二进制文件,将其转换为Uint8Array,并解析JSON和二进制缓冲区。我们将在后面的部分包括用于提取数据的函数,因此在此处我们只需要了解它们可以提供适当的数据给我们使用即可。
接下来,我们将渲染单个网格。为此,我们首先需要解析JSON数据中的该网格。这是我们如何做到的:
function extractMesh(json, index) {
return json.meshes[index];
}
我们可以传递我们要呈现的网格的索引作为参数,然后将其从JSON数据中提取出来。我们将使用索引0以确保我们呈现场景中的第一个网格。
我们还需要查找该网格需要使用哪个缓冲区进行呈现、需要使用哪个材料以及需要呈现多少实例。因此,我们需要查找它相关的primitive:
function extractPrimitive(json, index) {
return json.primitives[index];
}
从primitive中,我们可以获取许多属性,例如顶点、颜色、法向量和索引值。我们可以使用它们来呈现场景。
在呈现网格之前,我们需要将数据提交给WebGPU API。因此,我们需要创建一个缓冲区对象。
function createBuffer(device, usage, data) {
const buffer = device.createBuffer({
size: data.byteLength,
usage: usage,
});
device.queue.writeBuffer(buffer, 0, data, data.byteLength);
return buffer;
}
可以使用创建的缓冲区对象将数据传递给WebGPU。特别是,我们调用queue.writeBuffer函数来将数据写入缓冲区。
以下是呈现单个网格的完整代码:
async function start(gl, canvas) {
const device = await createDevice(canvas);
const { json, buffers, images } = await loadGLB(
“https://example.com/scene.gltf”
);
const mesh = extractMesh(json, 0);
const primitive = extractPrimitive(mesh, 0);
const buffer = createBuffer(device, “vertex”, buffers[0].data);
const pipeline = createPipeline(device, primitive, images);
const commandEncoder = device.createCommandEncoder();
const passEncoder = commandEncoder.beginRenderPass({
colorAttachments: [
{
view: device.currentTextureView,
loadValue: [0, 0, 0, 1],
},
],
});
passEncoder.setPipeline(pipeline);
passEncoder.setVertexBuffer(0, buffer);
passEncoder.draw(primitive.count);
passEncoder.endPass();
device.queue.submit([commandEncoder.finish()]);
}
我们创建了一个新的WebGPU设备,并加载了glTF文件。然后,我们提取出单个网格和相关的primitive。之后,我们定义并创建了一个WebGPU管线,并将之前创建的顶点缓冲区对象指定给它。
如果我们需要呈现多个网格,则需要多次重复ByteBuffer, primitive和pipeline的创建过程,同时,我们还可以将所有的网格提交到同一个命令编码器中。例如,我们可以使用以下方式循环呈现所有网格:
const commandEncoder = device.createCommandEncoder();
for (let i = 0; i < json.meshes.length; i++) {
const mesh = extractMesh(json, i);
for (let j = 0; j < mesh.primitives.length; j++) {
const primitive = extractPrimitive(mesh, j);
const buffer = createBuffer(device, “vertex”, buffers[primitive.attributes.POSITION]);
const pipeline = createPipeline(device, primitive, images);
const passEncoder = commandEncoder.beginRenderPass({
colorAttachments: [
{
view: device.currentTextureView,
loadValue: [0, 0, 0, 1],
},
],
});
passEncoder.setPipeline(pipeline);
passEncoder.setVertexBuffer(0, buffer);
passEncoder.draw(primitive.count);
passEncoder.endPass();
}
}
device.queue.submit([commandEncoder.finish()]);
现在,您已经了解了如何呈现单个网格,让我们看看如何渲染完整的glTF场景。
三、渲染完整的glTF场景
要呈现完整的glTF场景,我们需要遍历JSON数据,并呈现每个网格。我们还需要为每个网格创建新的缓冲区并呈现它。在此处我们可以考虑使用Instanced drawing实现。Instanced drawing是一种技术,它可以创建多个网格实例,而无需为每个实例单独呈现。
Instanced drawing涉及到调用draw函数时传递额外的实例参数。设我们使用M来表示实例数即多少个矩阵需要呈现,N为网格的顶点数,则我们要使用以下代码编写绘制函数:
passEncoder.draw(N, M);
为了呈现glTF场景,我们可以遍历每个网格并添加其实例数到顶点缓冲区中。例如,我们可以编写以下函数:
function createBufferWithInstances(device, usage, data, instanceCount) {
const instanceData = new Float32Array(instanceCount * 16);
for (let i = 0; i < instanceData.length; i += 16) {
mat4.identity(instanceData.subarray(i, i + 16));
}
const bytes = instanceData.buffer;
const totalBytes = bytes.byteLength + data.byteLength;
const buffer = device.createBuffer({
size: totalBytes,
usage: usage,
});
const mapped = buffer.getMappedRange(0, bytes.byteLength);
new Uint8Array(mapped).set(new Uint8Array(bytes));
device.queue.writeBuffer(buffer, bytes.byteLength, data, data.byteLength);
return buffer;
}
在上述代码实现中,我们首先为每个实例创建一个新的矩阵,并将其填充到instanceData数组中。然后,我们计算缓冲区需要的总字节数,然后创建一个大小为totalBytes的新缓冲区。我们使用getMappedRange函数来获取缓冲区的顶部,在其中填充instanceData数组并将其转换为Uint8Array。接下来,我们使用queue.writeBuffer将数据写入缓冲区即可。
现在,我们已经准备好渲染完整的glTF场景了。下面是渲染完整场景的代码:
async function start(gl, canvas) {
const device = await createDevice(canvas);
const { json, buffers, images } = await loadGLB(
“https://example.com/scene.gltf”
);
let bufferOffset = 0;
const commandEncoder = device.createCommandEncoder();
for (let i = 0; i < json.meshes.length; i++) {
const mesh = extractMesh(json, i);
const instanceCount = 1; // will be updated later
let totalBytes = 0;
const vbuffers = mesh.primitives.map((primitive) => {
instanceCount = Math.max(instanceCount, primitive.count);
const vertexBuffer = createBuffer(
device,
“vertex”,
buffers[primitive.attributes.POSITION].data
);
totalBytes += vertexBuffer.getSize();
return vertexBuffer;
});
const ibuffers = mesh.primitives.map((primitive) => {
const indexBuffer = createBuffer(
device,
“index”,
buffers[primitive.indices].data
);
totalBytes += indexBuffer.getSize();
return indexBuffer;
});
const buffer = createBufferWithInstances(
device,
“vertex”,
new ArrayBuffer(totalBytes),
instanceCount
);
let offset = 0;
for (let j = 0; j < mesh.primitives.length; j++) {
const primitive = extractPrimitive(mesh, j);
const vertexBuffer = vbuffers[j];
const indexBuffer = ibuffers[j];
const pipeline = createPipeline(device, primitive, images);
device.queue.writeBuffer(
buffer,
offset,
vertexBuffer.mapAsync(READ).then((arrayBuffer) => {
return new Uint8Array(arrayBuffer);
}),
vertexBuffer.getSize()
);
offset += vertexBuffer.getSize();
device.queue.writeBuffer(
buffer,
offset,
indexBuffer.mapAsync(READ).then((arrayBuffer) => {
return new Uint8Array(arrayBuffer);
}),
indexBuffer.getSize()
);
offset += indexBuffer.getSize();
let primitiveCount;
if (primitive.mode == 4) {
primitiveCount = primitive.count / 4;
} else {
primitiveCount = primitive.count / 3;
}
commandEncoder.pushDebugGroup(`Draw ‘${mesh.name}’`);
const passEncoder = commandEncoder.beginRenderPass({
colorAttachments: [
{
view: device.currentTextureView,
loadValue: [0, 0, 0, 1],
},
],
});
passEncoder.setPipeline(pipeline);
passEncoder.setVertexBuffer(0, buffer, bufferOffset);
passEncoder.setIndexBuffer(indexBuffer, “uint16”);
passEncoder.drawIndexed(
primitiveCount * instanceCount,
instanceCount,
0,
vertexBuffer.getSize() + indexBuffer.getSize()
);
passEncoder.endPass();
commandEncoder.popDebugGroup();
}
bufferOffset += totalBytes;
}
device.queue.submit([commandEncoder.finish()]);
}
我们首先遍历场景中的每个网格。对于每个网格,我们创建顶点缓冲区和索引缓冲区。然后,我们创建一个单独的缓冲区以呈现整个网格,并将所有数据都填充到这个单独的缓冲区中。我们将使用该缓冲区呈现整个网格。最后,我们将创建管道、提交呈现所需命令并绘制整个场景。我们使用WebGPU广泛的API完成了整个渲染。
总结
在这篇文章中,我们介绍了从0到glTF的完整WebGPU渲染。我们首先使用Promise异步加载glTF文件。接下来,我们解析JSON和二进制缓冲区,呈现单个网格和完整的场景。我们看到了如何使用Instanced drawing技术来渲染多个实例。我们还介绍了如何利用WebGPU API进行呈现操作。我们将继续关注WebGPU的发展和新的技术完美实现更好的渲染效果。
了解更多有趣的事情:https://blog.ds3783.com/