// Screen-space decal projection shader (D1 + D2 + D3 + D4 + D6 + D7 + D8).
//
// Group 0: camera_bgl (CameraUniform)
// Group 1: per-viewport scene depth texture (depth-only aspect view)
// Group 2: per-decal uniform + albedo + sampler + normal + roughness + metallic
//
// Vertex: full-screen quad (6 vertices, no vertex buffer)
// Fragment: load scene depth, reconstruct world position, project into decal local
// space, sample texture, optionally perturb shading via a normal map,
// and apply roughness/metallic specular approximation.
struct Camera {
view_proj: mat4x4<f32>,
eye_pos: vec3<f32>,
_pad: f32,
forward: vec3<f32>,
_pad1: f32,
inv_view_proj: mat4x4<f32>,
view: mat4x4<f32>,
};
@group(0) @binding(0) var<uniform> camera: Camera;
// Scene lights, shared with the opaque pass (same group 0 layout). Used so the
// decal is lit by the real scene light direction rather than the camera view
// direction, which made decals brighten and darken as the camera moved.
// #include "scene_lighting.wgsl"
@group(0) @binding(3) var<uniform> lights_uniform: Lights;
// Scene depth written by the opaque pass (depth-only aspect, Depth24PlusStencil8).
@group(1) @binding(0) var scene_depth: texture_depth_2d;
// Scene stencil written by the opaque pass (stencil-only aspect, Depth24PlusStencil8).
// Value 1 = receives decals, 0 = excluded.
@group(1) @binding(1) var scene_stencil: texture_2d<u32>;
struct DecalUniform {
// Inverse of the decal model matrix: transforms world -> decal local space.
inv_transform: mat4x4<f32>,
blend_mode: u32, // 0 = Replace, 1 = Multiply
alpha: f32,
normal_blend_strength: f32, // D2: [0, 1], 0 = no effect
has_normal: u32, // D2: 1 when a normal map is bound
// D3
roughness: f32, // [0, 1]: 0 = mirror-smooth, 1 = fully matte
metallic: f32, // [0, 1]: 0 = dielectric, 1 = metal
has_roughness_tex: u32, // 1 when roughness_tex is bound
has_metallic_tex: u32, // 1 when metallic_tex is bound
// D4
uv_offset: vec2<f32>, // added to final UV before sampling
uv_scale: vec2<f32>, // scales final UV before offset (sprite sheet / scroll)
// D6
emissive: f32, // emissive intensity multiplier
has_emissive_tex: u32, // 1 when emissive_tex is bound
// D7
edge_fade: f32, // [0, 0.5]: fraction of half-extent over which alpha fades
_pad: u32,
// D8
projection: u32, // 0 = Planar, 1 = TriPlanar
tri_blend_sharpness: f32,
_pad2: u32,
_pad3: u32,
};
@group(2) @binding(0) var<uniform> u: DecalUniform;
@group(2) @binding(1) var decal_tex: texture_2d<f32>;
@group(2) @binding(2) var decal_samp: sampler;
@group(2) @binding(3) var decal_normal: texture_2d<f32>; // D2
@group(2) @binding(4) var roughness_tex: texture_2d<f32>; // D3
@group(2) @binding(5) var metallic_tex: texture_2d<f32>; // D3
@group(2) @binding(6) var emissive_tex: texture_2d<f32>; // D6
struct VertexOutput {
@builtin(position) clip_pos: vec4<f32>,
@location(0) ndc_xy: vec2<f32>,
};
@vertex
fn vs_main(@builtin(vertex_index) vi: u32) -> VertexOutput {
var x: f32;
var y: f32;
switch vi {
case 0u: { x = -1.0; y = -1.0; }
case 1u: { x = 1.0; y = -1.0; }
case 2u: { x = -1.0; y = 1.0; }
case 3u: { x = -1.0; y = 1.0; }
case 4u: { x = 1.0; y = -1.0; }
default: { x = 1.0; y = 1.0; }
}
var out: VertexOutput;
out.clip_pos = vec4<f32>(x, y, 0.0, 1.0);
out.ndc_xy = vec2<f32>(x, y);
return out;
}
// Direction from `world` toward the primary scene light (index 0). Directional
// lights use their fixed direction; point / spot lights point at the fragment.
// Falls back to world up when no lights are present so the relief shading stays
// stable and view-independent.
fn primary_light_dir(world: vec3<f32>) -> vec3<f32> {
if lights_uniform.count == 0u {
return vec3<f32>(0.0, 0.0, 1.0);
}
let l = lights_storage[0];
if l.light_type == 0u {
return normalize(l.pos_or_dir);
}
return normalize(l.pos_or_dir - world);
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
// Load scene depth at the current pixel.
let pix = vec2<i32>(i32(in.clip_pos.x), i32(in.clip_pos.y));
let depth = textureLoad(scene_depth, pix, 0);
// D5: stencil 0 means this surface is marked non-receiver -- skip it.
let stencil = textureLoad(scene_stencil, pix, 0).r;
if stencil == 0u { discard; }
// depth == 1.0 means background -- no surface.
if depth >= 1.0 {
discard;
}
// Reconstruct world-space position from NDC.
let ndc = vec4<f32>(in.ndc_xy, depth, 1.0);
let world_h = camera.inv_view_proj * ndc;
let world = world_h.xyz / world_h.w;
// Transform into decal local space. Projection volume is [-0.5, 0.5]^3.
let local_h = u.inv_transform * vec4<f32>(world, 1.0);
let local = local_h.xyz;
// Reject fragments outside the projection volume.
// Cylindrical: radial distance in XY <= 0.5, Z within [-0.5, 0.5].
// Planar / TriPlanar: box test.
if u.projection == 2u || u.projection == 3u {
let r2 = local.x * local.x + local.y * local.y;
if r2 > 0.25 || abs(local.z) > 0.5 { discard; }
} else {
if any(local < vec3<f32>(-0.5)) || any(local > vec3<f32>(0.5)) {
discard;
}
}
// D7: compute edge fade from local-space coordinates.
// Cylindrical: fade by radial distance and Z.
// Planar / TriPlanar: fade by each box face.
var edge_alpha = 1.0;
if u.edge_fade > 0.0 {
if u.projection == 2u || u.projection == 3u {
let r = sqrt(local.x * local.x + local.y * local.y);
let fr = smoothstep(0.0, u.edge_fade, 0.5 - r);
let fz = smoothstep(0.0, u.edge_fade, 0.5 - abs(local.z));
edge_alpha = fr * fz;
} else {
let fx = smoothstep(0.0, u.edge_fade, 0.5 - abs(local.x));
let fy = smoothstep(0.0, u.edge_fade, 0.5 - abs(local.y));
let fz = smoothstep(0.0, u.edge_fade, 0.5 - abs(local.z));
edge_alpha = fx * fy * fz;
}
}
// Estimate the receiver surface normal from world-position screen derivatives.
// Used for D2 normal-map shading and D3 specular -- not for the facing check.
let ddx_w = dpdx(world);
let ddy_w = dpdy(world);
let n_raw = normalize(cross(ddx_w, ddy_w));
let view_dir = normalize(camera.eye_pos - world);
let N_recv = select(-n_raw, n_raw, dot(n_raw, view_dir) > 0.0);
// Decal projection axis (column 2 of model matrix = world-space local Z,
// extracted from rows of inv_transform via inverse-transpose).
let decal_Z = normalize(vec3<f32>(u.inv_transform[0][2], u.inv_transform[1][2], u.inv_transform[2][2]));
// Planar-only: reject fragments where the surface or camera doesn't face
// the decal projection axis. Skipped for tri-planar (all axes are used).
//
// The surface-normal check (N_recv . decal_Z) prevents two problems that
// occur when a projection box overlaps geometry that is perpendicular to
// the projection direction:
// - The D2 normal-map scaling ratio (new_nv / old_nv) becomes near-zero
// on perpendicular surfaces, darkening the decal to black.
// - The UV collapses to a constant coordinate, producing a uniform stripe
// instead of the correct texture.
// A threshold of 0.1 rejects surfaces more than ~84 degrees off-axis while
// allowing slightly curved or low-angle receivers.
if u.projection == 0u {
if dot(N_recv, decal_Z) < 0.1 { discard; }
if dot(view_dir, decal_Z) < 0.05 { discard; }
}
// D9: Cylindrical facing check.
// Transform receiver normal into decal local space and test its XY radial
// component against the surface position to verify the surface faces the
// correct side of the cylinder.
if u.projection == 2u || u.projection == 3u {
let local_normal = normalize((u.inv_transform * vec4<f32>(N_recv, 0.0)).xyz);
let r = sqrt(local.x * local.x + local.y * local.y);
if r > 0.001 {
let radial_dir = vec2<f32>(local.x, local.y) / r;
let radial_dot = dot(local_normal.xy, radial_dir);
if u.projection == 2u {
// Outward: normal should point away from axis.
if radial_dot < 0.1 { discard; }
} else {
// Inward: normal should point toward axis.
if radial_dot > -0.1 { discard; }
}
}
}
// D8/D9: sample texture.
// Planar: standard XY projection.
// TriPlanar: blend three orthogonal projections weighted by the surface normal.
// Cylindrical: angle around Z axis -> UV.x; position along Z -> UV.y.
var uv: vec2<f32>;
var tex_col: vec4<f32>;
if u.projection == 0u {
// D4: apply UV scale + offset. Base UV maps local XY from [-0.5, 0.5] to [0, 1].
let base_uv = local.xy + vec2<f32>(0.5);
uv = u.uv_offset + u.uv_scale * base_uv;
tex_col = textureSample(decal_tex, decal_samp, uv);
} else if u.projection == 2u || u.projection == 3u {
// D9: cylindrical -- angle around local Z axis, length along local Z.
let angle = atan2(local.y, local.x);
let base_uv = vec2<f32>(angle / (2.0 * 3.14159265) + 0.5, local.z + 0.5);
uv = u.uv_offset + u.uv_scale * base_uv;
tex_col = textureSample(decal_tex, decal_samp, uv);
} else {
// Transform the world-space receiver normal into decal local space.
// Applying inv_transform as a direction transform (not a proper normal
// transform) is correct when the decal matrix has no shear; normalization
// handles non-uniform scale.
let local_normal = normalize((u.inv_transform * vec4<f32>(N_recv, 0.0)).xyz);
// Blend weights: each axis's weight is proportional to how much the
// surface faces that axis.
var w = pow(abs(local_normal), vec3<f32>(u.tri_blend_sharpness));
w = w / (w.x + w.y + w.z);
let uv_xy = u.uv_offset + u.uv_scale * (local.xy + vec2<f32>(0.5));
let uv_xz = u.uv_offset + u.uv_scale * (local.xz + vec2<f32>(0.5));
let uv_yz = u.uv_offset + u.uv_scale * (local.yz + vec2<f32>(0.5));
let c_xy = textureSample(decal_tex, decal_samp, uv_xy);
let c_xz = textureSample(decal_tex, decal_samp, uv_xz);
let c_yz = textureSample(decal_tex, decal_samp, uv_yz);
tex_col = c_xy * w.z + c_xz * w.y + c_yz * w.x;
uv = uv_xy; // fallback UV for other texture slots (normal, roughness, emissive)
}
let alpha = tex_col.a * u.alpha * edge_alpha;
if alpha < 0.001 {
discard;
}
var out_rgb = tex_col.rgb;
// Perturbed shading normal: the receiver normal bent by the decal normal
// map when one is bound, else the receiver normal itself.
var N_final = N_recv;
if u.has_normal != 0u {
// Derive the decal's world-space tangent frame from inv_transform.
// Row i of inv_transform is proportional to world-space decal axis i
// (the inverse-transpose property for normal/direction transforms).
let decal_T = normalize(vec3<f32>(u.inv_transform[0][0], u.inv_transform[1][0], u.inv_transform[2][0]));
let decal_B = normalize(vec3<f32>(u.inv_transform[0][1], u.inv_transform[1][1], u.inv_transform[2][1]));
// Sample and decode the tangent-space normal map.
let nmap_raw = textureSample(decal_normal, decal_samp, uv);
let nmap = normalize(nmap_raw.xyz * 2.0 - 1.0);
// Rotate tangent-space normal into world space using the TBN frame
// (N_recv acts as the TBN normal axis).
let N_decal = normalize(decal_T * nmap.x + decal_B * nmap.y + N_recv * nmap.z);
// Blend between receiver normal and decal normal.
N_final = normalize(mix(N_recv, N_decal, u.normal_blend_strength));
}
// D2: Normal map shading perturbation.
// Modulate the decal colour by the change in diffuse response the normal map
// produces under the primary scene light: the ratio of new-to-old N.L. This
// reads as a lit indent/emboss instead of a flat sticker. Driven by the real
// light direction (not the camera), so it does not change as the camera
// moves. Clamped to a modest range so strong normal maps cannot drive the
// decal to black or blow it out.
if u.has_normal != 0u {
let L = primary_light_dir(world);
let old_nl = max(dot(N_recv, L), 0.1);
let new_nl = max(dot(N_final, L), 0.1);
out_rgb = out_rgb * clamp(new_nl / old_nl, 0.5, 1.6);
}
// D3: Roughness and metallic specular highlight.
// Blinn-Phong against the primary scene light: the highlight sits where the
// half-vector aligns with the surface, gated by N.L so it never appears on
// the unlit side. Skipped for matte decals (gloss ~ 0).
let roughness_val = select(u.roughness,
textureSample(roughness_tex, decal_samp, uv).r,
u.has_roughness_tex != 0u);
let metallic_val = select(u.metallic,
textureSample(metallic_tex, decal_samp, uv).r,
u.has_metallic_tex != 0u);
let gloss = 1.0 - roughness_val;
if gloss > 0.01 {
let L = primary_light_dir(world);
let H = normalize(L + view_dir);
let NoH = max(dot(N_final, H), 0.0);
let NoL = max(dot(N_final, L), 0.0);
// Phong exponent: increases quadratically with gloss for tight highlights.
let spec_exp = max(gloss * gloss * 128.0, 1.0);
let spec_intensity = pow(NoH, spec_exp) * gloss * NoL;
// Dielectric: white highlight. Metal: highlight tinted by albedo colour.
let spec_color = mix(vec3<f32>(0.95), out_rgb, metallic_val);
out_rgb = out_rgb + spec_color * spec_intensity;
}
// D6: emissive contribution -- always additive on top of the blend result.
if u.emissive > 0.0 {
let emissive_col = select(out_rgb,
textureSample(emissive_tex, decal_samp, uv).rgb,
u.has_emissive_tex != 0u);
out_rgb = out_rgb + emissive_col * u.emissive;
}
return vec4<f32>(out_rgb, alpha);
}