DEV Community

Cover image for WebGPU Engine from Scratch Part 3: Textures
ndesmic
ndesmic

Posted on

WebGPU Engine from Scratch Part 3: Textures

Next let's explore textures. We'll try to recreate the globe from the WebGL chapter.

The first thing we need to do is render the sphere and that can do with a little bit of cleanup. The newly named "mesh-generator" will build a uv sphere but I've cleaned up the implementation and removed some features like centroids that we no longer care about.

Creating a texture

Creating a texture is straightforward:

async initializeTextures(){
    const image = await loadImage("./img/earth.png");
    const textureSize = {
        width: image.width,
        height: image.height,
        depthOrArrayLayers: 1
    };
    const texture = this.#device.createTexture({
        size: textureSize,
        dimension: '2d',
        format: `rgba8unorm`,
        usage: GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING
    });
    this.#device.queue.copyExternalImageToTexture(
        {
            source: image
        }, 
        {
            texture: texture,
            mipLevel: 0
        },
        textureSize
    );
    this.#textures.set("earth", texture);
}
Enter fullscreen mode Exit fullscreen mode

We first load in the image as an Image. We get the texture dimensions width (U), height (V) and depthOrArrayLayers (depth). Then we create a texture on the GPU with those dimensions of a 2d type with the right format and usage flags. Then we upload the texture to the GPU using copyExternalImageToTexture and tell it what mip level it represents. Then we do the normal bookkeeping thing of sticking it in a map so we can reference it later.

We also create a "sampler" which is an object that allows us to sample for the texture (eg applies filtering):

initializeSamplers(){
    const sampler = this.#device.createSampler({
        addressModeU: "repeat",
        addressModeV: "repeat",
        magFilter: "linear",
        minFilter: "nearest"
    });
    this.#samplers.set("main", sampler);
}
Enter fullscreen mode Exit fullscreen mode

Once we have these two things then we can actually bind them so they appear in the shader. First we create the bindGroupLayout:

const textureBindGroupLayout = this.#device.createBindGroupLayout({
    label: "main-texture-bind-group-layout",
    entries: [
        {
            binding: 0,
            visibility: GPUShaderStage.FRAGMENT,
            sampler: {
                type: "filtering"
            }
        },
        {
            binding: 1,
            visibility: GPUShaderStage.FRAGMENT,
            texture: {
                sampleType: "float",
                viewDimension: "2d",
                multisampled: false
            }
        },
    ]
});
Enter fullscreen mode Exit fullscreen mode

This is for the texture and the sampler. Note that they are only used in the fragment shader so we only need that usage. You can look up the rest of the options but they aren't important right now, these are just the basics for 2d color textures.

Make sure add it to the pipeline descriptor (not shown)!

We can reference it in the shader and sample from it using the UV coordinates:

struct VertexOut {
    @builtin(position) position : vec4<f32>,
    @location(0) uv : vec2<f32>
};
struct Uniforms {
    view_matrix: mat4x4<f32>,
    projection_matrix: mat4x4<f32>,
    model_matrix: mat4x4<f32>,
    normal_matrix: mat3x3<f32>,
    camera_position: vec3<f32>
}

@group(0) @binding(0) var<uniform> uniforms : Uniforms;
@group(1) @binding(0) var main_sampler: sampler;
@group(1) @binding(1) var earth_texture: texture_2d<f32>;
@vertex
fn vertex_main(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>) -> VertexOut
{
    var output : VertexOut;
    output.position =  uniforms.projection_matrix * uniforms.view_matrix * uniforms.model_matrix * vec4<f32>(position, 1.0);
    output.uv = uv;
    return output;
}
@fragment
fn fragment_main(fragData: VertexOut) -> @location(0) vec4<f32>
{
    return textureSample(earth_texture, main_sampler, fragData.uv);
}
Enter fullscreen mode Exit fullscreen mode

Last we need to actually bind it to the render pass:

setMainTextureBindGroup(passEncoder, bindGroupLayouts){
    const textureBindGroup = this.#device.createBindGroup({
        layout: bindGroupLayouts.get("textures"),
        entries: [
            { binding: 0, resource: this.#samplers.get("main") },
            { binding: 1, resource: this.#textures.get("earth").createView() },
        ]
    });
    passEncoder.setBindGroup(1, textureBindGroup);
}
Enter fullscreen mode Exit fullscreen mode

The method is called during the render pass.

Input

In order to really see the textures (especially for debug) we want to be able to move things on the screen to see them from different angles so need to hook up the drag events.

//wc-geo.js

attachEvents() {
    document.body.addEventListener("keydown", this.onKeyDown);
    this.dom.canvas.addEventListener("pointerdown", this.onPointerDown);
    this.dom.canvas.addEventListener("wheel", this.onWheel);
}
onKeyDown(e){
    if(!this.#engineReady) return;
    switch (e.code) {
        case "KeyA": {
            this.engine.cameras.get("main").panBy({ x: 0.1 });
            break;
        }
        case "KeyD": {
            this.engine.cameras.get("main").panBy({ x: -0.1 });
            break;
        }
        case "KeyW": {
            this.engine.cameras.get("main").panBy({ z: 0.1 });
            break;
        }
        case "KeyS": {
            this.engine.cameras.get("main").panBy({ z: -0.1 });
            break;
        }
        case "NumpadAdd": {
            this.engine.cameras.get("main").zoomBy(2);
            break;
        }
        case "NumpadSubtract": {
            this.engine.cameras.get("main").zoomBy(0.5);
            break;
        }
    }
    e.preventDefault();
}
onPointerDown(e){
    if (!this.#engineReady) return;
    this.#initialPointer = [e.offsetX, e.offsetY];
    this.#initialCameraPosition = this.engine.cameras.get("main").getPosition();
    this.dom.canvas.setPointerCapture(e.pointerId);
    this.dom.canvas.addEventListener("pointermove", this.onPointerMove);
    this.dom.canvas.addEventListener("pointerup", this.onPointerUp);
}
onPointerMove(e){
    const pointerDelta = [
        e.offsetX - this.#initialPointer[0],
        e.offsetY - this.#initialPointer[1]
    ];
    const radsPerWidth = (180 / DEGREES_PER_RADIAN) / this.#width;
    const xRads = pointerDelta[0] * radsPerWidth;
    const yRads = pointerDelta[1] * radsPerWidth * (this.#height / this.#width);
    this.engine.cameras.get("main").setPosition(this.#initialCameraPosition);
    this.engine.cameras.get("main").orbitBy({ long: xRads, lat: yRads });
}
onPointerUp(e){
    this.dom.canvas.removeEventListener("pointermove", this.onPointerMove);
    this.dom.canvas.removeEventListener("pointerup", this.onPointerUp);
    this.dom.canvas.releasePointerCapture(e.pointerId);
}
onWheel(e){
    if (!this.#engineReady) return;
    e.preventDefault();
    const delta = e.deltaY / 1000;
    this.engine.cameras.get("main").orbitBy({ radius: delta });
Enter fullscreen mode Exit fullscreen mode

This shouldn't be surprising as it's almost entirely copied from the old engine with a few slight changes for naming. We expose the camera via the engine and move it around. This made more sense in the component because that's where the DOM stuff happens and I'd rather not push any more of that down than absolutely necessary.

Issues

The first thing we see is that things get messed up. It's much more apparent when you move the mouse around what's going on.

The back part of the sphere is bleeding through the front. Luckily we know how to solve this: backface culling. We can add this in the pipeline where the primitive is defined:

primitive: {
    topology: "triangle-list",
+   frontFace: "ccw",
+   cullMode: "back"
}
Enter fullscreen mode Exit fullscreen mode

We give it the direction of front and cull the back.

Now it looks a little more consistent but it's upside down. We need to flip the texture because texture coordinates are actually opposite of the image. We can fix this where we upload the texture to the GPU as it gives us a handy flag:

this.#device.queue.copyExternalImageToTexture(
    {
        source: image,
+       flipY: true
    }, 
    {
        texture: texture,
        mipLevel: 0
    },
    textureSize
);
Enter fullscreen mode Exit fullscreen mode

And that fixes it:

Cubemaps

We also want to try out cubemaps with WebGPU so lets to that. Finding a good space cubemap that's free is actually a bit hard but there's a program called spacescape that can generate simple ones, so I used that. I exported the 6 sides.

Update to Meshes

I'm going to do this in 2 passes. The first pass will draw the background and the second pass will draw the Earth. So we need a new mesh for the background which will be a screen-space quad. There's probably a smarter way to just transform the old quad but it's no too important to get clever right away. Then we add new code the initializeMeshes (the {} just lets us block scope the variables so we can reuse them, I also renamed the earth sphere mesh to "Earth" because "background" was a stupid name)

//...sphere mesh
{
    const mesh = new Mesh(screenQuad());
    const vertices = packMesh(mesh, { positions: 2 });
    const vertexBuffer = this.#device.createBuffer({
        size: vertices.byteLength,
        usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
    });
    this.#device.queue.writeBuffer(vertexBuffer, 0, vertices);
    const indexBuffer = this.#device.createBuffer({
        size: mesh.indices.byteLength,
        usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
    });
    this.#device.queue.writeBuffer(indexBuffer, 0, mesh.indices);
    this.#meshes.set("background", {
        vertexBuffer,
        indexBuffer,
        mesh
    });
}
Enter fullscreen mode Exit fullscreen mode
/**
 * Generates a screen space quad. For UI/backgrounds
 * @returns {Mesh}
 */
export function screenQuad() {
    return {
        positions: new Float32Array([
            -1.0, -1.0,
            1.0, -1.0,
            1.0, 1.0,
            -1.0, 1.0,
        ]),
        uvs: new Float32Array([
            0.0, 1.0,
            1.0, 1.0,
            1.0, 0.0,
            0.0, 0.0,
        ]),
        indices: [0, 1, 2, 0, 2, 3],
        length: 4
    }
}
Enter fullscreen mode Exit fullscreen mode

One thing to be careful about here: the number of attributes (position/color/normals etc.) and the number indices is different for the screen quad. We have 4 points but 6 indices because we reuse them so in packMesh we need to be careful which length we use. length on the mesh object is the attribute length, the index length is just mesh.indices.length.

Updates to Textures

For the textures we add a new cubemap.

//cubemap -> [+X, -X, +Y, -Y, +Z, -Z]
const cubeSideImages = await Promise.all([
    loadImage("./img/space_right.png"),
    loadImage("./img/space_left.png"),
    loadImage("./img/space_top.png"),
    loadImage("./img/space_bottom.png"),
    loadImage("./img/space_front.png"),
    loadImage("./img/space_back.png"),
]);
const cubemapSize = {
    width: cubeSideImages[0].width,
    height: cubeSideImages[0].height,
    depthOrArrayLayers: 6
};
const cubemap = this.#device.createTexture({
    size: cubemapSize,
    dimension: "2d",
    format: `rgba8unorm`,
    usage: GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING
});
cubeSideImages.forEach((img, layer) => {
    this.#device.queue.copyExternalImageToTexture(
        {
            source: img,
            flipY: true
        },
        {
            texture: cubemap,
            origin: [0, 0, layer]
        },
        {
            width: img.width,
            height: img.height,
            depthOrArrayLayers: 1
        }
    );
});
this.#textures.set("space", cubemap);
Enter fullscreen mode Exit fullscreen mode

Very similar to a normal 2d texture. We take the first images width and height because they are all the same and we create a texture with 6 layers. When copying we copy each image but we have to assign it to the right layer with the origin property. The 3rd value is the layer, the other two are the texture coordinate. Note that the depthOrArrayLayers for copying is still 1 because the image itself is just one layer and it's being copied into a multilayer texture.

Updates to Pipelines

Since we're doing 2 passes, we need to 2 pipelines so we have to define another one (bleh, there are ways around this but this is the simplest possible thing to do).

const vertexBufferDescriptor = [{
    attributes: [
        {
            shaderLocation: 0,
            offset: 0,
            format: "float32x2"
        }
    ],
    arrayStride: 8,
    stepMode: "vertex"
}];
const shaderModule = this.#device.createShaderModule({
    code: `
    struct VertexOut {
        @builtin(position) frag_position : vec4<f32>,
        @location(0) clip_position: vec4<f32>
    };
    @group(0) @binding(0) var<uniform> inverse_view_matrix: mat4x4<f32>;
    @group(1) @binding(0) var main_sampler: sampler;
    @group(1) @binding(1) var space_texture: texture_cube<f32>;
    @vertex
    fn vertex_main(@location(0) position: vec2<f32>) -> VertexOut
    {
        var output : VertexOut;
        output.frag_position =  vec4(position, 0.0, 1.0);
        output.clip_position = vec4(position, 0.0, 1.0);
        return output;
    }
    @fragment
    fn fragment_main(fragData: VertexOut) -> @location(0) vec4<f32>
    {
        var pos = inverse_view_matrix * fragData.clip_position;
        return textureSample(space_texture, main_sampler, pos.xyz);
    }
    `
});
Enter fullscreen mode Exit fullscreen mode

The vertex buffer only uses float32x2s for screen space coordinates. The shader module is more interesting. If you look back at the previous chapter on the topic we used the inverse view matrix to get the cube map coordinate because we are taking a screen-space coordinate and transforming it back into the camera position. We need to pass the position value through the vertex shader. Note that the builtin "position" will automatically convert to pixel coordinates (eg 0 to 719 for 720px width) and not the clip space coordinates (-1.0 to 1.0) so we must make our own property in the struct to pass clip space coordinate through (position is still required by WGSL so we can't omit it). Once we have the clip space coordinates we can apply the inverse view to it. The resulting vector is the direction the camera is pointing at that particular fragment and we can use it to sample the cube map. Note that magnitude doesn't matter so we don't need to normalize or anything.

We're still using manual bind groups for now so let's make those.

//manually setting bind groups
const uniformBindGroupLayout = this.#device.createBindGroupLayout({
    label: "background-bind-group-layout",
    entries: [
        {
            binding: 0,
            visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
            buffer: {
                type: "uniform"
            }
        }
    ]
});
const textureBindGroupLayout = this.#device.createBindGroupLayout({
    label: "background-texture-bind-group-layout",
    entries: [
        {
            binding: 0,
            visibility: GPUShaderStage.FRAGMENT,
            sampler: {
                type: "filtering"
            }
        },
        {
            binding: 1,
            visibility: GPUShaderStage.FRAGMENT,
            texture: {
                sampleType: "float",
                viewDimension: "cube",
                multisampled: false
            }
        }
    ]
});
Enter fullscreen mode Exit fullscreen mode

I renamed the previous bindgroups to not use the name environment since it sounded too much like "environment map" terminology. Instead all of the basic matrices are just called "uniforms" for lack of better name. We just have one uniform this time, the inverse view matrix. For textures we still need a sampler, and we'll pass the cubemap. Here we need to set the viewDimension to cube so that it knows how to use it.

The rest of the layout is the same. We don't really need the culling but it doesn't hurt.

const pipelineLayout = this.#device.createPipelineLayout({
    label: "background-pipeline-layout",
    bindGroupLayouts: [
        uniformBindGroupLayout,
        textureBindGroupLayout
    ]
});
const pipelineDescriptor = {
    label: "background-pipeline",
    vertex: {
        module: shaderModule,
        entryPoint: "vertex_main",
        buffers: vertexBufferDescriptor
    },
    fragment: {
        module: shaderModule,
        entryPoint: "fragment_main",
        targets: [
            { format: "rgba8unorm" }
        ]
    },
    primitive: {
        topology: "triangle-list",
        frontFace: "ccw",
        cullMode: "back"
    },
    layout: pipelineLayout
};
this.#pipelines.set("background", {
    pipeline: this.#device.createRenderPipeline(pipelineDescriptor),
    pipelineLayout,
    bindGroupLayouts: new Map([
        ["uniforms", uniformBindGroupLayout],
        ["textures", textureBindGroupLayout]
    ])
});
Enter fullscreen mode Exit fullscreen mode

Update to BindGroups

Now the tricky part. What sucks is that bind groups are related to specific pipelines and so our methods to bind only work for the "main" pipeline. We need actual code to bind the new stuff only when the background pass happens.

First I'm going to create an association of meshes with pipelines.

initializePipelineMesh(){
    this.#pipelineMesh.set("background", ["background"]);
    this.#pipelineMesh.set("main", ["earth"]);
}
Enter fullscreen mode Exit fullscreen mode

This is called from initialize like the others and just sets up a mappings. Eventually this will have to be pulled out somehow but this is the easiest way I could think while still being a bit flexible. I did pipeline -> mesh instead of mesh -> pipeline because the latter would require them to be sorted to avoid unnecessary pipeline switching between renders but maybe that would make more sense in the long run, I don't know yet.

We also need to update our pipelineContainer objects in initializePipeline to handle the binding methods since these are now specific per pipeline.

this.#pipelines.set("main", {
    pipeline: this.#device.createRenderPipeline(pipelineDescriptor),
    pipelineLayout,
    bindGroupLayouts: new Map([
        ["environment", uniformBindGroupLayout],
        ["textures", textureBindGroupLayout]
    ]),
+   bindMethod: this.setMainBindGroups.bind(this)
});

//...

this.#pipelines.set("background", {
    pipeline: this.#device.createRenderPipeline(pipelineDescriptor),
    pipelineLayout,
    bindGroupLayouts: new Map([
        ["uniform", uniformBindGroupLayout],
        ["textures", textureBindGroupLayout]
    ]),
+   bindMethod: this.setBackgroundBindGroups.bind(this) //we'll make this next
});
Enter fullscreen mode Exit fullscreen mode

Render Updates

Finally we can render. Since we have an association of pipeline to mesh we can iterate through each pipeline and then each mesh and render. This avoids switching the pipeline unnecessarily.

render() {
    const commandEncoder = this.#device.createCommandEncoder({
        label: "main-command-encoder"
    });
    const passEncoder = commandEncoder.beginRenderPass({
        label: "main-render-pass",
        colorAttachments: [
            {
                storeOp: "store",
                loadOp: "load",
                view: this.#context.getCurrentTexture().createView()
            }
        ]
    });
    const camera = this.#cameras.get("main");
    for(const [pipelineName, meshNames] of this.#pipelineMesh.entries()){
        const pipelineContainer = this.#pipelines.get(pipelineName);
        passEncoder.setPipeline(pipelineContainer.pipeline);
        for(const meshName of meshNames){
            const meshContainer = this.#meshes.get(meshName);
            pipelineContainer.bindMethod(passEncoder, pipelineContainer.bindGroupLayouts, camera, meshContainer.mesh);
            passEncoder.setVertexBuffer(0, meshContainer.vertexBuffer);
            passEncoder.setIndexBuffer(meshContainer.indexBuffer, "uint16");
            passEncoder.drawIndexed(meshContainer.mesh.indices.length);
        }
        passEncoder.end();
    }
    this.#device.queue.submit([commandEncoder.finish()]);
}
Enter fullscreen mode Exit fullscreen mode

Finally we get something that looks like a planet with stars that we can rotate.

Code

https://github.com/ndesmic/geo/tree/v0.3

Links

Top comments (0)