介绍
在本教程中,我们将逐步添加新的有用功能:从 OBJ 文件加载网格、加载纹理并最终应用照明。我们可以使用任何具有三角形面的 OBJ,但在这个例子中,我使用了 Blender 中的 Suzanne 猴子。
我们将从上一个教程中我们停止的地方继续旋转彩色立方体。
加载 OBJ
要加载OBJ文件,我们必须将 .obj 格式转换为可供使用的顶点列表。我们将创建一个 OBJLoader 类来帮助我们完成此操作。
export interface ObjVec3 {
x: number,
y: number,
z: number
}
export interface ObjUV {
u: number,
v: number
}
export interface ObjVertex {
position: ObjVec3,
normal: ObjVec3,
uv: ObjUV
}
export class OBJLoader {
private constructor() {}
public static async LoadOBJ(path: string): Promise<ObjVertex[]> {
const rawObj = await ((await fetch(path)).text());
const positions: ObjVec3[] = [];
const normals: ObjVec3[] = [];
const uvs: ObjUV[] = [];
const vertices: ObjVertex[] = [];
for (const line of rawObj.split("\n")) {
if (line.startsWith("v ")) {
positions.push(this._parsePositionLine(line));
} else if (line.startsWith("vn ")) {
normals.push(this._parseNormalLine(line));
} else if (line.startsWith("vt ")) {
uvs.push(this._parseUVLine(line));
} else if (line.startsWith("f ")) {
vertices.push(...this._parseIndexLine(line, positions, normals, uvs));
}
}
return vertices;
}
private static _parsePositionLine(line: string): ObjVec3 {
const positionParts = line.split(" ");
return {x: Number(positionParts[1]), y: Number(positionParts[2]), z: Number(positionParts[3])};
}
private static _parseNormalLine(line: string): ObjVec3 {
const normalParts = line.split(" ");
return {x: Number(normalParts[1]), y: Number(normalParts[2]), z: Number(normalParts[3])};
}
private static _parseUVLine(line: string): ObjUV {
const uvParts = line.split(" ");
return {u: Number(uvParts[1]), v: Number(uvParts[2])};
}
private static _parseIndexLine(line: string, positions: ObjVec3[], normals: ObjVec3[], uvs: ObjUV[]): ObjVertex[] {
const extractPositionIndex = (vertex: string): number =>{
return Number(vertex.split("/")[0]) - 1;
}
const extractTextureIndex = (vertex: string): number =>{
return Number(vertex.split("/")[1]) - 1;
}
const extractNormalIndex = (vertex: string): number =>{
return Number(vertex.split("/")[2]) - 1;
}
return line.split(" ").splice(1).flatMap((vertex: string) => {
return {
position: positions[extractPositionIndex(vertex)],
normal: normals[extractNormalIndex(vertex)],
uv: uvs[extractTextureIndex(vertex)]
}
})
}
}
我们有一个名为LoadOBJ 的公共静态方法,它将使用我们的私有辅助函数来解码我们的 .obj 文件。我们需要将 OBJ 放入我们的程序中,然后我们为顶点、法线和 UV 构建数组,并将整个内容作为具有这三个项目作为属性的 OBJVertices 列表返回。
理解 OBJ 中的线条
我们关心的 obj 中的每一行将是以下四个项目之一……
- v 0.437500 0.164062 0.765625 — ‘v’ 表示这是一条顶点线,告诉我们这个顶点在 3D 空间中的位置。它按 x、y、z 顺序排列,因此这个顶点位于 (0.4375, 0.164062, 0.765625)。
- vt 0.931250 0.820926 — ‘vt’ 代表顶点纹理(或 UV 坐标),它告诉我们基于给定图像文件的像素是什么颜色。
UV 坐标从左下角开始为 (0,0),到右上角结束为 (1,1),所以我们的 UV 坐标表示“将此顶点绘制为位于图像右侧 93% 处和位于图像上方 82% 处的颜色”。
- vn 0.3329 0.5231 0.7846 — ‘vn’ 代表顶点法线,这是该顶点法线所采用的方向向量。在计算光线如何影响绘制在像素上的颜色时,法线非常重要,它使我们能够获得更具 3D 效果的结构,而不是平面结构。
这三个属性(’v’、’vn’ 和 ‘vt’)的解析非常简单,只需在空格上拆分字符串并获取相关的数字部分即可。然后我们只需将它们存储在三个单独的数组中即可。但是我们如何确定绘制这些顶点的顺序?哪些 UV 和法线应该与哪些顶点相关联?这就是我们最终的线型 — face — 的用武之地。
- f 71/183/617 199/196/617 200/188/617 — ‘f’ 代表面,告诉我们如何绘制每个三角形。它在文件中出现的顺序就是它在屏幕上绘制的顺序。每个 #/#/# 代表三角形中的一个顶点。第一个数字是顶点数组中要使用的索引(用于位置),第二个数字是 UV 数组中要使用的索引,第三个是法线数组中要使用的索引。
这样我们就可以减少 OBJ 中的重复数据。例如,假设我们的 OBJ 模型只有两种颜色(黑色和白色),但我们的模型有 1,000 个顶点,那么拥有 1,000 条 vt 线会浪费空间。相反,我们只使用两条 vt 线并在面线中引用它们 — 从而减少存储纹理数据所需的空间。
现在您已经了解了 OBJ 文件代表什么,我们的加载器代码实际上非常简单。文本 -> 位置、UV 和法线数组 -> OBJ 顶点的有序列表。我们现在可以在渲染器中使用这个顶点列表。
WebGPU查看器
export class WebGPUViewer {
private _device: GPUDevice | undefined;
private _context: GPUCanvasContext | undefined;
private _format: GPUTextureFormat | undefined;
private _renderPipeline: GPURenderPipeline | undefined;
private _size: { width: number; height: number; } | undefined;
private readonly _vertexBuffers: GPUVertexBufferLayout[] | undefined;
private readonly _fragShader: string;
private readonly _vertShader: string;
private readonly _withDepth: boolean;
private constructor(vertShader: string, fragShader: string, withDepth: boolean, vertexBuffers?: GPUVertexBufferLayout[]) {
this._vertShader = vertShader;
this._fragShader = fragShader;
this._withDepth = withDepth;
this._vertexBuffers = vertexBuffers;
}
public static async init(canvas: HTMLCanvasElement, vertShader: string, fragShader: string, withDepth: boolean, vertexBuffers?: GPUVertexBufferLayout[]): Promise<WebGPUViewer> {
const webGPUViewer: WebGPUViewer = new WebGPUViewer(vertShader, fragShader, withDepth, vertexBuffers);
await webGPUViewer._initWebGpu(canvas);
await webGPUViewer._initRenderPipeline();
return webGPUViewer;
}
get size(): { width: number; height: number } | undefined {
return this._size;
}
public async createTextureBindGroup(textureUrl: string, bindGroup: number): Promise<GPUBindGroup | undefined> {
if (!this._device || !this._renderPipeline) { return }
const res = await fetch(textureUrl);
const img = await res.blob();
const bitmap = await createImageBitmap(img);
const textureSize = [bitmap.width, bitmap.height];
const texture = this._device.createTexture({
size: textureSize,
format: 'rgba8unorm',
usage:
GPUTextureUsage.TEXTURE_BINDING |
GPUTextureUsage.COPY_DST |
GPUTextureUsage.RENDER_ATTACHMENT
});
this._device.queue.copyExternalImageToTexture(
{ source: bitmap },
{ texture: texture },
textureSize
);
const sampler = this._device.createSampler({
magFilter: 'linear',
minFilter: 'linear'
});
return this._device.createBindGroup({
label: 'Texture Group with Texture/Sampler',
layout: this._renderPipeline.getBindGroupLayout(bindGroup),
entries: [
{
binding: 0,
resource: sampler
},
{
binding: 1,
resource: texture.createView()
}
]
})
}
public createBindGroup(buffers: GPUBuffer[]): GPUBindGroup | undefined {
if (!this._device || !this._renderPipeline) { return }
return this._device.createBindGroup({
entries: buffers.map((buffer, index) => {
return {
binding: index,
resource: {
buffer: buffer
}
}
}),
layout: this._renderPipeline.getBindGroupLayout(0)
})
}
public createBuffer(data: Float32Array | Int16Array, usage: number): GPUBuffer | undefined {
if (!this._device) { return }
const buffer = this._device.createBuffer({
size: data.byteLength,
usage: usage,
});
this._device.queue.writeBuffer(buffer, 0, data);
return buffer;
}
public writeToBuffer(buffer: GPUBuffer, data: Float32Array) {
if (!this._device) { return }
this._device.queue.writeBuffer(buffer, 0, data);
}
public draw(numVertices: number, bindGroups: GPUBindGroup[], vertexBuffer?: GPUBuffer) {
if (!this._device || !this._context || !this._renderPipeline) {return}
const commandEncoder = this._device.createCommandEncoder();
const view = this._context.getCurrentTexture().createView();
const colorAttachment: GPURenderPassColorAttachment = {
loadOp: "load",
storeOp: "store",
view: view,
clearValue: {r: 0, g: 0, b: 0, a: 1}
}
const renderPassDescriptor: GPURenderPassDescriptor = {
colorAttachments: [
colorAttachment
],
}
if (this._withDepth && this._size) {
const depthTexture = this._device.createTexture({
size: this._size,
format: 'depth24plus',
usage: GPUTextureUsage.RENDER_ATTACHMENT,
})
const depthView = depthTexture.createView()
renderPassDescriptor.depthStencilAttachment = {
view: depthView,
depthClearValue: 1.0,
depthLoadOp: 'clear',
depthStoreOp: 'store',
}
}
const renderPassEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
renderPassEncoder.setPipeline(this._renderPipeline);
for (let i = 0; i < bindGroups.length; i++) {
renderPassEncoder.setBindGroup(i, bindGroups[i]);
}
if (vertexBuffer) {
renderPassEncoder.setVertexBuffer(0, vertexBuffer);
}
renderPassEncoder.draw(numVertices);
renderPassEncoder.end();
this._device.queue.submit([commandEncoder.finish()]);
}
private async _initWebGpu(canvas: HTMLCanvasElement) {
if (!navigator.gpu) {
throw new Error("GPU not enabled");
}
const adapter = await navigator.gpu.requestAdapter({
powerPreference: "high-performance"
});
if (!adapter) {
throw new Error("Could not get adapter");
}
this._device = await adapter.requestDevice();
this._format = navigator.gpu.getPreferredCanvasFormat();
this._context = canvas.getContext("webgpu") as GPUCanvasContext;
const devicePixelRatio = window.devicePixelRatio || 1;
canvas.height = canvas.clientHeight * devicePixelRatio;
canvas.width = canvas.clientWidth * devicePixelRatio;
this._size = {width: canvas.width, height: canvas.height};
this._context.configure({
device: this._device,
format: this._format,
alphaMode: "opaque"
});
}
private async _initRenderPipeline(): Promise<GPURenderPipeline | undefined> {
if (!this._device || !this._format) {return}
const descriptor: GPURenderPipelineDescriptor = {
layout: 'auto',
vertex: {
module: this._device.createShaderModule({
code: this._vertShader
}),
entryPoint: 'main',
buffers: this._vertexBuffers
},
primitive: {
topology: 'triangle-list' // try point-list, line-list, line-strip, triangle-strip?
},
fragment: {
module: this._device.createShaderModule({
code: this._fragShader
}),
entryPoint: 'main',
targets: [
{
format: this._format
}
]
}
}
if (this._withDepth) {
descriptor.depthStencil = {
depthWriteEnabled: true,
depthCompare: 'less',
format: 'depth24plus',
}
}
this._renderPipeline = await this._device.createRenderPipelineAsync(descriptor);
}
}
WebGPUViewer 与我们在上一个教程中提到的几乎完全相同,唯一的增强是引入了这个createTextureBindGroup函数,它允许我们加载图像并使用我们的 UV 进行引用。
此函数加载给定的图像 URL 并创建一个图像位图(基本上是图像的未压缩版本,其中每个像素颜色在图像矩阵中都有一个条目),我们可以将其提供给 GPU。
然后,我们使用 GPU 设备为纹理腾出空间,并将位图复制到此纹理“缓冲区”。我们提供一个采样器,它告诉我们如何在图像像素之间插入 UV 坐标的值,并为我们的纹理创建并返回一个 bindGroup。
我们还增强了我们的绘制函数,使其能够接受 bindGroups 列表而不是单个列表。
查看器 (App.tsx)
import {useEffect, useRef} from "react";
import {WebGPUViewer} from "./WebGPUViewer";
import {positionVert} from "./shaders/verts";
import {colorFrag} from "./shaders/frags";
import {getMvpMatrix} from "./math";
import {OBJLoader, ObjVertex} from "./OBJLoader";
export const App = () => {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const setUpViewer = async () => {
const positionAttribute: GPUVertexAttribute = {
//position xyz
shaderLocation: 0,
offset: 0,
format: "float32x3"
};
const uvAttribute: GPUVertexAttribute = {
//uv
shaderLocation: 1,
offset: 3*4, //3 float32,
format: "float32x2"
}
const normalAttribute: GPUVertexAttribute = {
//normal
shaderLocation: 2,
offset: 3*4 + 2*4, //3 float32 + 2 float32,
format: "float32x3"
}
const vertexBuffer: GPUVertexBufferLayout = {
arrayStride: 3 * 4 + 2*4 + 3*4, // 3 float32 + 2 float32 + 3 float32,
attributes: [
positionAttribute,
uvAttribute,
normalAttribute
]
}
const webGPUViewer: WebGPUViewer = await WebGPUViewer.init(canvasRef.current!, positionVert, colorFrag, true,[vertexBuffer]);
const obj: ObjVertex[] = await OBJLoader.LoadOBJ("monkey.obj");
const vertex = new Float32Array(obj.flatMap(vert =>
[vert.position.x, vert.position.y, vert.position.z, vert.uv.u, vert.uv.v, vert.normal.x, vert.normal.y, vert.normal.z]
));
const vertexGPUBuffer: GPUBuffer = webGPUViewer.createBuffer(vertex, GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST)!;
let aspect = webGPUViewer.size!.width / webGPUViewer.size!.height;
const position = {x:0, y:0, z: -3}
const scale = {x:1, y:1, z:1}
const rotation = {x: 0, y: 0, z:0}
const mvp = getMvpMatrix(aspect, position, rotation, scale);
const mvpGPUBuffer: GPUBuffer = webGPUViewer.createBuffer(mvp, GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST)!;
const pointLight = new Float32Array(8);
pointLight[0] = 5 // x
pointLight[1] = 1 //y
pointLight[2] = -3 //z
pointLight[4] = 5 // intensity
pointLight[5] = 10 // radius
const pointLightBuffer: GPUBuffer = webGPUViewer.createBuffer(pointLight, GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST)!;
const bindGroup: GPUBindGroup = webGPUViewer.createBindGroup([ mvpGPUBuffer, pointLightBuffer])!;
const textureGroup: GPUBindGroup = (await webGPUViewer.createTextureBindGroup("brick.jpeg", 1))!;
const frame = () => {
rotation.x += 0.005;
rotation.z += 0.005;
webGPUViewer.writeToBuffer(mvpGPUBuffer, getMvpMatrix(aspect, position, rotation, scale));
webGPUViewer.draw(obj.length, [bindGroup, textureGroup], vertexGPUBuffer);
requestAnimationFrame(frame)
}
frame();
}
useEffect(() => {
if (!canvasRef.current) {
return;
}
setUpViewer();
}, [])
return (
<canvas ref={canvasRef} style={{width: "100%", height: "100%"}}/>
);
}
与我们旧的旋转立方体查看器相比,该查看器有四个主要区别,即:向顶点缓冲区添加 UV 和法线、从 OBJ 加载顶点数据、删除颜色并添加灯光以及加载纹理文件。
将 UV 和法线添加到我们的顶点缓冲区
const positionAttribute: GPUVertexAttribute = {
//position xyz
shaderLocation: 0,
offset: 0,
format: "float32x3"
};
const uvAttribute: GPUVertexAttribute = {
//uv
shaderLocation: 1,
offset: 3*4, //3 float32,
format: "float32x2"
}
const normalAttribute: GPUVertexAttribute = {
//normal
shaderLocation: 2,
offset: 3*4 + 2*4, //3 float32 + 2 float32,
format: "float32x3"
}
const vertexBuffer: GPUVertexBufferLayout = {
arrayStride: 3 * 4 + 2*4 + 3*4, // 3 float32 + 2 float32 + 3 float32,
attributes: [
positionAttribute,
uvAttribute,
normalAttribute
]
}
现在,我们可以向 GPU 提供更多关于模型的信息,每个顶点都有三个属性:位置、UV 和法线。在每个属性中……
- 我们指定它将进入哪个@location(#) 以供我们的顶点着色器使用。
- 然后,我们说明该属性在给定顶点中的偏移量(以字节为单位)。例如,uv 偏移了 3 * 4 个字节,因为我们在它前面有 3 个 float32 来编码 x、y 和 z 位置。
- 最后,我们来谈谈属性的格式。
当我们创建顶点缓冲区时,我们必须给出一个步幅量,表示要跳过多少字节才能到达数组中的下一个顶点。
从 OBJ 加载顶点数据
const obj:ObjVertex[] = await OBJLoader.LoadOBJ("monkey.obj");
const vertex = new Float32Array(obj.flatMap(vert =>
[vert.position.x, vert.position.y, vert.position.z, vert.uv.u, vert.uv.v, vert.normal.x, vert.normal.y, vert.normal.z]
));
在 OBJLoader 的帮助下,我们获得顶点,然后将属性平面映射到 float32 数组,以便我们可以将其传递到顶点缓冲区中。
去除颜色并添加灯光
const pointLight = new Float32Array(8);
pointLight[0] = 5 // x
pointLight[1] = 1 //y
pointLight[2] = -3 //z
pointLight[4] = 15 // 强度
pointLight[5] = 10 // 半径
const pointLightBuffer: GPUBuffer = webGPUViewer.createBuffer(pointLight, GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST)!;
const bindGroup: GPUBindGroup = webGPUViewer.createBindGroup([ mvpGPUBuffer, pointLightBuffer])!;
在本例中,我们删除了用于传入立方体的颜色统一体,并传入一个新的 float32 数组来保存有关点光源的信息。我们将其与 MVP 矩阵一起放入绑定组中,将其传入着色器代码中。
加载纹理文件
const textureGroup: GPUBindGroup = (await webGPUViewer.createTextureBindGroup("brick.jpeg", 1))!;
我们的 WebGPUViewer 完成了大部分实际工作,但我们调用此方法将 brick.jpeg 加载到绑定组 1 中以供我们的片段着色器使用。
着色器
现在让我们进入着色器!这些着色器与旧着色器有很大不同,因此我将从头开始介绍它们,而不仅仅是突出不同之处。
顶点着色器
@group(0) @binding(0) var<uniform> mvpMatrix : mat4x4<f32>;
struct VertexOutput {
@builtin(position) Position : vec4<f32>,
@location(0) fragPosition: vec3<f32>,
@location(1) fragUv: vec2<f32>,
@location(2) fragNorm: vec3<f32>,
};
@vertex
fn main(@location(0) position : vec3<f32>, @location(1) uv: vec2<f32>, @location(2) normal: vec3<f32>) -> VertexOutput {
var output : VertexOutput;
output.Position = mvpMatrix * vec4<f32>(position, 1.0);
output.fragPosition = output.Position.xyz;
output.fragUv = uv;
output.fragNorm = (mvpMatrix * vec4<f32>(normal, 1.0)).xyz;
return output;
}
这里没有发生太多事情,我们的顶点着色器将使用我们的 MVP 矩阵计算我们的顶点的位置和法线,然后将顶点的位置、uv 和法线传递到我们的片段着色器进行实际绘画。
片段着色器
@group(0) @binding(1) var<uniform> pointLight : array<vec4<f32>,2>;
@group(1) @binding(0) var Sampler: sampler;
@group(1) @binding(1) var Texture: texture_2d<f32>;
@fragment
fn main(@location(0) fragPosition: vec3<f32>, @location(1) fragUv: vec2<f32>, @location(2) fragNorm: vec3<f32>) -> @location(0) vec4<f32> {
var objectColor = (textureSample(Texture, Sampler, fragUv)).xyz;
//point light
var lightResult = vec3(0.0, 0.0, 0.0);
var pointLightColor = vec3(1.0, 1.0,1.0);
var pointPosition = pointLight[0].xyz;
var pointIntensity: f32 = pointLight[1][0];
var pointRadius: f32 = pointLight[1][1];
var L = pointPosition - fragPosition;
var distance = length(L);
if (distance < pointRadius) {
var diffuse: f32 = max(dot(normalize(L), fragNorm), 1.0);
var distanceFactor: f32 = pow(1.0 - distance / pointRadius, 2.0);
lightResult += pointLightColor * pointIntensity * diffuse * distanceFactor;
}
return vec4<f32>(objectColor * lightResult, 1.0);
}
我们通过首先采样纹理然后应用光来确定像素的颜色。
采样纹理
我们使用内置的textureSample函数,采用texture_2d和采样器绑定以及片段的插值UV,从输入图像中选择像素颜色作为我们的RGB颜色(在这个例子中,我们不处理透明模型,所以我们的alpha将始终为1.0)。
应用照明
一旦我们有了基础颜色,我们就可以应用我们拥有的任何和所有照明的效果。有不同类型的灯光,按计算最简单到最复杂的顺序排列:环境光、方向光、点光和聚光灯。我们将在场景中添加一个点光源。
首先,我们创建一个颜色变量(我们选择白色),并提取位置、强度和半径的值(这些都是通过我们创建的绑定组传入的)。
然后我们对点光源进行计算。
首先我们得到从 fragPosition 到光源的距离向量 L。
如果距离在光的半径范围内,我们会得到 L 向量与像素处法线之间的点积。如果法线与光的方向更垂直,这使我们能够降低照明效果,因此在现实世界中不会那么亮。
我们还通过反二次距离来减少光照效果,以产生指数衰减效果。
我们的 lightResult 是通过将所有这些因子相乘而得到的。然后我们将光因子应用于基色以获得最终颜色(如果 lightResult < 1,则使对象变暗,如果 lightResult 大于 1,则使对象变亮)。
结论
现在,您应该可以轻松地加载自己的 3D 模型,包括纹理文件和照明。这些是完整 3D 查看器的构建块,您可以基于这些基础来设计您梦想中的 Web GPU 查看器!
RA/SD 衍生者AI训练营。发布者:chris,转载请注明出处:https://www.shxcj.com/archives/6229