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);
}
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);
}
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
}
},
]
});
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);
}
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);
}
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 });
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"
}
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
);
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
});
}
/**
* 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
}
}
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);
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);
}
`
});
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
}
}
]
});
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]
])
});
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"]);
}
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
});
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()]);
}
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
- Spacescape http://alexcpeterson.com/spacescape/
Top comments (0)