8a12225ba1
build / mod (push) Successful in 3m21s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
305 lines
14 KiB
WebGPU Shading Language
305 lines
14 KiB
WebGPU Shading Language
// AirDef effects: a post pass over the world (render.set-post, AirDef fork).
|
|
// Blasts are noise volumes marched along the view ray (fireball turning into a smoke
|
|
// column), with a flash, a shock wave that bends the light and heat haze; engine
|
|
// flames and muzzle flashes glow. Everything is placed in the world and hidden by
|
|
// what stands in front of it (the world's depth).
|
|
//
|
|
// frame.params (vec4):
|
|
// [0] now (mod ticks), blasts, lights, -
|
|
// [1 + 2i], [2 + 2i] blast i: x, y, z, size | start tick, life ticks, kind, -
|
|
// [97 + 2j], [98 + 2j] light j: x, y, z, radius | r, g, b, intensity
|
|
// [255].x hold: 0 live; a replay's share of the frame's partial tick it does not advance with
|
|
|
|
const BLAST_BASE: i32 = 1;
|
|
const LIGHT_BASE: i32 = 97;
|
|
|
|
fn hash3(p: vec3<f32>) -> f32 {
|
|
let q = fract(p * 0.3183099 + vec3<f32>(0.11, 0.17, 0.13));
|
|
let r = q * 17.0;
|
|
return fract(r.x * r.y * r.z * (r.x + r.y + r.z));
|
|
}
|
|
|
|
fn noise3(x: vec3<f32>) -> f32 {
|
|
let i = floor(x);
|
|
let f = fract(x);
|
|
let u = f * f * (3.0 - 2.0 * f);
|
|
let a = mix(hash3(i), hash3(i + vec3<f32>(1.0, 0.0, 0.0)), u.x);
|
|
let b = mix(hash3(i + vec3<f32>(0.0, 1.0, 0.0)), hash3(i + vec3<f32>(1.0, 1.0, 0.0)), u.x);
|
|
let c = mix(hash3(i + vec3<f32>(0.0, 0.0, 1.0)), hash3(i + vec3<f32>(1.0, 0.0, 1.0)), u.x);
|
|
let d = mix(hash3(i + vec3<f32>(0.0, 1.0, 1.0)), hash3(i + vec3<f32>(1.0, 1.0, 1.0)), u.x);
|
|
return mix(mix(a, b, u.y), mix(c, d, u.y), u.z);
|
|
}
|
|
|
|
fn fbm(p: vec3<f32>) -> f32 {
|
|
var a = 0.5;
|
|
var s = 0.0;
|
|
var q = p;
|
|
for (var i = 0; i < 3; i++) {
|
|
s += a * noise3(q);
|
|
q = q * 2.03 + vec3<f32>(1.7, 9.2, 3.1);
|
|
a *= 0.5;
|
|
}
|
|
return s;
|
|
}
|
|
|
|
// The ray (from the camera, along d) through a sphere: entry and exit distances.
|
|
fn ray_sphere(d: vec3<f32>, c: vec3<f32>, r: f32) -> vec2<f32> {
|
|
let b = dot(-c, d);
|
|
let k = dot(c, c) - r * r;
|
|
let h = b * b - k;
|
|
if h < 0.0 { return vec2<f32>(-1.0, -1.0); }
|
|
let s = sqrt(h);
|
|
return vec2<f32>(-b - s, -b + s);
|
|
}
|
|
|
|
// The screen size (pixels) of a world radius at a world point.
|
|
fn screen_radius(world: vec3<f32>, radius: f32) -> f32 {
|
|
let right = (frame.view_inverse * vec4<f32>(1.0, 0.0, 0.0, 0.0)).xyz;
|
|
let a = project(world);
|
|
let b = project(world + right * radius);
|
|
return length((b.xy - a.xy) * frame.resolution.xy);
|
|
}
|
|
|
|
// The clip-space position of a world point (what `project` starts from).
|
|
fn clip_of(world: vec3<f32>) -> vec4<f32> {
|
|
return frame.view_proj * vec4<f32>(world - frame.camera.xyz, 1.0);
|
|
}
|
|
|
|
// A clip position on the screen in uv.
|
|
fn clip_uv(c: vec4<f32>) -> vec2<f32> {
|
|
let ndc = c.xy / max(abs(c.w), 1e-5);
|
|
return vec2<f32>(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
|
|
}
|
|
|
|
// `screen_radius` for a point whose clip position is known (the projection is linear,
|
|
// so the offset to the right is added in clip space): saves two projections and a matrix
|
|
// product for every effect at every pixel.
|
|
fn screen_radius_clip(c0: vec4<f32>, radius: f32) -> f32 {
|
|
let right = frame.view_inverse[0].xyz;
|
|
let cb = c0 + frame.view_proj * vec4<f32>(right * radius, 0.0);
|
|
return length((clip_uv(cb) - clip_uv(c0)) * frame.resolution.xy);
|
|
}
|
|
|
|
fn now() -> f32 {
|
|
return frame.params[0].x + frame.time.y * (1.0 - frame.params[255].x);
|
|
}
|
|
|
|
// Fire's colour from its heat (0 cold soot .. 1 white-hot).
|
|
fn fire(heat: f32) -> vec3<f32> {
|
|
let h = clamp(heat, 0.0, 1.0);
|
|
let ember = vec3<f32>(0.35, 0.05, 0.01);
|
|
let orange = vec3<f32>(1.6, 0.55, 0.12);
|
|
let white = vec3<f32>(3.0, 2.4, 1.5);
|
|
return mix(mix(ember, orange, smoothstep(0.0, 0.5, h)), white, smoothstep(0.6, 1.0, h));
|
|
}
|
|
|
|
fn post(uv: vec2<f32>, pixel: vec2<f32>) -> vec4<f32> {
|
|
let t_now = now();
|
|
let blasts = i32(frame.params[0].y);
|
|
let lights = i32(frame.params[0].z);
|
|
let dir = view_dir(uv);
|
|
let px = uv * frame.resolution.xy;
|
|
|
|
// 1. Shock waves and heat haze bend where the world is sampled.
|
|
var offset = vec2<f32>(0.0);
|
|
for (var i = 0; i < blasts; i++) {
|
|
let a = frame.params[BLAST_BASE + 2 * i];
|
|
let b = frame.params[BLAST_BASE + 2 * i + 1];
|
|
let age = t_now - b.x;
|
|
if age < 0.0 { continue; }
|
|
// Past both windows (the ring's second, the haze over a hot ball) it bends nothing.
|
|
let heat = 1.0 - smoothstep(0.0, 0.5, age / b.y);
|
|
if age >= 24.0 && heat <= 0.0 { continue; }
|
|
let centre = a.xyz;
|
|
let c0 = clip_of(centre);
|
|
if c0.w <= 0.5 { continue; }
|
|
let to = px - clip_uv(c0) * frame.resolution.xy;
|
|
let dist = length(to);
|
|
// The shock: a ring racing out, fading in a second.
|
|
if age < 24.0 {
|
|
let ring = screen_radius_clip(c0, age * 3.2 * (0.6 + a.w * 0.1));
|
|
let width = 6.0 + ring * 0.08;
|
|
let x = (dist - ring) / width;
|
|
// exp(-x * x) * x is below 1e-8 past six widths.
|
|
if abs(x) < 6.0 {
|
|
let strength = (1.0 - age / 24.0) * min(a.w / 4.0, 2.0) * 10.0;
|
|
offset += normalize(to + vec2<f32>(1e-4)) * exp(-x * x) * x * strength * frame.resolution.zw;
|
|
}
|
|
}
|
|
// Heat haze over a hot fireball.
|
|
let r = screen_radius_clip(c0, a.w * 2.0);
|
|
if heat > 0.0 && dist < r {
|
|
let n = noise3(vec3<f32>(px * 0.04, t_now * 0.35)) - 0.5;
|
|
offset += vec2<f32>(n, -abs(n)) * heat * (1.0 - dist / r) * 6.0 * frame.resolution.zw;
|
|
}
|
|
}
|
|
let suv = clamp(uv + offset, vec2<f32>(0.0), vec2<f32>(1.0));
|
|
var color = scene(suv);
|
|
let wall = scene_distance(suv);
|
|
|
|
// 1b. Fire lights the land and the vehicles: blasts and flames as point lights on what
|
|
// the pixel sees, much stronger at night.
|
|
let night = clamp(0.35 - frame.time.z * 1.8, 0.0, 1.0);
|
|
let strength = mix(0.35, 2.2, night);
|
|
// Under the HD lighting (params[0].w) the engine lights the world from the same lights.
|
|
if wall < 1.0e5 && frame.params[0].w < 0.5 {
|
|
let p = dir * wall;
|
|
var light = vec3<f32>(0.0);
|
|
for (var i = 0; i < blasts; i++) {
|
|
let a = frame.params[BLAST_BASE + 2 * i];
|
|
let b = frame.params[BLAST_BASE + 2 * i + 1];
|
|
let age = t_now - b.x;
|
|
if age < 0.0 { continue; }
|
|
let heat = exp(-age / max(b.y * 0.12, 1.0));
|
|
let flash = exp(-age * 0.5) * 3.0;
|
|
let d = length(p - (a.xyz - frame.camera.xyz)) / (a.w * 3.0);
|
|
light += vec3<f32>(1.0, 0.55, 0.2) * (heat + flash) * a.w * 0.6 / (1.0 + d * d * 4.0);
|
|
}
|
|
for (var j = 0; j < lights; j++) {
|
|
let a = frame.params[LIGHT_BASE + 2 * j];
|
|
let b = frame.params[LIGHT_BASE + 2 * j + 1];
|
|
let d = length(p - (a.xyz - frame.camera.xyz)) / max(a.w * 6.0, 1.0);
|
|
light += b.rgb * b.w / (1.0 + d * d * 6.0);
|
|
}
|
|
color += color * light * strength + light * 0.02 * night;
|
|
}
|
|
|
|
// 2. Fireballs and their smoke, marched front to back.
|
|
var glow = vec3<f32>(0.0);
|
|
var trans = 1.0;
|
|
var lit = vec3<f32>(0.0);
|
|
let sun = max(frame.time.z, 0.15);
|
|
for (var i = 0; i < blasts; i++) {
|
|
let a = frame.params[BLAST_BASE + 2 * i];
|
|
let b = frame.params[BLAST_BASE + 2 * i + 1];
|
|
let age = t_now - b.x;
|
|
if age < 0.0 { continue; }
|
|
let life = b.y;
|
|
// Burnt out: no density and no fire light.
|
|
if age >= life { continue; }
|
|
let k = clamp(age / life, 0.0, 1.0);
|
|
let size = a.w;
|
|
// The ball and its smoke stay inside 4.5 sizes of the blast (radius 2.28 sizes, risen
|
|
// 2.2 sizes): rays that miss that sphere cannot reach the volume. Only the
|
|
// pixels' own direction is needed, no volume maths.
|
|
let rel = a.xyz - frame.camera.xyz;
|
|
let reach = size * 4.5 + 0.1;
|
|
let cc = dot(rel, rel);
|
|
let along = dot(rel, dir);
|
|
if cc > reach * reach && (along < 0.0 || cc - along * along > reach * reach) {
|
|
// Out of reach of the volume; the fire's glow still counts.
|
|
let h0 = 1.0 - smoothstep(0.05, 0.55, k);
|
|
glow += vec3<f32>(1.0, 0.5, 0.15) * h0 * h0 * size * 0.05 / (1.0 + length(rel + vec3<f32>(0.0, k * k * size * 2.2, 0.0)) * 0.03);
|
|
continue;
|
|
}
|
|
let seed = vec3<f32>(a.x * 0.37, a.y * 0.11, a.z * 0.53);
|
|
// The ball swells fast, then rises and spreads as smoke.
|
|
let radius = size * (0.3 + 0.9 * (1.0 - exp(-age / 3.0))) * (1.0 + k * 0.9);
|
|
let centre = a.xyz - frame.camera.xyz + vec3<f32>(0.0, k * k * size * 2.2, 0.0);
|
|
let span = ray_sphere(dir, centre, radius);
|
|
if span.y <= 0.0 { continue; }
|
|
let t0 = max(span.x, 0.0);
|
|
let t1 = min(span.y, wall);
|
|
if t1 <= t0 { continue; }
|
|
let heat = 1.0 - smoothstep(0.05, 0.55, k);
|
|
// With the HD renderer's volumetric smoke (params[0].w = 2) only the fireball is drawn here.
|
|
var fade = 1.0 - smoothstep(0.65, 1.0, k);
|
|
if frame.params[0].w > 1.5 { fade = 1.0 - smoothstep(0.08, 0.28, k); }
|
|
// Fewer steps for a fireball that is small on screen.
|
|
let on_screen = radius / max(length(centre), 1.0);
|
|
let steps = i32(clamp(on_screen * 40.0, 6.0, 14.0));
|
|
let ds = (t1 - t0) / f32(steps);
|
|
for (var s = 0; s < steps; s++) {
|
|
if trans < 0.02 { break; }
|
|
let p = dir * (t0 + (f32(s) + 0.5) * ds);
|
|
let local = (p - centre) / radius;
|
|
let r = length(local);
|
|
let n = fbm(local * 2.4 + seed + vec3<f32>(0.0, -age * 0.06, age * 0.02));
|
|
let shape = smoothstep(1.0, 0.25, r + (n - 0.5) * 1.1);
|
|
if shape <= 0.0 { continue; }
|
|
let density = shape * (1.2 + n * 2.0) * fade;
|
|
let absorb = 1.0 - exp(-density * ds * 1.4 / max(radius * 0.25, 0.5));
|
|
// Hot inside, soot outside; smoke lit from above by the sky.
|
|
let core = heat * (1.0 - r) * (0.6 + n);
|
|
let smoke = vec3<f32>(0.10, 0.095, 0.09) * (0.5 + 0.8 * sun * (0.5 + local.y * 0.5));
|
|
let c = mix(smoke, fire(core * 1.6), clamp(core * 2.2, 0.0, 1.0));
|
|
lit += trans * absorb * c;
|
|
trans *= 1.0 - absorb;
|
|
}
|
|
// Light thrown round by the fire.
|
|
let d = length(centre);
|
|
glow += vec3<f32>(1.0, 0.5, 0.15) * heat * heat * size * 0.05 / (1.0 + d * 0.03);
|
|
}
|
|
color = color * trans + lit;
|
|
|
|
// 3. Flashes (#51): a huge bright bloom at the blast, a wide halo that lifts the sky and the
|
|
// clouds, a white shock ring racing out, and the view lit up for a moment. The sum is
|
|
// squashed, so a crowded sky never whites the screen out for long.
|
|
var flare = vec3<f32>(0.0);
|
|
for (var i = 0; i < blasts; i++) {
|
|
let a = frame.params[BLAST_BASE + 2 * i];
|
|
let b = frame.params[BLAST_BASE + 2 * i + 1];
|
|
let age = t_now - b.x;
|
|
if age < 0.0 || age > 24.0 { continue; }
|
|
let c0 = clip_of(a.xyz);
|
|
if c0.w <= 0.5 { continue; }
|
|
let p = vec3<f32>(clip_uv(c0), c0.w);
|
|
// Ray distance on both sides: `p.z` is the depth along the view axis, which is shorter
|
|
// than the distance along the ray towards the screen edge.
|
|
let dist = length(a.xyz - frame.camera.xyz);
|
|
let visible = select(0.25, 1.0, scene_distance(p.xy) > dist - a.w);
|
|
let mass = min(a.w / 4.0, 2.5);
|
|
// How close it is, in its own sizes: a burst overhead is felt, a far one only seen.
|
|
let near = clamp(1.0 - dist / (a.w * 40.0), 0.0, 1.0);
|
|
let centre = p.xy * frame.resolution.xy;
|
|
if age <= 10.0 {
|
|
let f = exp(-age * 0.7) * mass * visible;
|
|
let r = screen_radius_clip(c0, a.w * 2.5) + 20.0;
|
|
let dd = length(px - centre) / r;
|
|
// The bloom's tail is below 5e-4 (under 8 bits) past nine radii.
|
|
if dd < 9.0 {
|
|
flare += vec3<f32>(1.0, 0.85, 0.6) * f * (exp(-dd * dd * 3.0) * 3.2 + exp(-dd * 0.8) * 0.3);
|
|
}
|
|
// The halo: a third of the screen wide for a close blast, lifting the sky and clouds most.
|
|
let wide = (0.12 + 0.3 * near) * frame.resolution.y;
|
|
let dw = length(px - centre) / wide;
|
|
if dw < 4.0 {
|
|
let sky = select(1.0, 1.7, wall > 1.0e5);
|
|
flare += vec3<f32>(1.0, 0.82, 0.58) * f * near * sky * exp(-dw * dw) * 0.55;
|
|
}
|
|
flare += vec3<f32>(1.0, 0.8, 0.55) * f * (0.12 + 0.2 * near) / (1.0 + p.z * 0.01);
|
|
}
|
|
// The shock ring: the same radius as the wave that bends the picture.
|
|
let ring = screen_radius_clip(c0, age * 3.2 * (0.6 + a.w * 0.1));
|
|
let width = 3.0 + ring * 0.025;
|
|
let x = (length(px - centre) - ring) / width;
|
|
if abs(x) < 4.0 {
|
|
let fade = 1.0 - age / 24.0;
|
|
flare += vec3<f32>(1.0, 0.9, 0.72) * exp(-x * x) * fade * fade * mass * (0.25 + 0.5 * near) * visible;
|
|
}
|
|
}
|
|
color += flare / (1.0 + flare * 0.2);
|
|
|
|
// 4. Glows: engine flames and muzzle flashes.
|
|
for (var j = 0; j < lights; j++) {
|
|
let a = frame.params[LIGHT_BASE + 2 * j];
|
|
let b = frame.params[LIGHT_BASE + 2 * j + 1];
|
|
let c0 = clip_of(a.xyz);
|
|
if c0.w <= 0.3 { continue; }
|
|
let p = clip_uv(c0);
|
|
let r = screen_radius_clip(c0, a.w) + 3.0;
|
|
let dd = length(px - p * frame.resolution.xy) / r;
|
|
// Far from the glow (intensity at most 2): its tail is below 5e-4, under 8 bits.
|
|
// This is checked before the depth lookup, which most pixels then never make.
|
|
if dd > 6.0 { continue; }
|
|
if scene_distance(p) < length(a.xyz - frame.camera.xyz) - a.w * 1.5 { continue; }
|
|
let flicker = 0.85 + 0.15 * noise3(vec3<f32>(a.xyz * 0.7 + vec3<f32>(t_now * 1.3)));
|
|
color += b.rgb * b.w * flicker * (exp(-dd * dd * 4.0) * 1.2 + exp(-dd * 1.2) * 0.18);
|
|
}
|
|
|
|
color += glow * 0.3;
|
|
// Soft roll-off so bright fire stays coloured instead of clipping flat.
|
|
color = color / (1.0 + max(color - vec3<f32>(1.0), vec3<f32>(0.0)) * 0.5);
|
|
return vec4<f32>(color, 1.0);
|
|
}
|