DEV Community

Cover image for WebGPU Engine from Scratch Part 7: Specular Lighting
ndesmic
ndesmic

Posted on

WebGPU Engine from Scratch Part 7: Specular Lighting

Since we added diffuse lighting we might as well add specular too. The teapot is a marble texture that should be shiny. Let's start with a fixed value and build up to specular maps which were included with the texture I downloaded.

First we'll start with an entity. We'll add a few properties to Material:

  • useSpecularMap - a boolean for whether or not we're using a specular map
  • specularMap - a specular map (we're not saying which kind but for now it'll be hardcoded as a roughness map since that's what my textures came with. eg. gloss = 1 - value). This could be expanded in the future. They are also full color so a sampled value is the same as a glossColor.
  • specularSampler - the sampler for the specular map
  • glossColor - the constant gloss color if not using a specular map. This encodes gloss value per color channel, not the color of the highlight directly.
//material.js
export class Material {
    #useSpecularMap;
    #specularMap;
    #specularSampler;
    #glossColor;

    #texture;
    #textureSampler;
    #name;

    constructor(options){
        this.name = options.name;
        this.useSpecularMap = options.useSpecularMap ?? false;
        this.specularMap = options.specularMap ?? "dummy";
        this.specularSampler = options.specularSampler ?? "default";
        this.glossColor = options.glossColor ?? new Float32Array(1,1,1,1);
        this.texture = options.texture ?? "dummy";
        this.textureSampler = options.textureSampler ?? "default";
    }

    set name(val){
        this.#name = val;
    }
    get name(){
        return this.#name;
    }
    set useSpecularMap(val){
        this.#useSpecularMap = val;
    }
    get useSpecularMap(){
        return this.#useSpecularMap;
    }
    set specularMap(val){
        this.#specularMap = val;
    }
    get specularMap(){
        return this.#specularMap;
    }
    set specularSampler(val){
        this.#specularSampler = val;
    }
    get specularSampler(){
        return this.#specularSampler;
    }
    set glossColor(val){
        this.#glossColor = new Float32Array(val);
    }
    get glossColor(){
        return this.#glossColor;
    }
    set texture(val){
        this.#texture = val;
    }
    get texture() {
        return this.#texture;
    }
    set textureSampler(val){
        this.#textureSampler = val;
    }
    get textureSampler(){
        return this.#textureSampler;
    }
}
Enter fullscreen mode Exit fullscreen mode

The way this will work is that useSpecularMap will be set depending on if we are using a constant or a specular map. specularMap is the map if we're using it and specularSampler samples from it. If we aren't using the map then we use glossColor. Note that like texture the specular map is a string reference. I also added textureSampler for completeness (also a string).

Also note the the default values. Specular map "dummy" will be a new texture that functions as a placeholder when we're not using one so the binding still works. Sampler "default" is what we'll rename sampler "main" to because it's a good default for most things (bilinear UVs).

We can initialize a Material like this:

//gpu-engine.js
initializeMaterials(){
    this.#materials.set("marble", new Material({
        texture: "marble",
        useSpecularMap: false,
        glossColor: new Float32Array([4.0,4.0,4.0,1])
    }));
    this.#materials.set("red-fabric", new Material({
        texture: "red-fabric"
    }));
}
Enter fullscreen mode Exit fullscreen mode

(May there's an argument for auto setting useSpecularMap if using a value, but I like it explicit right now). For the dummy texture we'll use this:

//wgpu-utils.js
/**
 * Creates a 1x1 texture of a color
 * @param {GPUDevice} device 
 * @returns 
 */
export function createColorTexture(device, options = {}) {
    const texture = device.createTexture({
        label: options.label,
        size: [1, 1],
        format: 'rgba8unorm',
        usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
    });

    const texel = options.color ?? new Uint8Array([255, 255, 255, 255]);

    device.queue.writeTexture(
        { texture: texture },
        texel,
        { bytesPerRow: 4 },
        { width: 1, height: 1, depthOrArrayLayers: 1 }
    );

    return texture;
}
Enter fullscreen mode Exit fullscreen mode

And set it:

//gpu-engine.js - initializeTextures
this.#textures.set("dummy", createColorTexture(this.#device, { label: "dummy-texture" }));
Enter fullscreen mode Exit fullscreen mode

This will be called after initializeTextures. Next let's create the bindings. Since things are changing I renamed setMainTextureBindGroup to setMainMaterialBindGroup.

setMainMaterialBindGroup(passEncoder, bindGroupLayouts, mesh){
    const material = this.#materials.get(mesh.material);
    const specular = {
        useSpecularMap: material.useSpecularMap ? 1 : 0, //0 => constant, 1 => map
        specularValue: material.specularValue,
        specularColor: material.specularColor
    }
    const specularData = packStruct(specular, [
        ["useSpecularMap", "u32"],
        ["glossColor", "vec4f32"]
    ]);
    const specularBuffer = this.#device.createBuffer({
        size: specularData.byteLength,
        usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
        label: "main-specular-buffer"
    });
    this.#device.queue.writeBuffer(specularBuffer, 0, specularData);
    const materialBindGroup = this.#device.createBindGroup({
        layout: bindGroupLayouts.get("materials"),
        entries: [
            { binding: 0, resource: this.#samplers.get(material.textureSampler) },
            { binding: 1, resource: this.#textures.get(material.texture).createView() },
            { 
                binding: 2, 
                resource: {
                    buffer: specularBuffer,
                    offset: 0,
                    size: specularData.byteLength
                }
            },
            { binding: 3, resource: this.#samplers.get(material.specularSampler) },
            { binding: 4, resource: this.#textures.get(material.specularMap).createView()}
        ]
    });
    passEncoder.setBindGroup(1, materialBindGroup);
}
Enter fullscreen mode Exit fullscreen mode

We've upgraded it to take in the extra parameters from the mesh's material. As an aside I got caught up for a really long time based on a terrible error message. If you forget .createView() on the texture you will get an error about buffer not existing on the resource. I kept thinking it was binding 2 (it doesn't tell you which) but I guess it happens for texture bindings under the covers. Really confusing. https://issues.chromium.org/issues/40287743. You also, as always, need to use the bindings otherwise they get erased by the compiler.

Specular Maps

When we use specular maps there are now some conventions we need to consider. The first is that the maps we have are "roughness" maps and these are the inverse of a gloss map, 0 is very shiny, 1 is fully diffuse. The other is that the images themselves are black and white. This actually works out since our pipeline does specular per color channel but it's possible a specular map could only have a single value and be embedded in one channel like red. We'll cross these bridges if we get to them.

To use specular maps we need to provide them to shader:

initializeMaterials(){
    this.#materials.set("marble", new Material({
        texture: "marble",
        useSpecularMap: true,
        specularMap: "marble-roughness"
    }));
    this.#materials.set("red-fabric", new Material({
        texture: "red-fabric",
        useSpecularMap: true,
        specularMap: "red-fabric-roughness"
    }));
}
Enter fullscreen mode Exit fullscreen mode

And finally the shader:


struct VertexOut {
    @builtin(position) frag_position : vec4<f32>,
    @location(0) world_position: vec4<f32>,
    @location(1) uv : vec2<f32>,
    @location(2) normal : vec3<f32>
};
struct Scene {
    view_matrix: mat4x4<f32>,
    projection_matrix: mat4x4<f32>,
    model_matrix: mat4x4<f32>,
    normal_matrix: mat3x3<f32>,
    camera_position: vec3<f32>
}
struct Light {
    light_type: u32,
    position: vec3<f32>,
    direction: vec3<f32>,
    color: vec4<f32>
}
struct LightCount {
    count: u32
}
struct SpecularMaterial {
    use_specular_map: u32,
    gloss_color: vec4<f32>
}

@group(0) @binding(0) var<uniform> scene : Scene;

@group(1) @binding(0) var main_sampler: sampler;
@group(1) @binding(1) var texture: texture_2d<f32>;
@group(1) @binding(2) var<uniform> specular: SpecularMaterial;
@group(1) @binding(3) var specular_sampler: sampler;
@group(1) @binding(4) var specular_map: texture_2d<f32>;

@group(2) @binding(0) var<storage, read> lights: array<Light>;
@group(2) @binding(1) var<uniform> light_count: LightCount;

@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.model_matrix * vec4<f32>(position, 1.0);
    output.world_position = scene.model_matrix * vec4<f32>(position, 1.0);
    output.uv = uv;
    output.normal = scene.normal_matrix * normal;
    return output;
}
@fragment
fn fragment_main(frag_data: VertexOut) -> @location(0) vec4<f32>
{   
    //assume it's a roughness map
    var gloss_from_map = vec4(1.0) - textureSample(specular_map, specular_sampler, frag_data.uv);
    var gloss = mix(specular.gloss_color.rgb, gloss_from_map.rgb, f32(specular.use_specular_map));

    var surface_color = textureSample(texture, main_sampler, frag_data.uv);
    var total_diffuse = vec4(0.0);
    var total_specular = vec4(0.0);

    var i = 0;
    for(var i: u32 = 0; i < light_count.count; i++){
        //diffuse
        var light = lights[i];
        var to_light = normalize(light.position - frag_data.world_position.xyz);
        var diffuse_intensity = max(dot(normalize(frag_data.normal), to_light), 0.0);
        total_diffuse += light.color * vec4(diffuse_intensity, diffuse_intensity, diffuse_intensity, 1);

        //specular
        var to_camera = normalize(scene.camera_position - frag_data.world_position.xyz);
        var half_vector = normalize(to_light + to_camera);
        var base_specular = vec3(clamp(dot(half_vector, frag_data.normal), 0.0, 1.0));
        var specular_intensity = pow(base_specular, gloss.rgb);
        specular_intensity *= vec3(f32(gloss.r > 0.0), f32(gloss.g > 0.0), f32(gloss.b > 0.0)); //disable if specular_value is 0
        total_specular += light.color * vec4(specular_intensity.r, specular_intensity.g, specular_intensity.b, 1); 
    }

    //return gloss_from_map;
    return surface_color * (total_diffuse + total_specular);
}
Enter fullscreen mode Exit fullscreen mode

First we convert roughness to gloss. Then we use mix to interpolate between the constant value and the map value (this saves us from having to use an if which causes branching in the shader which is bad). This works because it can only ever be 0 or 1, fully one or the other. The rest is pretty much the same as the WebGL version. There is a term specular_intensity *= vec3(f32(gloss.r > 0.0), f32(gloss.g > 0.0), f32(gloss.b > 0.0)); which fully turns off specular if the value is 0. Then we add it all up.

specular lit teapot with overexposed looking image

The result works but is a little underwhelming because the the scale is all wrong. Turns out roughness maps don't exactly translate to our gloss values (no surprise). We could add scaling factors to fudge it, but perhaps we'll revisit it with a real PBR (Physically Based Rendering) implementation later. For now I'm okay with passing in constants.

Since it's easy to flip back and forth here are my values:

initializeMaterials(){
    this.#materials.set("marble", new Material({
        texture: "marble",
        useSpecularMap: false,
        glossColor: [4,4,4,1],
        specularMap: "marble-roughness"
    }));
    this.#materials.set("red-fabric", new Material({
        texture: "red-fabric",
        useSpecularMap: false,
        glossColor: [0,0,0,1],
        specularMap: "red-fabric-roughness"
    }));
}
Enter fullscreen mode Exit fullscreen mode

Teapot with specular

Good enough for now.

Code

https://github.com/ndesmic/geo/releases/tag/v0.5

Top comments (0)