Files
wsg/lib/src/shaders/standard_shader.wgsl
T
Jérôme Bousquié 54a482e354 PBR
2026-09-25 14:40:47 +02:00

460 lines
23 KiB
WebGPU Shading Language
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! # Standard Shader Module (Phong + diffuse texture)
//!
//! Default lit shading pipeline for WSG. Implements an ambient + directional-diffuse
//! (Phong-style) lighting model with an explicit "unlit" mode so that flat 2D rendering
//! is a special case of the 3D path (see DRAFT décision actée : « 2D ⊂ 3D »).
//! Since Étape 10 (DRAFT D2) the fragment can also sample a diffuse texture whose texel
//! modulates the vertex color (`texel.rgb * in.color.rgb`).
//!
//! ## Uniform Contract
//! Four bind groups, shared by every material (one single pipeline layout — voir Étape 3) :
//! - `@group(0) @binding(0)` : `FrameUniforms` (per-frame, camera + lights + shadow + fog) [816 bytes]
//! - `@group(1) @binding(0)` : `ObjectUniform` (per-entity model matrix) [64 bytes]
//! - `@group(2) @binding(0)` : `texture_sampler` (sampler) — diffuse (Étape 10)
//! - `@group(2) @binding(1)` : `diffuse_texture` (texture_2d<f32>) (Étape 10)
//! - `@group(3) @binding(0)` : `shadow_sampler` (sampler_comparison) (Étape 14)
//! - `@group(3) @binding(1)` : `shadow_map` (texture_depth_2d) (Étape 14)
//!
//! `FrameUniforms` layout (std140 — each element 16-byte aligned) :
//! | Offset | Field | Type | Meaning |
//! |-----------------------|-------------------|---------------|----------------------------------|
//! | 0 | view | mat4x4<f32> | Camera view matrix |
//! | 64 | proj | mat4x4<f32> | Camera projection matrix |
//! | 128 | cam_pos | vec4<f32> | Camera world position (.xyz) |
//! | 144 | ambient | vec4<f32> | Ambient hemisphere color (.rgb) |
//! | 160 | lights[0..MAX] | array<Light> | Global light list |
//! | 160 + 64·MAX_LIGHTS | num_directional | u32 | # directional (indices 0..n) |
//! | | num_point | u32 | # point (indices n..) |
//! | | num_spot | u32 | # spot (indices after point) |
//! | | shadow_light_index| u32 | packed index of shadow light |
//! | 160 + 64·MAX_LIGHTS+16| light_view_proj | mat4x4<f32> | world → light clip space (D3) |
//! | | shadow_params | vec4<f32> | .x = map size, .y = depth bias |
//! | | options | vec4<u32> | .x = unlit ; .y = shadows on |
//!
//! `MAX_LIGHTS = 8`. `struct Light` is 64 bytes (4 × vec4). Directional lights occupy
//! `lights[0..num_directional]` (`position_dir.xyz` = direction **from the surface toward the
//! light**); point lights occupy `lights[num_directional..num_directional + num_point]`
//! (`position_dir.xyz` = world position, `radius.x` = linear attenuation radius); spot lights
//! occupy `lights[num_directional + num_point..]` (`position_dir.xyz` = world position,
//! `dir_angle.xyz` = cone axis from the light toward the scene, `dir_angle.w` = cos of the
//! half-angle). No type flag — the index disambiguates (Étapes 12–13).
//!
//! ## Texturing (Étape 10, D2)
//! The fragment samples `diffuse_texture` **unconditionally**. A texture-less `Material` binds the
//! white 1×1 placeholder (texel = `[1,1,1]`), which is the multiplicative identity: `texel * color`
//! leaves the vertex color unchanged, exactly reproducing the pre-Étape-10 look in both lit and
//! unlit modes. A real texture tints/multiplies the vertex color.
//!
//! ## Vertex Input Layout (matches the full `resources::Vertex` struct, 56-byte stride)
//! | Location | Attribute | Type | Offset (bytes) |
//! |----------|-----------|----------|----------------|
//! | 0 | position | vec3<f32>| 0 |
//! | 1 | normal | vec3<f32>| 12 |
//! | 2 | uv | vec2<f32>| 24 |
//! | 3 | color | vec4<f32>| 32 |
//!
//! ## Entry Points
//! - `@vertex vs_main` : world = model * position ; clip = proj * view * world.
//! - `@fragment fs_main` : base = texel * vertex color; × (ambient + diffuse) when lit, or base when unlit.
struct VertexInput {
@location(0) position: vec3<f32>,
@location(1) normal: vec3<f32>,
@location(2) uv: vec2<f32>,
@location(3) color: vec4<f32>,
};
// Étape 12 (Phase 4.2) : maximum number of lights in the per-frame array. Must match
// `wsg_lib::resources::MAX_LIGHTS`.
const MAX_LIGHTS: u32 = 8u;
// A single light (64 bytes = 4 × vec4). Directional: `position_dir.xyz` = direction from the
// surface toward the light. Point: `position_dir.xyz` = world position, `radius.x` = linear
// attenuation radius. Spot: `position_dir.xyz` = world position, `dir_angle.xyz` = cone axis
// (from the light toward the scene), `dir_angle.w` = cos of the half-angle. The array index
// disambiguates the type (no flag stored).
struct Light {
position_dir: vec4<f32>,
color: vec4<f32>, // rgb = color; a = intensity
radius: vec4<f32>, // x = point/spot attenuation radius
dir_angle: vec4<f32>, // spot: xyz = cone axis, w = cos(half-angle)
};
struct FrameUniforms {
view: mat4x4<f32>,
proj: mat4x4<f32>,
cam_pos: vec4<f32>,
ambient: vec4<f32>, // .rgb = ambient hemisphere color
lights: array<Light, MAX_LIGHTS>, // directional, then point, then spot
num_directional: u32,
num_point: u32,
num_spot: u32,
shadow_light_index: u32, // packed index of the shadow light ; MAX_LIGHTS = off
light_view_proj: mat4x4<f32>, // world → shadow light clip space (Étape 14, D3)
shadow_params: vec4<f32>, // .x = map size, .y = constant bias, .z = slope bias
options: vec4<u32>, // .x = unlit flag ; .y = shadows on
fog_a: vec4<f32>, // .x=enabled .y=mode .z=near .w=far (Étape 25)
fog_b: vec4<f32>, // .x=density .y/.z/.w=fog color RGB (Étape 25)
};
struct ObjectUniform {
model: mat4x4<f32>, // 64 bytes (offset 0)
emissive: vec4<f32>, // 16 bytes (offset 64): rgb = color, a = intensity (can be > 1.0 in HDR)
pbr: vec4<f32>, // 16 bytes (offset 80): .x=metallic .y=roughness (Étape 27)
};
@group(0) @binding(0) var<uniform> frame: FrameUniforms;
@group(1) @binding(0) var<uniform> object: ObjectUniform;
// Étape 10 (DRAFT D1) : groupe texture — sampler (0) + texture diffuse (1). Un matériau sans
// texture lie le placeholder blanc 1×1 (D2), d'où l'échantillonnage inconditionnel.
@group(2) @binding(0) var texture_sampler: sampler;
@group(2) @binding(1) var diffuse_texture: texture_2d<f32>;
// Étape 27 : normal map (binding 2) + son sampler (binding 3). Placeholder (128,128,255) si absent.
@group(2) @binding(2) var normal_texture: texture_2d<f32>;
@group(2) @binding(3) var normal_sampler: sampler;
// Étape 14 (DRAFT D1/D5) : groupe ombre — comparaison sampler (0) + carte de profondeur (1).
// Toujours lié (layout unifié) ; inutilisé tant que `options.y == 0` (ombres désactivées).
@group(3) @binding(0) var shadow_sampler: sampler_comparison;
@group(3) @binding(1) var shadow_map: texture_depth_2d;
struct VertexOutput {
@builtin(position) clip_position: vec4<f32>,
@location(0) world_pos: vec3<f32>,
@location(1) normal: vec3<f32>,
@location(2) uv: vec2<f32>,
@location(3) color: vec4<f32>,
@location(4) tangent: vec3<f32>, // Étape 27 : tangente pour normal mapping
};
@vertex
fn vs_main(input: VertexInput) -> VertexOutput {
var out: VertexOutput;
let world = object.model * vec4<f32>(input.position, 1.0);
out.clip_position = frame.proj * frame.view * world;
out.world_pos = world.xyz;
// Model matrix is assumed to contain no non-uniform scale, so the normal is
// transformed by the upper-left 3x3 without needing an inverse-transpose.
// WGSL n'autorise pas un cast mat4x4 -> mat3x3 ; on construit la sous-matrice
// à partir des trois premières colonnes.
let normal_matrix = mat3x3<f32>(
object.model[0].xyz,
object.model[1].xyz,
object.model[2].xyz,
);
out.normal = normal_matrix * input.normal;
out.uv = input.uv;
out.color = input.color;
// Étape 27 : tangente approximée par cross(normal, référence) — évite un attribut tangent.
// La référence est choisie pour éviter la dégénérescence (normal parallèle à l'axe Y).
let ref_dir = select(
vec3<f32>(0.0, 1.0, 0.0),
vec3<f32>(1.0, 0.0, 0.0),
abs(input.normal.y) > 0.99,
);
let tangent_local = normalize(cross(ref_dir, input.normal));
out.tangent = normal_matrix * tangent_local;
return out;
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
// Étape 10 (D2) : échantillonnage inconditionnel. Le texel module la couleur du vertex
// (base = texel * color). Avec le placeholder blanc (texel = 1), base == vertex color :
// aucune régression pour les matériaux sans texture, en lit comme en unlit.
let texel = textureSample(diffuse_texture, texture_sampler, in.uv);
let base = texel.rgb * in.color.rgb;
// Flat (unlit) mode : pas d'éclairage, texel * couleur du vertex + emissive.
if (frame.options.x != 0u) {
let emissive_contrib = base * object.emissive.rgb * object.emissive.a;
let final_rgb = base + emissive_contrib;
return vec4<f32>(apply_fog(final_rgb, in.world_pos), in.color.a);
}
let n = normalize(in.normal);
// Ambient hémisphérique : dépend de la composante verticale de la normale (couleur venue
// de frame.ambient, Étape 12 — était codée en dur via la couleur de la lumière avant).
let sky = max(n.y, 0.0);
let ambient = frame.ambient.rgb * (0.3 + 0.4 * sky);
var diffuse = vec3<f32>(0.0);
// Lumières directionnelles (indices 0..num_directional). `position_dir` pointe de la surface
// vers la lumière, donc on l'utilise tel quel pour le terme N·L (dot(n, direction vers la
// lumière) > 0 = face éclairée).
for (var i = 0u; i < frame.num_directional; i++) {
let l = normalize(frame.lights[i].position_dir.xyz);
let ndotl = max(dot(n, l), 0.0);
diffuse += frame.lights[i].color.rgb * frame.lights[i].color.a * ndotl;
}
// Lumières ponctuelles (indices num_directional..num_directional + num_point). Atténuation
// linéaire dans le rayon (zéro au-delà).
for (var i = frame.num_directional; i < frame.num_directional + frame.num_point; i++) {
let to_light = frame.lights[i].position_dir.xyz - in.world_pos;
let dist = length(to_light);
let l = to_light / max(dist, 1e-4);
let ndotl = max(dot(n, l), 0.0);
let falloff = clamp(1.0 - dist / max(frame.lights[i].radius.x, 1e-4), 0.0, 1.0);
diffuse += frame.lights[i].color.rgb * frame.lights[i].color.a * ndotl * falloff;
}
// Lumières spot (indices num_directional + num_point..num_directional + num_point +
// num_spot). Cône orienté : on teste l'alignement de la direction **de la lumière vers le
// point** de la surface (-l, car l pointe de la surface vers la lumière) avec l'axe du cône
// (dir_angle.xyz, de la lumière vers la scène). Pénombre lissée entre le demi-angle intérieur
// (dir_angle.w) et un liseré extérieur (demi-angle − 0.1 rad), plus atténuation linéaire.
let spot_base = frame.num_directional + frame.num_point;
for (var i = spot_base; i < spot_base + frame.num_spot; i++) {
let to_light = frame.lights[i].position_dir.xyz - in.world_pos;
let dist = length(to_light);
let l = to_light / max(dist, 1e-4); // surface -> lumière
let ndotl = max(dot(n, l), 0.0);
// direction lumière -> point de la surface = -l ; alignée avec l'axe du cône (dir_angle.xyz).
let to_point = -l;
let cone = dot(to_point, normalize(frame.lights[i].dir_angle.xyz));
let cos_inner = frame.lights[i].dir_angle.w;
let cos_outer = cos_inner - 0.1;
let spot_factor = clamp((cone - cos_outer) / max(cos_inner - cos_outer, 1e-4), 0.0, 1.0);
let falloff = clamp(1.0 - dist / max(frame.lights[i].radius.x, 1e-4), 0.0, 1.0);
diffuse += frame.lights[i].color.rgb * frame.lights[i].color.a * ndotl * falloff * spot_factor;
}
let lit = base * (ambient + diffuse) * compute_shadow(in.world_pos, n);
// Étape 22 (6.2): emissive — added to the lit result (independent of lights/shadows).
// Zero emissive (default) → no change (non-regression). In HDR, intensity > 1.0 glows.
let emissive_contrib = base * object.emissive.rgb * object.emissive.a;
let final_rgb = lit + emissive_contrib;
return vec4<f32>(apply_fog(final_rgb, in.world_pos), in.color.a);
}
// Étape 25 : distance fog. Blends the final color toward the fog color based on the
// fragment's distance from the camera. Three modes: linear, exponential, exponential².
// When `fog_a.x == 0` (disabled), returns the input color unchanged — zero cost.
fn apply_fog(color: vec3<f32>, world_pos: vec3<f32>) -> vec3<f32> {
if (frame.fog_a.x < 0.5) {
return color;
}
let dist = length(world_pos - frame.cam_pos.xyz);
var fog_factor: f32;
if (frame.fog_a.y < 0.5) {
// Linear: 1.0 at near, 0.0 at far.
fog_factor = saturate((frame.fog_a.w - dist) / max(frame.fog_a.w - frame.fog_a.z, 1e-4));
} else if (frame.fog_a.y < 1.5) {
// Exponential: exp(-density * distance).
fog_factor = exp(-frame.fog_b.x * dist);
} else {
// Exponential²: exp(-density² * distance²) — sharper cutoff.
let d2 = frame.fog_b.x * frame.fog_b.x;
fog_factor = exp(-d2 * dist * dist);
}
let fog_color = frame.fog_b.yzw;
return mix(color, fog_color, 1.0 - fog_factor);
}
// Étape 14 (DRAFT 3.2, D5) : PCF shadow factor for this fragment. Reprojects the world position
// into the shadow light's clip space, converts to depth-map UVs + normalized depth, then averages
// a 3×3 `textureSampleCompare` neighborhood using the comparison sampler (LessEqual). Returns
// 1.0 when fully lit (or shadows disabled), 0.0 when fully in shadow.
//
// Bias strategy : **slope-scaled** — the reference depth is pulled toward the viewer by
// `max(constant_bias, slope_bias * (1.0 - abs(dot(n, light_dir))))`. The slope term grows as the
// surface becomes perpendicular to the light (grazing angle), where acne is worst. This prevents
// the large black patches that a constant bias alone cannot suppress on large flat surfaces.
fn compute_shadow(world_pos: vec3<f32>, normal: vec3<f32>) -> f32 {
// Shadows off (options.y == 0) or no valid caster (sentinel = MAX_LIGHTS) → fully lit.
if (frame.options.y == 0u || frame.shadow_light_index == MAX_LIGHTS) {
return 1.0;
}
let light_clip = frame.light_view_proj * vec4<f32>(world_pos, 1.0);
// Perspective divide then map NDC [-1,1] → UV [0,1]. Orthographic depth is linear in the map.
let shadow_ndc = light_clip.xyz / max(light_clip.w, 1e-6);
var shadow_uv = shadow_ndc.xy * 0.5 + 0.5;
shadow_uv = vec2<f32>(shadow_uv.x, 1.0 - shadow_uv.y); // flip V for texture coordinates
// The light projection is built with the WebGPU `[0,1]` clip-depth convention (glam
// directx/WebGPU module), so NDC z is already in [0,1]: no extra remap is needed.
let current_depth = shadow_ndc.z;
let texel = 1.0 / max(frame.shadow_params.x, 1.0);
// Slope-scaled bias (fixes the large acne patches on surfaces at grazing angles to the light).
// Direction from surface toward the shadow-casting light:
// directional → position_dir.xyz (already the surface→light direction)
// spot → normalize(light_position - world_pos)
let sl_idx = frame.shadow_light_index;
let sl = frame.lights[sl_idx];
let is_dir = (sl_idx < frame.num_directional);
var light_dir: vec3<f32>;
if (is_dir) {
light_dir = normalize(sl.position_dir.xyz);
} else {
light_dir = normalize(sl.position_dir.xyz - world_pos);
}
// The slope factor: 0 when the normal faces the light (no bias needed), 1 when perpendicular.
let slope = 1.0 - abs(dot(normalize(normal), light_dir));
let bias = max(frame.shadow_params.y, frame.shadow_params.z * slope);
// 3×3 PCF : average of the comparison results around the fragment's texel.
var lit_count = 0.0;
for (var ox = -1i; ox <= 1; ox++) {
for (var oy = -1i; oy <= 1; oy++) {
let offset = vec2<f32>(f32(ox), f32(oy)) * texel;
lit_count += textureSampleCompare(
shadow_map, shadow_sampler, shadow_uv + offset, current_depth - bias);
}
}
return lit_count / 9.0;
}
// ============================================================================
// Étape 27 : PBR Cook-Torrance (GGX + Smith + Schlick) + IBL hémisphère + normal mapping
// ============================================================================
const PI: f32 = 3.14159265;
// GGX/Trowbridge-Reitz distribution : contrôle la largeur du lobe spéculaire.
fn distribution_ggx(ndh: f32, roughness: f32) -> f32 {
let a = roughness * roughness;
let a2 = a * a;
let d = ndh * ndh * (a2 - 1.0) + 1.0;
return a2 / (PI * d * d);
}
// Smith visibility (GGX correlated) : occlusion microsurface.
fn geometry_smith(ndh: f32, ndv: f32, ndl: f32, roughness: f32) -> f32 {
let a2 = roughness * roughness;
// Heuristic : approxime D * V / 4 (voir "A Practical Improvement to the Direct
// Analytic Approximation of the Smith Microsurface Model").
let gv = ndl / (ndv * (1.0 - a2) + a2);
let gl = ndv * (ndl * (1.0 - a2) + a2);
return 0.5 * min(gv, gl);
}
// Fresnel-Schlick : interpolation entre F0 et 1 selon l'angle de vue.
fn fresnel_schlick(hv: f32, f0: vec3<f32>) -> vec3<f32> {
return f0 + (vec3<f32>(1.0) - f0) * pow(1.0 - hv, 5.0);
}
// BRDF PBR complet : diffuse (Lambert × (1-metallic) × (1-F)) + spéculaire (D×G×F).
fn brdf_pbr(n: vec3<f32>, v: vec3<f32>, l: vec3<f32>,
base: vec3<f32>, metallic: f32, roughness: f32) -> vec3<f32> {
let h = normalize(v + l);
let f0 = mix(vec3<f32>(0.04), base, metallic);
let ndl = max(dot(n, l), 0.0);
let ndv = max(dot(n, v), 0.0);
let ndh = max(dot(n, h), 0.0);
let hv = max(dot(h, v), 0.0);
let d = distribution_ggx(ndh, roughness);
let g = geometry_smith(ndh, ndv, ndl, roughness);
let f = fresnel_schlick(hv, f0);
// Diffuse : Lambert × (1 - F) × (1 - metallic) — énergie conservée.
let kd = (vec3<f32>(1.0) - f) * (1.0 - metallic);
let diffuse = kd * base / PI;
// Speculaire : D × G × F / (4 × N·V × N·L)
let denom = 4.0 * ndv * ndl + 1e-4;
let specular = d * g * f / denom;
return (diffuse + specular) * ndl;
}
// IBL hémisphérique analytique : sky/ground mix + spéculaire approximé par roughness.
fn compute_ibl(n: vec3<f32>, base: vec3<f32>, metallic: f32, roughness: f32) -> vec3<f32> {
let ambient = frame.ambient.rgb;
let sky = ambient;
let ground = ambient * 0.3;
let ibl_diffuse = mix(ground, sky, n.y * 0.5 + 0.5);
// Diffuse IBL : Lambert × (1 - metallic) × IBL color
let f0 = mix(vec3<f32>(0.04), base, metallic);
let f = fresnel_schlick(0.0, f0);
let kd = (vec3<f32>(1.0) - f) * (1.0 - metallic);
let diffuse = kd * base * ibl_diffuse / PI;
// Speculaire IBL : approximation — plus la roughness est faible, plus le spéculaire est "vif".
let spec_ibl = mix(ibl_diffuse, vec3<f32>(1.0), (1.0 - roughness) * 0.5);
let specular = f * spec_ibl * (0.1 + 0.4 * (1.0 - roughness));
return diffuse + specular;
}
// Normal mapping : construit la normale perturbée à partir du TBN + normal map.
// La tangente vient du vertex shader (cross produit avec une référence anti-dégénérescence).
fn compute_pbr_normal(in: VertexOutput) -> vec3<f32> {
let n = normalize(in.normal);
let t = normalize(in.tangent);
let b = normalize(cross(n, t));
let tbn = mat3x3<f32>(t, b, n);
// Échantillonner la normal map (placeholder 128,128,255 → nmap = (0,0,1) → aucun effet).
let nmap = textureSample(normal_texture, normal_sampler, in.uv).rgb * 2.0 - 1.0;
return normalize(tbn * nmap);
}
// Fragment PBR complet : IBL + lumières (BRDF Cook-Torrance) + emissive + fog.
@fragment
fn fs_pbr(in: VertexOutput) -> @location(0) vec4<f32> {
let texel = textureSample(diffuse_texture, texture_sampler, in.uv);
let base = texel.rgb * in.color.rgb;
// Unlit mode (identique à fs_main).
if (frame.options.x != 0u) {
let emissive_contrib = base * object.emissive.rgb * object.emissive.a;
let final_rgb = base + emissive_contrib;
return vec4<f32>(apply_fog(final_rgb, in.world_pos), in.color.a);
}
let metallic = object.pbr.x;
let roughness = clamp(object.pbr.y, 0.045, 1.0);
// Normal mapping (derivative tangent + normal map texture).
let n = compute_pbr_normal(in);
let v = normalize(frame.cam_pos.xyz - in.world_pos);
// IBL (hémisphère analytique).
var color = compute_ibl(n, base, metallic, roughness);
// Lumières directionnelles.
for (var i = 0u; i < frame.num_directional; i++) {
let l = normalize(frame.lights[i].position_dir.xyz);
let light_color = frame.lights[i].color.rgb * frame.lights[i].color.a;
let shadow = compute_shadow(in.world_pos, n);
color += brdf_pbr(n, v, l, base, metallic, roughness) * light_color * shadow;
}
// Lumières ponctuelles.
for (var i = frame.num_directional; i < frame.num_directional + frame.num_point; i++) {
let to_light = frame.lights[i].position_dir.xyz - in.world_pos;
let dist = length(to_light);
let l = to_light / max(dist, 1e-4);
let falloff = clamp(1.0 - dist / max(frame.lights[i].radius.x, 1e-4), 0.0, 1.0);
let light_color = frame.lights[i].color.rgb * frame.lights[i].color.a * falloff;
let shadow = compute_shadow(in.world_pos, n);
color += brdf_pbr(n, v, l, base, metallic, roughness) * light_color * shadow;
}
// Lumières spot.
let spot_base = frame.num_directional + frame.num_point;
for (var i = spot_base; i < spot_base + frame.num_spot; i++) {
let to_light = frame.lights[i].position_dir.xyz - in.world_pos;
let dist = length(to_light);
let l = to_light / max(dist, 1e-4);
let to_point = -l;
let cone = dot(to_point, normalize(frame.lights[i].dir_angle.xyz));
let cos_inner = frame.lights[i].dir_angle.w;
let cos_outer = cos_inner - 0.1;
let spot_factor = clamp((cone - cos_outer) / max(cos_inner - cos_outer, 1e-4), 0.0, 1.0);
let falloff = clamp(1.0 - dist / max(frame.lights[i].radius.x, 1e-4), 0.0, 1.0);
let light_color = frame.lights[i].color.rgb * frame.lights[i].color.a * falloff * spot_factor;
let shadow = compute_shadow(in.world_pos, n);
color += brdf_pbr(n, v, l, base, metallic, roughness) * light_color * shadow;
}
// Emissive.
let emissive_contrib = base * object.emissive.rgb * object.emissive.a;
let final_rgb = color + emissive_contrib;
return vec4<f32>(apply_fog(final_rgb, in.world_pos), in.color.a);
}