// Nucleation headless render shader
// Supports: texture atlas, vertex colors, directional lighting, HDRI skybox + IBL.
struct Uniforms {
view_proj: mat4x4<f32>,
inv_view_proj: mat4x4<f32>,
// x: alpha_cutoff, y: hdri_enabled (>0.5 = yes), z: hdri_intensity, w: ambient
params: vec4<f32>,
// xyz: world-space direction toward the light, w: directional intensity
light: vec4<f32>,
};
// Per-draw animation state. Written by the animation layer as a `Pose`; an
// un-animated draw supplies the identity value, which is a no-op in the maths
// below so posed and un-posed rendering agree bit for bit.
struct DrawUniforms {
model: mat4x4<f32>,
// Inverse-transpose of the model's upper 3x3. Required, or rotated and
// non-uniformly scaled geometry shades wrong.
normal_mat: mat3x3<f32>,
// Multiplied into the base colour. Identity is (1, 1, 1, 1).
tint: vec4<f32>,
// Added after lighting. Identity is (0, 0, 0, 0).
emissive: vec4<f32>,
};
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
@group(1) @binding(0) var atlas_texture: texture_2d<f32>;
@group(1) @binding(1) var atlas_sampler: sampler;
@group(2) @binding(0) var hdri_texture: texture_2d<f32>;
@group(2) @binding(1) var hdri_sampler: sampler;
@group(3) @binding(0) var<uniform> draw: DrawUniforms;
// ─── Mesh rendering ─────────────────────────────────────────────────────────
struct VertexInput {
@location(0) position: vec3<f32>,
@location(1) normal: vec3<f32>,
@location(2) uv: vec2<f32>,
@location(3) color: vec4<f32>,
};
struct VertexOutput {
@builtin(position) clip_position: vec4<f32>,
@location(0) world_normal: vec3<f32>,
@location(1) uv: vec2<f32>,
@location(2) color: vec4<f32>,
};
@vertex
fn vs_main(in: VertexInput) -> VertexOutput {
var out: VertexOutput;
let world = draw.model * vec4<f32>(in.position, 1.0);
out.clip_position = uniforms.view_proj * world;
out.world_normal = draw.normal_mat * in.normal;
out.uv = in.uv;
out.color = in.color;
return out;
}
// Sample HDRI equirectangular map from a world-space direction.
fn sample_hdri(dir: vec3<f32>) -> vec3<f32> {
let d = normalize(dir);
let u = atan2(d.z, d.x) * 0.15915494 + 0.5; // 1/(2*pi)
let v = acos(clamp(d.y, -1.0, 1.0)) * 0.31830989; // 1/pi
return textureSampleLevel(hdri_texture, hdri_sampler, vec2<f32>(u, v), 0.0).rgb;
}
// Rough diffuse IBL: sample HDRI in the normal direction (approximation).
fn hdri_diffuse(normal: vec3<f32>) -> vec3<f32> {
// Sample at a higher mip (or just the base — equirect doesn't have mips,
// so this is a rough approximation). For proper IBL we'd precompute an
// irradiance map, but for a PoC this looks decent.
return sample_hdri(normal);
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let tex_color = textureSample(atlas_texture, atlas_sampler, in.uv);
let material_alpha = tex_color.a * in.color.a;
let base_color = tex_color * in.color * draw.tint;
if base_color.a <= 0.0001 {
discard;
}
// Cutout classification depends on the material, not animation opacity.
let alpha_cutoff = uniforms.params.x;
if alpha_cutoff > 0.0 && material_alpha < alpha_cutoff {
discard;
}
let n = normalize(in.world_normal);
let hdri_enabled = uniforms.params.y > 0.5;
let hdri_intensity = uniforms.params.z;
let light_dir = normalize(uniforms.light.xyz);
let directional_intensity = uniforms.light.w;
var lighting: f32;
var ambient_color: vec3<f32>;
if hdri_enabled {
// Image-based lighting from HDRI
let ibl = hdri_diffuse(n) * hdri_intensity;
// Key light for definition
let n_dot_l = max(dot(n, light_dir), 0.0);
// Minimum ambient floor to prevent total darkness
let min_ambient = vec3<f32>(0.15);
ambient_color = base_color.rgb * max(ibl + 0.35 * directional_intensity * n_dot_l, min_ambient);
// Tonemap mesh colors too (matches skybox)
let mapped = ambient_color / (ambient_color + vec3<f32>(1.0));
return vec4<f32>(mapped + draw.emissive.rgb, base_color.a);
} else {
// Fallback: simple directional lighting
let n_dot_l = max(dot(n, light_dir), 0.0);
let ambient = uniforms.params.w;
lighting = ambient + (1.0 - ambient) * directional_intensity * n_dot_l;
return vec4<f32>(base_color.rgb * lighting + draw.emissive.rgb, base_color.a);
}
}
// ─── Grid / axis lines ──────────────────────────────────────────────────────
// A world-space reference grid, drawn with the scene's view-projection so it
// sits under the build and shares its perspective. No lighting or texturing —
// each vertex just carries its own colour.
struct LineVertex {
@location(0) position: vec3<f32>,
@location(1) color: vec4<f32>,
};
struct LineOutput {
@builtin(position) clip_position: vec4<f32>,
@location(0) color: vec4<f32>,
};
@vertex
fn vs_line(in: LineVertex) -> LineOutput {
var out: LineOutput;
out.clip_position = uniforms.view_proj * vec4<f32>(in.position, 1.0);
out.color = in.color;
return out;
}
@fragment
fn fs_line(in: LineOutput) -> @location(0) vec4<f32> {
return in.color;
}
// ─── Skybox rendering ───────────────────────────────────────────────────────
struct SkyVertexOutput {
@builtin(position) clip_position: vec4<f32>,
@location(0) clip_pos: vec2<f32>,
};
// Fullscreen triangle (3 vertices, no vertex buffer needed)
@vertex
fn vs_sky(@builtin(vertex_index) vertex_index: u32) -> SkyVertexOutput {
var out: SkyVertexOutput;
// Generate fullscreen triangle: covers (-1,-1) to (1,1)
let x = f32(i32(vertex_index & 1u)) * 4.0 - 1.0;
let y = f32(i32(vertex_index >> 1u)) * 4.0 - 1.0;
out.clip_position = vec4<f32>(x, y, 0.9999, 1.0); // near max depth
out.clip_pos = vec2<f32>(x, y);
return out;
}
@fragment
fn fs_sky(in: SkyVertexOutput) -> @location(0) vec4<f32> {
// Reconstruct world-space ray direction from clip space
let clip = vec4<f32>(in.clip_pos.x, in.clip_pos.y, 1.0, 1.0);
let world = uniforms.inv_view_proj * clip;
let dir = normalize(world.xyz / world.w);
let color = sample_hdri(dir);
// Tonemap (Reinhard) for HDR → LDR
let mapped = color / (color + vec3<f32>(1.0));
return vec4<f32>(mapped, 1.0);
}