WebGPU 基础知识:加载纹理模型

WebGPU 基础知识:加载纹理模型

介绍

在本教程中,我们将逐步添加新的有用功能:从 OBJ 文件加载网格、加载纹理并最终应用照明。我们可以使用任何具有三角形面的 OBJ,但在这个例子中,我使用了 Blender 中的 Suzanne 猴子。

WebGPU 基础知识:加载纹理模型

我们将从上一个教程中我们停止的地方继续旋转彩色立方体。

加载 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 坐标),它告诉我们基于给定图像文件的像素是什么颜色。
WebGPU 基础知识:加载纹理模型

UV 坐标从左下角开始为 (0,0),到右上角结束为 (1,1),所以我们的 UV 坐标表示“将此顶点绘制为位于图像右侧 93% 处和位于图像上方 82% 处的颜色”。

  • vn 0.3329 0.5231 0.7846 — ‘vn’ 代表顶点法线,这是该顶点法线所采用的方向向量。在计算光线如何影响绘制在像素上的颜色时,法线非常重要,它使我们能够获得更具 3D 效果的结构,而不是平面结构。
WebGPU 基础知识:加载纹理模型

这三个属性(’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 向量与像素处法线之间的点积。如果法线与光的方向更垂直,这使我们能够降低照明效果,因此在现实世界中不会那么亮。

我们还通过反二次距离来减少光照效果,以产生指数衰减效果。

WebGPU 基础知识:加载纹理模型
我粗略画出的点光源

我们的 lightResult 是通过将所有这些因子相乘而得到的。然后我们将光因子应用于基色以获得最终颜色(如果 lightResult < 1,则使对象变暗,如果 lightResult 大于 1,则使对象变亮)。

结论

现在,您应该可以轻松地加载自己的 3D 模型,包括纹理文件和照明。这些是完整 3D 查看器的构建块,您可以基于这些基础来设计您梦想中的 Web GPU 查看器!

RA/SD 衍生者AI训练营。发布者:chris,转载请注明出处:https://www.shxcj.com/archives/6229

(0)
上一篇 2024-09-20 3:46 下午
下一篇 2024-09-20 4:08 下午

相关推荐

发表回复

登录后才能评论
本文授权以下站点有原版访问授权 https://www.shxcj.com https://www.2img.ai https://www.2video.cn