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 aglossColor
. -
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;
}
}
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"
}));
}
(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;
}
And set it:
//gpu-engine.js - initializeTextures
this.#textures.set("dummy", createColorTexture(this.#device, { label: "dummy-texture" }));
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);
}
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"
}));
}
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);
}
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.
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"
}));
}
Good enough for now.
Top comments (0)