struct Camera {
view_proj: mat4x4<f32>,
light_view_proj: mat4x4<f32>,
// Straight down over the scene, for the height field the occlusion is
// read from.
ao_view_proj: mat4x4<f32>,
// xyz towards the key light, w its strength.
light_dir: vec4<f32>,
key_color: vec4<f32>,
// xyz towards the rim light, w its strength.
rim_dir: vec4<f32>,
rim_color: vec4<f32>,
ambient: vec4<f32>,
// Where the camera is, for the half-angle a highlight needs.
eye: vec4<f32>,
// x: penumbra per unit of map depth, y: a shadow texel in world units,
// z: a shadow texel in uv.
shadow: vec4<f32>,
// x: how far the occlusion hunt reaches in uv, y: world height per unit
// of the map's depth, z: how much it darkens, w: that reach in world
// units.
ao: vec4<f32>,
// x: how many local lights there are this frame.
counts: vec4<f32>,
// x, y: scale and bias taking log(view depth) to a cluster slice.
// z, w: the frame in pixels.
cluster: vec4<f32>,
};
@group(0) @binding(0)
var<uniform> camera: Camera;
@group(0) @binding(1)
var shadow_map: texture_depth_2d;
@group(0) @binding(2)
var shadow_sampler: sampler_comparison;
@group(0) @binding(3)
var ao_map: texture_depth_2d;
struct VertexInput {
@location(0) position: vec3<f32>,
@location(1) normal: vec3<f32>,
};
// One per drawn model: its transform, and the tint to draw it in.
struct InstanceInput {
@location(2) model_0: vec4<f32>,
@location(3) model_1: vec4<f32>,
@location(4) model_2: vec4<f32>,
@location(5) model_3: vec4<f32>,
@location(6) color: vec4<f32>,
// x: which material in the table.
@location(7) material: vec4<u32>,
};
struct VertexOutput {
@builtin(position) clip_position: vec4<f32>,
@location(0) normal: vec3<f32>,
@location(1) color: vec4<f32>,
@location(2) world: vec3<f32>,
// Flat: an index is the same for every fragment of the triangle, and
// interpolating one would give a material that does not exist.
@location(3) @interpolate(flat) material: u32,
};
@vertex
fn vs_main(vertex: VertexInput, instance: InstanceInput) -> VertexOutput {
let model = mat4x4<f32>(
instance.model_0,
instance.model_1,
instance.model_2,
instance.model_3,
);
var out: VertexOutput;
let world = model * vec4<f32>(vertex.position, 1.0);
out.world = world.xyz;
out.clip_position = camera.view_proj * world;
// Placement is a rotation and a uniform scale, both of which keep angles,
// so the model matrix carries normals without an inverse transpose.
out.normal = normalize((model * vec4<f32>(vertex.normal, 0.0)).xyz);
out.color = instance.color;
out.material = instance.material.x;
return out;
}
/// Taps in the blocker hunt, and in the filter over the penumbra it works out.
///
/// The filter gets more because it is what is actually seen: its answer is
/// quantised to roughly one part in this many, and over a wide penumbra that
/// quantisation is the grain. The hunt only has to land on a mean depth.
const BLOCKER_TAPS: u32 = 16u;
const SHADOW_TAPS: u32 = 32u;
/// Widest any shadow filter is allowed to open, as a fraction of the map.
///
/// A cap rather than a physical limit: taps spread over more than this stop
/// reading as a soft edge and start reading as taps.
const PENUMBRA_CEILING: f32 = 0.02;
/// One number per pixel, from an integer hash of where it is. A hash rather
/// than a gradient noise, which lays a visible weave over a still image.
fn dither(at: vec2<f32>) -> f32 {
var h = u32(at.x) * 73856093u ^ u32(at.y) * 19349663u;
h = h ^ (h >> 13u);
h = h * 1274126177u;
h = h ^ (h >> 16u);
return f32(h) * (1.0 / 4294967296.0);
}
/// A point on a spiral: the golden angle between one tap and the next, and a
/// radius growing as the square root of the index, which keeps them evenly
/// dense rather than crowded in the middle.
///
/// `turn` rotates the whole set and `slip` slides it along its own radius,
/// both per pixel — turning alone leaves neighbouring pixels sampling the same
/// radii, and pixels that agree about where to look agree about what they find.
fn spiral(i: u32, count: u32, turn: f32, slip: f32) -> vec2<f32> {
let index = f32(i) + slip;
let radius = sqrt(index / f32(count));
let angle = index * 2.3999632 + turn;
return vec2<f32>(cos(angle), sin(angle)) * radius;
}
/// How far away, on average, whatever stands between this point and the light
/// is — over a disc of `reach` in the map's uv. Negative when nothing does.
///
/// The map is orthographic, so its depth is already proportional to distance
/// and the mean of the depths found is the depth of the mean.
fn blockers_within(
uv: vec2<f32>,
depth: f32,
reach: f32,
size: vec2<f32>,
turn: f32,
slip: f32,
) -> f32 {
var total = 0.0;
var found = 0.0;
for (var i = 0u; i < BLOCKER_TAPS; i = i + 1u) {
let step = spiral(i, BLOCKER_TAPS, turn, slip);
let at = clamp(uv + step * reach, vec2<f32>(0.0), vec2<f32>(1.0));
let there = textureLoad(shadow_map, vec2<i32>(at * size), 0);
if (there < depth) {
total = total + there;
found = found + 1.0;
}
}
if (found == 0.0) {
return -1.0;
}
return total / found;
}
/// How much of the light reaches a point, softened by how big the light is.
///
/// Percentage-closer soft shadows. The map holds a depth, and a depth is all
/// that is needed to know how much to blur: how far a blocker stands in front
/// of the receiver, times how wide the light is. That is the penumbra, by
/// similar triangles.
///
/// The catch is that the depth has to be looked up over the right area. Hunting
/// over a fixed wide disc averages in occluders that have nothing to do with
/// the point being shaded — at the foot of a piece it finds the top of the
/// piece along with the base, splits the difference, and blurs as though the
/// whole thing were floating. So the hunt is sized from the depth as well, and
/// then run again over what the first one worked out, which is what makes a
/// shadow tighten as it approaches whatever cast it.
fn soft_shadow(uv: vec2<f32>, depth: f32, pixel: vec2<f32>) -> f32 {
let size = vec2<f32>(textureDimensions(shadow_map));
let turn = dither(pixel) * 6.2831853;
// A second, unrelated number for the radial slide, so it is not the same
// hash of the same thing.
let slip = dither(pixel + vec2<f32>(37.0, 17.0));
// Never below a texel: a filter narrower than the map's resolution is only
// a slower way of drawing a hard edge.
let texel = camera.shadow.z;
let spread = camera.shadow.x;
// The widest a penumbra could be here: a blocker as close to the light as
// the map can record. The hunt starts there, because a hunt narrower than
// the answer cannot find it.
let widest = clamp(spread * depth, texel, PENUMBRA_CEILING);
let blocker = blockers_within(uv, depth, widest, size, turn, slip);
// Nothing in the way at all — most of a frame, and why this is affordable.
if (blocker < 0.0) {
return 1.0;
}
var reach = clamp(spread * max(depth - blocker, 0.0), texel, widest);
// Again, over what that came to. Where the first hunt was already about
// right this changes nothing; where it was too wide — which is everywhere
// near a contact — this is what narrows it.
let closer = blockers_within(uv, depth, reach, size, turn + 2.0, slip);
if (closer >= 0.0) {
reach = clamp(spread * max(depth - closer, 0.0), texel, reach);
}
// Filter over it, on the same spiral turned to a different phase so the
// passes do not share tap positions. Each tap is a comparison sample, so
// the hardware has tested and blended four texels by the time it comes
// back, and what arrives is a fraction rather than a yes or no.
//
// Tapered rather than flat: a tap at the rim is the one most likely to
// disagree with its neighbours, and weighting it down stops the estimate
// jumping as the rim crosses an edge.
var lit = 0.0;
var weight = 0.0;
for (var i = 0u; i < SHADOW_TAPS; i = i + 1u) {
let step = spiral(i, SHADOW_TAPS, turn + 1.0, slip);
let taper = 1.0 - f32(i) / f32(SHADOW_TAPS);
let at = clamp(uv + step * reach, vec2<f32>(0.0), vec2<f32>(1.0));
lit = lit + textureSampleCompare(shadow_map, shadow_sampler, at, depth) * taper;
weight = weight + taper;
}
return lit / weight;
}
/// How much of the light reaches a point: 1 in the open, 0 in shadow, and a
/// penumbra between that widens with distance from whatever is casting it.
///
/// Anything outside the map is treated as lit, so the world beyond the fitted
/// volume does not get a hard black edge around it.
fn sun_visible(world: vec3<f32>, normal: vec3<f32>, pixel: vec2<f32>) -> f32 {
let light = normalize(camera.light_dir.xyz);
// Lifted along the normal by a texel of the map before testing, which is
// what keeps a surface from shadowing itself — and further the more the
// surface leans away from the light, since a texel covers a fixed width
// but an unbounded depth across a face seen edge on. Held to a few texels
// however grazing it is: lifting further silences the last of the acne,
// but walks the sample out of the umbra, and a shadow that has come away
// from what casts it will not tighten into a contact.
let leaning = clamp(1.0 / max(dot(normal, light), 0.05), 1.0, 3.0);
let lifted = world + normal * camera.shadow.y * 1.5 * leaning;
let clip = camera.light_view_proj * vec4<f32>(lifted, 1.0);
let ndc = clip.xyz / clip.w;
if (any(abs(ndc.xy) > vec2<f32>(1.0)) || ndc.z < 0.0 || ndc.z > 1.0) {
return 1.0;
}
let uv = vec2<f32>(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
return soft_shadow(uv, ndc.z, pixel);
}
/// Taps in the occlusion hunt. Fewer than the shadow filter gets: this is a
/// slow, wide term, and its grain is hidden by everything else going on.
const AO_TAPS: u32 = 12u;
/// How much of the sky reaches a point, from the height field looked down at
/// the scene.
///
/// Everything standing higher nearby is something the sky has to get past. A
/// neighbour level with this point shuts out none of it and one standing a
/// reach above shuts out all of it, weighted by how close it is — which is
/// what darkens the board around the foot of a piece, and the inside of a
/// crease before the outside of it.
fn ambient_occlusion(world: vec3<f32>, pixel: vec2<f32>) -> f32 {
let strength = camera.ao.z;
if (strength <= 0.0) {
return 1.0;
}
let clip = camera.ao_view_proj * vec4<f32>(world, 1.0);
let ndc = clip.xyz / clip.w;
if (any(abs(ndc.xy) > vec2<f32>(1.0)) || ndc.z < 0.0 || ndc.z > 1.0) {
return 1.0;
}
let uv = vec2<f32>(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
let size = vec2<f32>(textureDimensions(ao_map));
// The same spiral for every pixel, unlike the shadow filter. A shadow
// has a hard edge to hide and can afford the grain that dithering trades
// for it; occlusion is a slow, wide term, so neighbouring pixels
// disagreeing about where to look shows up as speckle across a flat face
// and nothing is gained.
let turn = 0.0;
let slip = 0.5;
let span = camera.ao.y;
let world_reach = max(camera.ao.w, 1e-5);
var shut = 0.0;
var weight = 0.0;
for (var i = 0u; i < AO_TAPS; i = i + 1u) {
let step = spiral(i, AO_TAPS, turn, slip);
let at = clamp(uv + step * camera.ao.x, vec2<f32>(0.0), vec2<f32>(1.0));
let there = textureLoad(ao_map, vec2<i32>(at * size), 0);
// A smaller depth is nearer the overhead camera, which is higher up.
let above = (ndc.z - there) * span;
// Square-rooted: a neighbour only slightly higher than this point
// still takes a good deal of the sky, and a linear ramp leaves the
// whole term looking like nothing until the geometry is extreme.
let shading = sqrt(clamp(above / world_reach, 0.0, 1.0));
let nearness = 1.0 - length(step);
shut = shut + shading * nearness;
weight = weight + nearness;
}
if (weight <= 0.0) {
return 1.0;
}
return clamp(1.0 - strength * shut / weight, 0.0, 1.0);
}
// The radiance a light of `radiance` arriving from `towards_light` puts back
// towards the eye: the BSDF, the light, and the cosine that says a surface
// edge-on to a light catches less of it.
fn direct(basis: Basis, wiL: vec3<f32>, towards_light: vec3<f32>, radiance: vec3<f32>) -> vec3<f32> {
if (all(radiance <= vec3<f32>(0.0))) {
return vec3<f32>(0.0);
}
let woL = worldToLocal(towards_light, basis);
// Below the horizon of the shading frame: the light is behind the
// surface, and an opaque one does not pass it through.
if (woL.z <= 0.0) {
return vec3<f32>(0.0);
}
let eval = openpbr_bsdf_evaluate(wiL, woL);
return eval.f * radiance * woL.z;
}
// What a local light delivers here, after its distance and its cone.
fn local_light_radiance(light: LocalLight, world: vec3<f32>) -> vec3<f32> {
let to_light = light.position_range.xyz - world;
let distance = length(to_light);
let range = light.position_range.w;
if (distance >= range || distance <= 0.0) {
return vec3<f32>(0.0);
}
// Inverse square, windowed so the light actually reaches zero at its
// range instead of merely getting small -- a light that never quite ends
// is a light every cluster has to carry.
let falloff = 1.0 / max(distance * distance, 1e-4);
let window = saturate(1.0 - pow(distance / range, 4.0));
var radiance = light.color.rgb * falloff * window * window;
// A spot narrows it further, smoothly between the two cone angles.
let outer = light.direction_outer.w;
if (outer > NOT_A_CONE) {
let towards_surface = -to_light / distance;
let alignment = dot(towards_surface, light.direction_outer.xyz);
let inner = light.cone.x;
radiance = radiance * smoothstep(outer, max(inner, outer + 1e-4), alignment);
}
return radiance;
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
// The material, tinted by the instance: one polished dielectric serves
// both sides of the board, and the tint is what tells them apart.
openpbr_begin(in.material, u32(in.clip_position.x) * 1973u + u32(in.clip_position.y) * 9277u);
// The instance's tint, after the unpack so it lands on the global the
// lobes actually read.
base_color = base_color * in.color.rgb;
let towards_eye = normalize(camera.eye.xyz - in.world);
// Two-sided: a piece's own back faces are wound away from the camera, and
// shading them by their winding lights them from inside.
var normal = normalize(in.normal);
if (dot(normal, towards_eye) < 0.0) {
normal = -normal;
}
let basis = makeBasis(normal);
let wiL = worldToLocal(towards_eye, basis);
// Lobe weights and probabilities, once for the whole shading point rather
// than once per light.
openpbr_prepare(wiL);
let occlusion = ambient_occlusion(in.world, in.clip_position.xy);
// The key light, and the only one that is shadowed.
let key = normalize(camera.light_dir.xyz);
let lit = sun_visible(in.world, normal, in.clip_position.xy);
var radiance = direct(
basis,
wiL,
key,
camera.key_color.rgb * camera.light_dir.w * lit,
);
// The rim casts nothing. It is there to find the edge the key misses, and
// a second shadow map to do that with would cost more than the edge.
let rim = normalize(camera.rim_dir.xyz);
radiance = radiance + direct(
basis,
wiL,
rim,
camera.rim_color.rgb * camera.rim_dir.w * occlusion,
);
// Only the lights whose reach covers the cell this fragment sits in.
// `clip_position.w` is the view-space depth, which is what the slices are
// cut along; the x and y cells come straight off the pixel.
let cell_x = min(
u32(in.clip_position.x / camera.cluster.z * f32(CLUSTER_X)),
CLUSTER_X - 1u,
);
let cell_y = min(
u32(in.clip_position.y / camera.cluster.w * f32(CLUSTER_Y)),
CLUSTER_Y - 1u,
);
let slice = log(max(in.clip_position.w, 1.0e-4)) * camera.cluster.x + camera.cluster.y;
let cell_z = min(u32(max(slice, 0.0)), CLUSTER_Z - 1u);
let cell = cell_x + cell_y * CLUSTER_X + cell_z * CLUSTER_X * CLUSTER_Y;
let count = cluster_counts[cell];
let first = cell * MAX_LIGHTS_PER_CLUSTER;
for (var i = 0u; i < count; i = i + 1u) {
let light = lights[cluster_lights[first + i]];
let radiance_here = local_light_radiance(light, in.world);
if (all(radiance_here <= vec3<f32>(0.0))) {
continue;
}
let towards_light = normalize(light.position_range.xyz - in.world);
radiance = radiance + direct(basis, wiL, towards_light, radiance_here);
}
// Ambient, from a sky above and a ground below. A BSDF answers about one
// direction at a time, so a whole hemisphere of light is not something it
// can be asked; this is the same split the pass has always used, driven
// now by the material rather than by two numbers beside it.
let sky = 0.5 + 0.5 * normal.y;
let ambient = camera.ambient.rgb * mix(0.5, 1.0, sky) * occlusion;
let diffuse_albedo = base_color * base_weight * (1.0 - base_metalness);
radiance = radiance + diffuse_albedo * ambient;
// What the sky would look like in a mirror, weighted by how glancing the
// view is -- the Fresnel effect, which is why a polished floor is a
// mirror across the room and matte underfoot. A rough surface scatters
// that reflection away, so roughness takes it back out.
let bounced = reflect(-towards_eye, normal);
let sky_color = camera.ambient.rgb * mix(0.3, 1.4, 0.5 + 0.5 * bounced.y);
let fresnel = pow(1.0 - clamp(dot(normal, towards_eye), 0.0, 1.0), 5.0);
let sharpness = 1.0 - specular_roughness;
let tint = mix(specular_color, base_color, base_metalness);
let reflection = sky_color
* mix(0.04, 1.0, fresnel)
* specular_weight
* sharpness
* sharpness
* tint
* occlusion;
radiance = radiance + reflection;
// A material that makes its own light.
radiance = radiance + emission_color * emission_luminance;
return vec4<f32>(radiance, in.color.a * geometry_opacity);
}