DEV Community

Cover image for WebGPU Engine from Scratch Part 10: Markup Language and Scene Graph
ndesmic
ndesmic

Posted on

WebGPU Engine from Scratch Part 10: Markup Language and Scene Graph

One thing that is becoming really annoying while doing manual tests is that I have to setup scenes. These are done by adding objects to the various dictionaries in the gpu-engine.js file which involves lots of scrolling and you can't see the scene description as a whole. This time I want to create an SVG-like scene descriptions for this purpose.

The scene as an object

The engine shouldn't deal with anything like this, it should just take raw scene data. It does but it's all over the place so let's combine it all into one object that we can pass in. For each initialize method we pass in the object. At this point I'm not sure if they should be classes themselves or just the object passed to the class constructor. For now they are classes since I don't see an issue with having those external.

Cameras

initializeCameras(cameras){
    for(const [key, camera] of Object.entries(cameras)){
        this.#cameras.set(key, camera);
    }
}
Enter fullscreen mode Exit fullscreen mode

Nothing too interesting here, just iterate over the object to turn it into the map.

Textures

export const DEPTH_TEXTURE = Symbol("depth-texture");
export const PLACEHOLDER_TEXTURE = Symbol("placeholder-texture");

async initializeTextures(textures) {
    for(const [key, texture] of Object.entries(textures)){
        if(texture.image ?? texture.images){
            this.#textures.set(key, await uploadTexture(this.#device, texture.image ?? texture.images, { label: `${key}-texture` }));
        } else if(texture.color){
            this.#textures.set(key, createColorTexture(this.#device, { color: texture.color, label: `${key}-texture` }));
        }
    }
    //default textures
    this.#textures.set(DEPTH_TEXTURE, this.#device.createTexture({
        label: "depth-texture",
        size: {
            width: this.#canvas.width,
            height: this.#canvas.height,
            depthOrArrayLayers: 1
        },
        format: "depth32float",
        usage: GPUTextureUsage.RENDER_ATTACHMENT
    }));
    this.#textures.set(PLACEHOLDER_TEXTURE, createColorTexture(this.#device, { label: "placeholder-texture" }));
}
Enter fullscreen mode Exit fullscreen mode

There are two types of textures. Those that come from images and those that are colors so we handle those cases based on which key is passed in. For some semantic ergonomics we support image or images but they are normalized in either case. We also have our 2 textures that are necessary for the pipeline to function. I've changed those to use symbol keys so they don't get overridden from the outside unintentionally. These need to be applied to the Material class which references them as well. I've pushed the async fetching outside of the engine because that's really not it's concern. With that I've updated uploadTexture to not do the fetching:

//wgpu-utils.js
/**
 * Loads an image url, uploads to GPU and returns texture ref.
 * Cubemaps defined like [+X, -X, +Y, -Y, +Z, -Z]
 * @param {GPUDevice} device 
 * @param {HTMLImageElement | HTMLImageElement[]} imageOrImages
 * @param {{ label?: string }} options 
 */
export function uploadTexture(device, imageOrImages, options = {}) {
    const images = [].concat(imageOrImages);
    const size = {
        width: images[0].width,
        height: images[0].height,
        depthOrArrayLayers: images.length
    };

    const texture = device.createTexture({
        label: options.label,
        size,
        dimension: "2d",
        format: `rgba8unorm`,
        usage: GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING
    });

    images.forEach((img, layer) => {
        device.queue.copyExternalImageToTexture(
            {
                source: img,
                flipY: true
            },
            {
                texture,
                origin: [0, 0, layer]
            },
            {
                width: img.width,
                height: img.height,
                depthOrArrayLayers: 1
            }
        );
    });

    return texture;
}
Enter fullscreen mode Exit fullscreen mode

Materials

initializeMaterials(materials) {
    for (const [key, material] of Object.entries(materials)) {
        this.#materials.set(key, material);
    }
}
Enter fullscreen mode Exit fullscreen mode

Materials are simple.

Samplers

I don't see a good reason right now to let these be set from the outside so they are left alone. But if we do pass them in we need to build a wrapper object for them and iterate over them like usual. I did add symbols for the default and shadow default sampler and those references need to be updated.

Meshes

initializeMeshes(meshes){
    for(const [key, mesh] of Object.entries(meshes)){
        const { vertexBuffer, indexBuffer } = uploadMesh(this.#device, mesh, { label: `${key}-mesh` });
        this.#meshContainers.set(key, { mesh, vertexBuffer, indexBuffer });
    }
}
Enter fullscreen mode Exit fullscreen mode

These are pretty simple too but we need to make sure to keep GPU related activities like uploadMesh in the engine itself. We'd also like to push async network stuff outside of the engine so fetchObj is in the component.

Lights

initializeLights(lights){
    for (const [key, light] of Object.entries(lights)) {
        this.#lights.set(key, light)
    }
    for(const key of this.#lights.keys()){
        this.#shadowMaps.set(key, this.#device.createTexture({
            label: `shadow-map-${key}`,
            size: {
                width: 2048,
                height: 2048,
                depthOrArrayLayers: 1
            },
            format: "depth32float",
            usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING
        }));
    }
    this.#shadowMaps.set("placeholder", this.#device.createTexture({
        label: "placeholder-depth-texture",
        size: { width: 1, height: 1, depthOrArrayLayers: 1 },
        format: "depth32float",
        usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING
    }));
}
Enter fullscreen mode Exit fullscreen mode

Nothing fancy here either except we need to setup the shadow maps. Shadow maps are an internal only thing so I'm not assigning symbols to anything. Since the ShadowMappedLight class is passed in this might indicate some internal leakage...

Pipelines

These shouldn't be exposed.

Pipeline - Mesh associations

initializePipelineMeshes(pipelineMeshes){
    for(const [key, pipelineMesh] of Object.entries(pipelineMeshes)){
        this.#pipelineMesh.set(key, pipelineMesh);
    }
}
Enter fullscreen mode Exit fullscreen mode

Nothing to it. Although this means that the author needs to know about our pipelines and what they are named. Should pipelines be exposed or maybe we need some other abstraction so we can hide the pipeline stuff?

Here's how the new initialization works:

await this.engine.initialize({
    scene: {
        cameras: {
            "main": new Camera({
                position: [0.5, 0.2, -0.5],
                screenHeight: this.dom.canvas.height,
                screenWidth: this.dom.canvas.width,
                fieldOfView: 90,
                near: 0.01,
                far: 5,
                isPerspective: true
            })
        },
        textures: {
            "marble": { image: await loadImage("./img/marble-white/marble-white-base.jpg") },
            "marble-roughness": { image: await loadImage("./img/marble-white/marble-white-roughness.jpg") },
            "red-fabric": { image: await loadImage("./img/red-fabric/red-fabric-base.jpg") },
            "red-fabric-roughness": { image: await loadImage("./img/red-fabric/red-fabric-roughness.jpg") },
            "gold": { color: [0, 0, 0, 1] },
        },
        materials : {
            "marble": new Material({
                texture: "marble",
                useSpecularMap: true,
                specularMap: "marble-roughness"
            }),
            "red-fabric": new Material({
                texture: "red-fabric",
                useSpecularMap: true,
                specularMap: "red-fabric-roughness"
            }),
            "gold": new Material({
                texture: "gold",
                useSpecularMap: false,
                roughness: 0.2,
                metalness: 1,
                baseReflectance: [1.059, 0.773, 0.307]
            })
        },
        meshes: {
            "teapot": (await fetchObjMesh("./objs/teapot.obj", { reverseWinding: true }))
                .useAttributes(["positions", "uvs", "normals"])
                .normalizePositions()
                .resizeUvs(2)
                .setMaterial("gold"),
            "rug": new Mesh(surfaceGrid(2, 2))
                .useAttributes(["positions", "uvs", "normals"])
                .translate({ y: -0.25 })
                .bakeTransforms()
                .setMaterial("red-fabric")
        },
        lights: {
            "light": new ShadowMappedLight({
                type: "directional",
                color: [1.0, 1.0, 1.0, 1],
                direction: [0, -1, 1],
                hasShadow: true,
            })
        },
        pipelineMeshes: {
            "main": ["teapot", "rug"]
        }
    }
});
Enter fullscreen mode Exit fullscreen mode

It's all nicely packed into one structure. It's not perfect. ShadowMappedLight probably shouldn't be part of this, and we know about the pipelines which if this was a different engine these implementations might not be a thing. So there will likely be more work to do here in the future to not leak that into the scene representation.

Building the markup

Might as well use HTML because it's already parsed as DOM. We don't really need custom elements or anything, just the ability to read it. In order to get the key for each entity it will be required that they all have an attribute key. I had originally went with id before deciding that was a bad idea because it conflates DOM ids with our entities key naming.

We do this with a new utility function parseScene which takes a wc-geo element as an parameter.

export async function parseScene(element) { //more below
Enter fullscreen mode Exit fullscreen mode

Cameras

//geo-markup-parser.js
function parseCamera(cameraEl, options) {
    const key = getKey(cameraEl, "camera");

    return [
        key,
        new Camera({
            position: parseVector(cameraEl.getAttribute("position"), 3),
            screenHeight: cameraEl.getAttribute("height") ?? options.defaultHeight,
            screenWidth: cameraEl.getAttribute("width") ?? options.defaultHWidth,
            fieldOfView: cameraEl.getAttribute("fov") ?? 90,
            near: cameraEl.getAttribute("near") ?? 0.01,
            far: cameraEl.getAttribute("far") ?? 5,
            isPerspective: !cameraEl.hasAttribute("is-orthographic")
        })
    ]
}
Enter fullscreen mode Exit fullscreen mode
//geo-markup-parser.js - parseScene
const cameras = Object.fromEntries(Array.from(element.querySelectorAll("geo-camera"))
    .map(c => parseCamera(c, { defaultHeight: element.dom.canvas.height, defaultHWidth: element.dom.canvas.width })));
Enter fullscreen mode Exit fullscreen mode

The only interesting thing here is that we need the default screenHeigth and screenWidth which come from the canvas. It's a bit of hack to just read it out.

Textures

// geo-markup-parser.js
async function parseTexture(textureEl){
    const key = getKey(textureEl, "texture")
    const src = textureEl.getAttribute("src");
    const color = textureEl.getAttribute("color");
    let value;
    if (src) {
        value = { image: await loadImage(src) };
    } else if (color) {
        value = { color: parseVector(color, 4) };
    }

    return [key, value];
}
Enter fullscreen mode Exit fullscreen mode
// geo-markup-parser.js - parseScene
const textures = Object.fromEntries(await Promise.all(Array.from(element.querySelectorAll("geo-texture"))
        .map(parseTexture)));
Enter fullscreen mode Exit fullscreen mode

Textures have two types, color and image url. Unfortunately they are async which complicates things (ideally async things should not happen in the parser but whatever).

Materials

For the materials I decided we'll stick with the PBR paradigm going forward and so instances of string "texture" are renamed "albedoMap" and "specularMap" to "roughnessMap".

// geo-markup-parser.js
function parseMaterial(materialEl) {
    const key = getKey(materialEl, "material");
    const roughnessMap = materialEl.getAttribute("roughness-map");
    const albedoMap = materialEl.getAttribute("albedo-map");

    return [
        key,
        new Material({
            name: key,
            albedoMap: albedoMap,
            useRoughnessMap: !!roughnessMap,
            roughness: parseFloatOrDefault(materialEl.getAttribute("roughness")),
            metalness: parseFloatOrDefault(materialEl.getAttribute("metalness")),
            baseReflectance: parseVector(materialEl.getAttribute("base-reflectance"), 3)
        })
    ]
}
Enter fullscreen mode Exit fullscreen mode
// geo-markup-parser.js - parseScene
const materials = Object.fromEntries(Array.from(element.querySelectorAll("geo-material"))
    .map(parseMaterial));
Enter fullscreen mode Exit fullscreen mode

Nothing interesting otherwise.

Meshes

The surface grid can be it's own element since it's a generative primitive mesh. I've renamed the height and width parameters to rowCount and colCount respectively because they align better with what's happening and don't imply orientation.

// geo-markup-parser.js
async function parseMesh(meshEl) {
    const key = getKey(meshEl, "mesh");
    const reverseWinding = meshEl.hasAttribute("reverse-winding");
    const src = meshEl.getAttribute("src");
    const mesh = await fetchObjMesh(src, { reverseWinding });

    updateMeshAttributes(meshEl, mesh);

    return [key, mesh];
}
Enter fullscreen mode Exit fullscreen mode
// geo-markup-parser.js
function parseSurfaceGrid(meshEl) {
    const key = getKey(meshEl, "surface-grid");
    const rowCount = parseInt(meshEl.getAttribute("row-count"), 10);
    const colCount = parseInt(meshEl.getAttribute("col-count"), 10);
    const mesh = new Mesh(surfaceGrid(rowCount, colCount));

    updateMeshAttributes(meshEl, mesh);

    return [key, mesh];
}
Enter fullscreen mode Exit fullscreen mode
// geo-markup-parser.js - sceneParser
const meshes = Object.fromEntries(await Promise.all(Array.from(element.querySelectorAll("geo-mesh"))
    .map(parseMesh)));
const surfaceGrids = Object.fromEntries(Array.from(element.querySelectorAll("geo-surface-grid"))
    .map(parseSurfaceGrid));
Enter fullscreen mode Exit fullscreen mode

Lights

After thinking about it harder, the ShadowMappedLight doesn't need to exist after all. Since we can calculate the view matrix and projection matrix with a few constants and non-light values we can make these into helper functions. The only exception is hasShadow which can be moved into the Light itself because it does make sense for it to be used in other engine contexts. I did that and renamed it to castsShadow because it makes more grammatical sense

//light-utils.js
const distance = 0.75;
const center = [0, 0, 0];
const frustumScale = 2;

export function getLightViewMatrix(direction) {
    const lightPosition = scaleVector(subtractVector(center, direction), distance);
    return getLookAtMatrix(lightPosition, center);
}

export function getLightProjectionMatrix(aspectRatio) {
    const right = aspectRatio * frustumScale;
    return getOrthoMatrix(-right, right, -frustumScale, frustumScale, 0.1, Math.min(distance * 2, 2.0));
}
Enter fullscreen mode Exit fullscreen mode

Even this is dubious because it's just some transforms that are near-generic. Could also be removed but I want some level of abstraction because I feel in the future this might handle more than it does like point and spotlights.

// geo-markup-parser.js
function parseLights(lightEl) {
    const key = getKey(lightEl, "light");
    const light = new Light({
        type: lightEl.getAttribute("type") ?? "point",
        color: parseVector(lightEl.getAttribute("color"), 4, [1, 1, 1, 1]),
        direction: parseVector(lightEl.getAttribute("direction"), 3, [0, 0, 0]),
        castsShadow: lightEl.hasAttribute("casts-shadow")
    });

    return [key, light];
}
Enter fullscreen mode Exit fullscreen mode
// geo-markup-parser.js - parseScene
const lights = Object.fromEntries(Array.from(element.querySelectorAll("geo-light"))
        .map(parseLights));
Enter fullscreen mode Exit fullscreen mode

Pipeline mesh

I still don't like it but we'll take it from the meshes themselves.

// geo-markup-parser.js
function getPipelineMesh(meshEl) {
    const pipeline = meshEl.getAttribute("pipeline");
    const meshKey = meshEl.getAttribute("key");

    return {
        pipeline,
        meshKey
    };
}
Enter fullscreen mode Exit fullscreen mode
// geo-markup-parser.js - parseScene
const pipelineMeshes = Array.from(element.querySelectorAll("geo-mesh, geo-surface-grid"))
        .map(getPipelineMesh);
Enter fullscreen mode Exit fullscreen mode

Finally we return the rest:

// geo-markup-parser.js - parseScene
if (cameras.length === 0) {
    throw new Error("Need a 'main' camera defined");
}
return {
    cameras,
    textures,
    materials,
    meshes: { ...meshes, ...surfaceGrids },
    lights,
    pipelineMeshes
};
Enter fullscreen mode Exit fullscreen mode

This code is not fantastic. We don't have a lot of validation or error messages that would be nice. This won't work on the server without a DOM polyfill either. It might be nice if some resources could be anonymous and just nested, like textures under materials for example. But it gets the job done. If there's more need maybe we can do a bit more.

Grouping and scene graph

One thing that would be nice is some grouping though. The idea here is that we group related parts of a scene so we can do things like apply transforms to all of them. We can start with a group class.

//group.js
export class Group {
    #children = [];
    #transforms = [];

    constructor(options) {
        this.#children = options.children;
    }

    get children() {
        return this.#children;
    }

    getModelMatrix() {
        return this.#transforms.reduce((mm, tm) => multiplyMatrix(tm, [4, 4], mm, [4, 4]), getIdentityMatrix());
    }

    translate({ x = 0, y = 0, z = 0 }) {
        this.#transforms.push(getTranslationMatrix(x, y, z));
        return this;
    }
    scale({ x = 1, y = 1, z = 1 }) {
        this.#transforms.push(getScaleMatrix(x, y, z));
        return this;
    }
    rotate({ x, y, z }) {
        //there's an order dependency here... something something quaternions...
        if (x) {
            this.#transforms.push(getRotationXMatrix(x));
        }
        if (y) {
            this.#transforms.push(getRotationYMatrix(y));
        }
        if (z) {
            this.#transforms.push(getRotationZMatrix(z));
        }
        return this;
    }
}
Enter fullscreen mode Exit fullscreen mode

This just collects meshes and has a transform array. Technically we could add more types of things to the group like lights and cameras but for simplicity today we'll just consider meshes. I'm also not going to immediately deal with subclassing yet but it makes sense that "transformable" things share one.

To parse a group

//geo-markup-parser.js
/**
 * 
 * @param {HTMLElement} groupEl 
 */
async function parseGroup(groupEl){
    const key = getKey(groupEl, "group");
    const children = await Promise.all(Array.from(groupEl.children).map(async c => {
        switch(c.tagName){
            case "GEO-MESH": {
                return (await parseMesh(c))[1];
            }
            case "GEO-SURFACE-GRID": {
                return parseSurfaceGrid(c)[1];
            }
            default: {
                throw new Error(`Group doesn't support ${c.tagName} children`)
            }
        }
    }))

    const group = new Group({
        children
    });

    return [key, group];
}

//geo-markup-parser.js - parseScene
const groups = Object.fromEntries(await Promise.all(Array.from(element.children).filter(c => c.tagName === "GEO-GROUP")
    .map(parseGroup)));
Enter fullscreen mode Exit fullscreen mode

Parsing is simple but I've modified it so that it only checks one level deep. This will be a limitation for now but it sets up recursion. Note that the children we only need the value, not the key. Once recursion is setup the keys will be less important. Likewise, meshes will not check the whole tree, just the top level.

//geo-markup-parser.js - parseScene
const meshes = Object.fromEntries(await Promise.all(Array.from(element.children).filter(c => c.tagName === "GEO-MESH")
    .map(parseMesh)));
Enter fullscreen mode Exit fullscreen mode

And hook it up in the engine:

//gpu-engine.js - intialize
this.initializeGroups(scene.groups);

//gpu-engine.js
initializeGroups(groups){
    for(const [key, group] of Object.entries(groups)){
        this.initializeMeshes(group.children);
        this.#groups.set(key, group);
    }
}
Enter fullscreen mode Exit fullscreen mode

At this point nothing should change.

To render the groups we need to make the rendering recursive.

//gpu-engine.js - renderShadowMaps
renderShadowMaps(){
    const commandEncoder = this.#device.createCommandEncoder({
        label: "shadow-map-command-encoder"
    });
    const shadowMapPipelineContainer = this.#pipelines.get("shadow-map");
    for(const [key, light] of this.#lights){
        let isFirstPass = true;
        const passEncoder = commandEncoder.beginRenderPass({
            label: `shadow-map-render-pass`,
            colorAttachments: [],
            depthStencilAttachment: {
                view: this.#shadowMaps.get(key).createView(),
                depthClearValue: 1.0,
                depthStoreOp: "store",
                depthLoadOp: isFirstPass ? "clear" : "load",
            }
        });
        passEncoder.setPipeline(shadowMapPipelineContainer.pipeline);
        const renderRecursive = (meshOrGroup) => {
            if(meshOrGroup instanceof Group){
                for(const child of meshOrGroup.children){
                    renderRecursive(child)
                }
            } else {
                const shadowMap = this.#shadowMaps.get(key);
                const meshContainer = this.#meshContainers.get(meshOrGroup);
                shadowMapPipelineContainer.bindMethod(passEncoder, shadowMapPipelineContainer.bindGroupLayouts, light, shadowMap, meshOrGroup);
                passEncoder.setVertexBuffer(0, meshContainer.vertexBuffer);
                passEncoder.setIndexBuffer(meshContainer.indexBuffer, "uint16");
                passEncoder.drawIndexed(meshContainer.mesh.indices.length);
            }
        }
        for (const meshName of this.#pipelineMesh.get("main")) {
            const group = this.#groups.get(meshName);
            renderRecursive(group);
        }
        passEncoder.end();
        isFirstPass = false;
    }
    this.#device.queue.submit([commandEncoder.finish()]);
}

//gpu-engine.js - renderScene
renderScene(){
    const commandEncoder = this.#device.createCommandEncoder({
        label: "main-command-encoder"
    });
    const camera = this.#cameras.get("main");
    let isFirstPass = true;
    const depthView = this.#textures.get(DEPTH_TEXTURE).createView();
    for (const [pipelineName, meshNames] of this.#pipelineMesh.entries()) {
        const passEncoder = commandEncoder.beginRenderPass({
            label: `${pipelineName}-render-pass`,
            colorAttachments: [
                {
                    storeOp: "store",
                    loadOp: isFirstPass ? "clear" : "load",
                    clearValue: { r: 0.1, g: 0.3, b: 0.8, a: 1.0 },
                    view: this.#context.getCurrentTexture().createView()
                }
            ],
            depthStencilAttachment: {
                view: depthView,
                depthClearValue: 1.0,
                depthStoreOp: "store",
                depthLoadOp: isFirstPass ? "clear" : "load"
            }
        });
        const pipelineContainer = this.#pipelines.get(pipelineName);
        passEncoder.setPipeline(pipelineContainer.pipeline);
        const renderRecursive = (meshOrGroup) => {
            if(meshOrGroup instanceof Group){
                for(const child of meshOrGroup.children){
                    renderRecursive(child)
                }
            } else {
                const meshContainer = this.#meshContainers.get(meshOrGroup);
                pipelineContainer.bindMethod(passEncoder, pipelineContainer.bindGroupLayouts, camera, meshContainer.mesh, this.#lights, this.#shadowMaps);
                passEncoder.setVertexBuffer(0, meshContainer.vertexBuffer);
                passEncoder.setIndexBuffer(meshContainer.indexBuffer, "uint16");
                passEncoder.drawIndexed(meshContainer.mesh.indices.length);
            }
        }
        for (const meshName of meshNames) {
            const group = this.#groups.get(meshName);
            renderRecursive(group);
        }
        passEncoder.end();
        isFirstPass = false;
    }
    this.#device.queue.submit([commandEncoder.finish()]);
}
Enter fullscreen mode Exit fullscreen mode

Note that since meshes no longer have specific keys, I changed the lookup to work on the mesh object itself.

//gpu-engine.js - initializeMeshes
initializeMeshes(meshes){
    for(const [key, mesh] of Object.entries(meshes)){
        const { vertexBuffer, indexBuffer } = uploadMesh(this.#device, mesh, { label: `${key}-mesh` });
        this.#meshContainers.set(mesh, { mesh, vertexBuffer, indexBuffer });
    }
}
Enter fullscreen mode Exit fullscreen mode

This again should render the same scene provided we update the markup.

<wc-geo>
    <geo-camera key="main" position="0.5, 0.2, -0.5"></geo-camera>
    <geo-texture key="marble" src="./img/marble-white/marble-white-base.jpg"></geo-texture>
    <geo-texture key="marble-roughness" src="./img/marble-white/marble-white-roughness.jpg"></geo-texture>
    <geo-texture key="red-fabric" src="./img/red-fabric/red-fabric-base.jpg"></geo-texture>
    <geo-texture key="red-fabric-roughness" src="./img/red-fabric/red-fabric-roughness.jpg"></geo-texture>
    <geo-texture key="gold" color="0, 0, 0, 1"></geo-texture>
    <geo-material key="marble" roughness-map="marble-roughness" albedo-map="marble"></geo-material>
    <geo-material key="red-fabric" roughness-map="red-fabric-roughness" albedo-map="red-fabric"></geo-material>
    <geo-material key="gold" roughness="0.2" metalness="1" base-reflectance="1.059, 0.773, 0.307" albedo-map="gold"></geo-material>
    <geo-group key="teapot-rug" pipeline="main" rotate="1.5707963267948966, 0, 0">
        <geo-mesh 
            key="teapot" 
            normalize
            bake-transforms 
            reverse-winding 
            src="./objs/teapot.obj" 
            resize-uvs="2" 
            material="gold" 
            attributes="positions, normals, uvs">
        </geo-mesh>
        <geo-surface-grid 
            key="rug" 
            row-count="2" 
            col-count="2" 
            translate="0, -0.25, 0" 
            bake-transforms 
            material="red-fabric" 
            attributes="positions, normals, uvs">
        </geo-surface-grid>
    </geo-group>

    <geo-light key="light1" type="directional" color="1, 1, 1, 1" direction="0, -1, 1" casts-shadow></geo-light>
Enter fullscreen mode Exit fullscreen mode

I've added the pipeline to the group instead. The one thing that doesn't work is the rotation because we need to recursively setup the transform.

Transforms on groups

Groups should be recursive in order to make full use of them. In order to do that we should start parsing them recursively:

// geo-markup-parser.js
/**
 * 
 * @param {HTMLElement} groupEl 
 */
async function parseGroup(groupEl){
    const key = getKey(groupEl, "group");
    const children = await Promise.all(Array.from(groupEl.children).map(async c => {
        switch(c.tagName){
            case "GEO-MESH": {
                return (await parseMesh(c))[1];
            }
            case "GEO-SURFACE-GRID": {
                return parseSurfaceGrid(c)[1];
            }
            case "GEO-QUAD": {
                return parseQuad(c);
            }
            case "GEO-CUBE": {
                return parseCube(c)
            }
            case "GEO-GROUP": {
                return (await parseGroup(c))[1]
            }
            default: {
                throw new Error(`Group doesn't support ${c.tagName} children`)
            }
        }
    }));

    const group = new Group({
        children
    });

    const translate = parseVector(groupEl.getAttribute("translate"), 3);
    if (translate) {
        group.translate({ x: translate[0], y: translate[1], z: translate[2] });
    }

    const rotate = parseVector(groupEl.getAttribute("rotate"), 3);
    if (rotate) {
        group.rotate({ x: rotate[0], y: rotate[1], z: rotate[2] });
    }

    const scale = parseVector(groupEl.getAttribute("scale"), 3);
    if (scale) {
        group.scale({ x: scale[0], y: scale[1], z: scale[2] });
    }

    return [key, group];
}
Enter fullscreen mode Exit fullscreen mode

It's not very clean because we have keys we aren't using but it'll do for now. In the engine we should initialize things recursively.

//gpu-engine.js
initializeMesh(mesh, key) {
    const { vertexBuffer, indexBuffer } = uploadMesh(this.#device, mesh, { label: `${key}-mesh` });
    this.#meshContainers.set(mesh, { mesh, vertexBuffer, indexBuffer });
}
initializeMeshes(meshes) {
    for (const [key, mesh] of Object.entries(meshes)) {
        this.initializeMesh(mesh, key);
    }
}
initializeGroups(groups) {
    for (const [key, group] of Object.entries(groups)) {
        this.initializeGroup(group, key);
    }
}
initializeGroup(group, key) {
    for (const child of group.children) {
        if (child instanceof Mesh) {
            this.initializeMesh(child);
        } else if (child instanceof Group) {
            this.initializeGroup(child);
        }
    }
    this.#groups.set(key, group);
}
Enter fullscreen mode Exit fullscreen mode

This still allows things to work the other way for now but if we restricted everything to a group this would become cleaner.

In order to actually apply the transforms we'll create a new concept of a world matrix per entity. (I also changed the getModelMatrix method to just be a getter since I doubt we'll need parameters for those)

//mesh.ts
#worldMatrix = getIdentityMatrix();
//mesh.js
get worldMatrix() {
    return this.#worldMatrix;
}
/**
 * @param {Float32Array} value 
 */
set worldMatrix(value){
    this.#worldMatrix = value;
}
Enter fullscreen mode Exit fullscreen mode

This is world matrix tells the object its parent transforms. The idea here is that the world matrix is set recursively. At each group we multiple the world matrix by the model matrix of the group, this becomes the world matrix for each of that group's child entities. By recursively multiplying them they will get us coordinates in the top-most space, the true global world space.

//group.ts
import { getIdentityMatrix, getRotationXMatrix, getRotationYMatrix, getRotationZMatrix, getScaleMatrix, getTranslationMatrix, multiplyMatrix } from "../utilities/vector.js";

export class Group {
    #children = [];
    #transforms = [];
    #worldMatrix = getIdentityMatrix();

    constructor(options) {
        this.#children = options.children;
    }

    get children() {
        return this.#children;
    }

    get modelMatrix() {
        return this.#transforms.reduce((mm, tm) => multiplyMatrix(tm, [4, 4], mm, [4, 4]), getIdentityMatrix());
    }

    get worldMatrix(){
        return this.#worldMatrix;
    }

    set worldMatrix(value){
        this.#worldMatrix = value;
        this.updateWorldMatrix();
    }

    updateWorldMatrix(){
        const worldMatrix = multiplyMatrix(this.modelMatrix, [4,4], this.#worldMatrix, [4,4]);
        for(const child of this.#children){
            child.worldMatrix = worldMatrix;
        }
    }

    translate({ x = 0, y = 0, z = 0 }) {
        this.#transforms.push(getTranslationMatrix(x, y, z));
        this.updateWorldMatrix();
        return this;
    }
    scale({ x = 1, y = 1, z = 1 }) {
        this.#transforms.push(getScaleMatrix(x, y, z));
        this.updateWorldMatrix();
        return this;
    }
    rotate({ x, y, z }) {
        //there's an order dependency here... something something quaternions...
        if (x) {
            this.#transforms.push(getRotationXMatrix(x));
        }
        if (y) {
            this.#transforms.push(getRotationYMatrix(y));
        }
        if (z) {
            this.#transforms.push(getRotationZMatrix(z));
        }
        this.updateWorldMatrix();
        return this;
    }
}
Enter fullscreen mode Exit fullscreen mode

Here, whenever a group is updated with a transform it will recursively update its children, and if they are groups, their children. This is the magic that makes nesting work.

The mesh-level multiplication will happen in the shader, so we'll pass this matrix in as well.

//gpu-engine.js
setMainSceneBindGroup(passEncoder, bindGroupLayouts, camera, mesh) {
    const scene = {
        viewMatrix: camera.getViewMatrix(),
        projectionMatrix: camera.getProjectionMatrix(),
        modelMatrix: getTranspose(mesh.modelMatrix, [4, 4]), //change to col major
        worldMatrix: mesh.worldMatrix,
        normalMatrix: getTranspose(
            getInverse(
                trimMatrix(
                    multiplyMatrix(mesh.worldMatrix, [4, 4], mesh.modelMatrix, [4, 4]),
                    [4, 4],
                    [3, 3]
                ),
                [3, 3]
            ),
            [3, 3]),
        cameraPosition: camera.getPosition()
    };
    const sceneData = packStruct(scene, [
        ["viewMatrix", "mat4x4f32"],
        ["projectionMatrix", "mat4x4f32"],
        ["modelMatrix", "mat4x4f32"],
        ["worldMatrix", "mat4x4f32"],
        ["normalMatrix", "mat3x3f32"],
        ["cameraPosition", "vec3f32"]
    ]);
    const sceneBuffer = this.#device.createBuffer({
        size: sceneData.byteLength,
        usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
        label: "main-scene-buffer"
    });
    this.#device.queue.writeBuffer(sceneBuffer, 0, sceneData);
    const sceneBindGroup = this.#device.createBindGroup({
        label: "main-scene-bind-group",
        layout: bindGroupLayouts.get("scene"),
        entries: [
            {
                binding: 0,
                resource: {
                    buffer: sceneBuffer,
                    offset: 0,
                    size: sceneData.byteLength
                }
            }
        ]
    });
    passEncoder.setBindGroup(0, sceneBindGroup);
}
Enter fullscreen mode Exit fullscreen mode

The main change here is adding the world matrix to the struct we pass in and multiplying the world matrix with the model matrix before doing all the transforms to get the correct normal matrix.

On the shader side it's just one more multiplication:

@vertex
fn vertex_main(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>, @location(2) normal: vec3<f32>) -> VertexOut
{
    var output : VertexOut;
    output.frag_position =  scene.projection_matrix * scene.view_matrix * scene.world_matrix * scene.model_matrix * vec4<f32>(position, 1.0);
    output.world_position = scene.world_matrix * scene.model_matrix * vec4<f32>(position, 1.0);
    output.uv = uv;
    output.normal = scene.normal_matrix * normal;

    return output;
}
Enter fullscreen mode Exit fullscreen mode

Now, we could just bake this transform data right into the model matrix itself before passing it in. I don't have a great answer for why I'm not doing that other than to spell it out more but I expect at some point this will return to a single matrix at least as far as the shader is concerned.

As a test I nested two groups doing different rotations:

<wc-geo>
    <geo-camera key="main" position="0, 0, -2"></geo-camera>
    <geo-texture key="marble" src="./img/marble-white/marble-white-base.jpg"></geo-texture>
    <geo-texture key="marble-roughness" src="./img/marble-white/marble-white-roughness.jpg"></geo-texture>
    <geo-texture key="red-fabric" src="./img/red-fabric/red-fabric-base.jpg"></geo-texture>
    <geo-texture key="red-fabric-roughness" src="./img/red-fabric/red-fabric-roughness.jpg"></geo-texture>
    <geo-texture key="gold" color="0, 0, 0, 1"></geo-texture>
    <geo-material key="marble" roughness-map="marble-roughness" albedo-map="marble"></geo-material>
    <geo-material key="red-fabric" roughness-map="red-fabric-roughness" albedo-map="red-fabric"></geo-material>
    <geo-material key="gold" roughness="0.2" metalness="1" base-reflectance="1.059, 0.773, 0.307" albedo-map="gold"></geo-material>
    <geo-group key="teapot-rug-0" pipeline="main" rotate="0, 1.5707963267948966, 0">
    <geo-group key="teapot-rug-1" rotate="1.5707963267948966, 0, 0">
        <geo-mesh 
            key="teapot" 
            normalize
            bake-transforms 
            reverse-winding 
            src="./objs/teapot.obj" 
            resize-uvs="2" 
            material="gold" 
            attributes="positions, normals, uvs">
        </geo-mesh>
        <geo-surface-grid 
            key="rug" 
            row-count="2" 
            col-count="2" 
            translate="0, -0.25, 0" 
            bake-transforms 
            material="red-fabric" 
            attributes="positions, normals, uvs">
        </geo-surface-grid>
    </geo-group>
    </geo-group>

    <geo-light key="light1" type="directional" color="1, 1, 1, 1" direction="-1, 0, 0" casts-shadow></geo-light>
</wc-geo>
Enter fullscreen mode Exit fullscreen mode

Which yields this image:

The teapot scene which has been rotated to the teapot if flipped vertically on the right side

Debugging

One way I tried to the test the result was to make a cube shape and then shine light on it from one side. This would test that the normals are correct after rotation. But I had a problem. When shining light from one side I would get an image like this:

A cube on a blue background, 2 sides are lit despite the light shining on exactly one side

With 2 sides lit. This was puzzling and took a while to figure out since if I just drew the colors of the normal vectors it looked correct. The problem here is that there is precision errors. Even very slight near-zero agitation of the normals to point toward the light will cause the material to look very lit. To fix this issue I had to make a function to round low values toward 0.

fn round_small_mag_3(v: vec3<f32>) -> vec3<f32> {
  return select(v, vec3<f32>(0.0), abs(v) < vec3<f32>(1e-6));
}
Enter fullscreen mode Exit fullscreen mode

This might not be necessary for "real" scenes since it's unlikely the planes are exactly orthogonal to light in that way and that the viewer would notice in a complex scene. However for something like a technical rendering like this it definitely looks off. So we can just apply this function to the normal value to clean up those almost-zero values. vec3<f32>(1e-6) is the threshold per element, you can play around with the value to get it to work depending on how bad the precision issue is.

Conclusion

It's nice working on a project nobody else uses because you can always go in an fix all your stupid naming conventions and cleanup bad abstractions. While functionally this didn't add very much nor was super complicated it really helps to have a clean codebase.

Of course the code here is a little iffy because it was added a bit ad-hoc to see what conventions stick and will need some cleanup.

Code

https://github.com/ndesmic/geo/pull/4/files

Top comments (0)