facett-core/src/render/gpu/graphcloud.wgsl
// **facett-graph3d GPU lane shaders** (feature `wgpu`) — instanced emissive nodes +
// edges drawn with ADDITIVE blend (src=ONE, dst=ONE) into the shared HDR
// (`Rgba16Float`) offscreen, then a bright-pass → Gaussian-blur → composite bloom
// post chain.
//
// The additive blend is the whole point: an edge crossing draws two emissive
// fragments into the same texel, so their linear radiance SUMS (it can exceed 1.0).
// The HDR offscreen keeps that >1.0 energy, the bright pass isolates it, the blur
// spreads it, and the composite adds the glow back — so dense crossings bloom hot.
//
// Geometry is generated in the vertex shader from a per-instance buffer (a unit quad
// expanded to a screen-space disc / thick line), so there is no index/vertex buffer:
// `draw(0..6, 0..instances)`.
// ── Node + edge pass: viewport size only (px → clip) ──────────────────────────
struct SceneUniforms {
// viewport.xy = pane size in physical px ; zw = pad
viewport: vec4<f32>,
};
@group(0) @binding(0) var<uniform> SU: SceneUniforms;
// Per-instance node: a screen-space emissive disc.
struct NodeInst {
@location(0) center: vec2<f32>, // px, top-left origin (y down)
@location(1) radius: f32, // px
@location(2) color: vec4<f32>, // emissive linear radiance (rgb may be > 1)
};
struct NodeVsOut {
@builtin(position) clip: vec4<f32>,
@location(0) local: vec2<f32>, // [-1,1] across the quad
@location(1) color: vec4<f32>,
};
// The 6 corners of a [-1,1] quad (two triangles).
fn quad_corner(vi: u32) -> vec2<f32> {
var c = array<vec2<f32>, 6>(
vec2<f32>(-1.0, -1.0), vec2<f32>( 1.0, -1.0), vec2<f32>( 1.0, 1.0),
vec2<f32>(-1.0, -1.0), vec2<f32>( 1.0, 1.0), vec2<f32>(-1.0, 1.0),
);
return c[vi];
}
// px (top-left, y down) → clip space (y up).
fn px_to_clip(px: vec2<f32>) -> vec2<f32> {
let n = px / SU.viewport.xy; // 0..1
return vec2<f32>(n.x * 2.0 - 1.0, 1.0 - n.y * 2.0);
}
@vertex
fn node_vs(in: NodeInst, @builtin(vertex_index) vi: u32) -> NodeVsOut {
let corner = quad_corner(vi);
let px = in.center + corner * in.radius;
var out: NodeVsOut;
out.clip = vec4<f32>(px_to_clip(px), 0.0, 1.0);
out.local = corner;
out.color = in.color;
return out;
}
@fragment
fn node_fs(in: NodeVsOut) -> @location(0) vec4<f32> {
let d = length(in.local);
if (d > 1.0) { discard; }
// A bright core + soft falloff (Gaussian-ish), so a node is a glowing orb.
let fall = exp(-3.0 * d * d);
let rgb = in.color.rgb * fall;
// Additive blend (ONE,ONE): emit linear radiance; alpha carries coverage so the
// composite background stays correct where nothing drew.
return vec4<f32>(rgb, fall * in.color.a);
}
// Per-instance edge: a thick screen-space emissive line segment a→b.
struct EdgeInst {
@location(0) a: vec2<f32>, // px
@location(1) b: vec2<f32>, // px
@location(2) width: f32, // px (half-width)
@location(3) color: vec4<f32>, // emissive linear radiance
};
struct EdgeVsOut {
@builtin(position) clip: vec4<f32>,
@location(0) across: f32, // [-1,1] across the line width
@location(1) color: vec4<f32>,
};
@vertex
fn edge_vs(in: EdgeInst, @builtin(vertex_index) vi: u32) -> EdgeVsOut {
let corner = quad_corner(vi); // corner.x = along (-1..1), corner.y = across
let dir = normalize(in.b - in.a + vec2<f32>(1e-6, 0.0));
let perp = vec2<f32>(-dir.y, dir.x);
// Map along: -1 → a, +1 → b.
let mid = (in.a + in.b) * 0.5;
let half_len = length(in.b - in.a) * 0.5;
let px = mid + dir * (corner.x * half_len) + perp * (corner.y * in.width);
var out: EdgeVsOut;
out.clip = vec4<f32>(px_to_clip(px), 0.0, 1.0);
out.across = corner.y;
out.color = in.color;
return out;
}
@fragment
fn edge_fs(in: EdgeVsOut) -> @location(0) vec4<f32> {
// Soft across-width falloff so lines are anti-aliased glowing filaments.
let fall = smoothstep(1.0, 0.0, abs(in.across));
return vec4<f32>(in.color.rgb * fall, fall * in.color.a);
}
// ── Post stack (bright → blur → composite) ────────────────────────────────────
struct PostUniforms {
// threshold (x), knee (y), bloom intensity (z), exposure (w)
params: vec4<f32>,
};
@group(0) @binding(0) var<uniform> PU: PostUniforms;
@group(0) @binding(1) var src_tex: texture_2d<f32>;
@group(0) @binding(2) var src_smp: sampler;
struct PostVsOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
};
@vertex
fn post_vs(@builtin(vertex_index) vi: u32) -> PostVsOut {
var p = array<vec2<f32>, 3>(
vec2<f32>(-1.0, -1.0),
vec2<f32>( 3.0, -1.0),
vec2<f32>(-1.0, 3.0),
);
var out: PostVsOut;
let xy = p[vi];
out.clip = vec4<f32>(xy, 0.0, 1.0);
out.uv = vec2<f32>(xy.x * 0.5 + 0.5, 1.0 - (xy.y * 0.5 + 0.5));
return out;
}
// Soft-knee bright pass: keep only radiance above `threshold` (knee = smooth fade).
fn soft_threshold(c: vec3<f32>, threshold: f32, knee: f32) -> vec3<f32> {
let br = max(c.r, max(c.g, c.b));
let soft = clamp(br - threshold + knee, 0.0, 2.0 * knee);
let soft2 = soft * soft / (4.0 * knee + 1e-5);
let contrib = max(soft2, br - threshold) / max(br, 1e-5);
return c * max(contrib, 0.0);
}
@fragment
fn bright_fs(in: PostVsOut) -> @location(0) vec4<f32> {
let c = textureSample(src_tex, src_smp, in.uv).rgb;
return vec4<f32>(soft_threshold(c, PU.params.x, PU.params.y), 1.0);
}
// Composite: HDR scene + blurred bloom, exposure, Reinhard tonemap (the project's
// established transfer), sRGB-ish lift → LDR. binding 3 = blurred bloom texture.
@group(0) @binding(3) var bloom_tex: texture_2d<f32>;
@fragment
fn composite_fs(in: PostVsOut) -> @location(0) vec4<f32> {
let scene = textureSample(src_tex, src_smp, in.uv).rgb;
let bloom = textureSample(bloom_tex, src_smp, in.uv).rgb;
var c = scene + bloom * PU.params.z; // additive bloom (intensity)
c = c * PU.params.w; // exposure
c = c / (c + vec3<f32>(0.85)); // Reinhard-style tonemap
c = pow(clamp(c, vec3<f32>(0.0), vec3<f32>(1.0)), vec3<f32>(1.0 / 1.15));
return vec4<f32>(c, 1.0);
}
// ══ THE CLOUD PASS — positions live ON the GPU, only the camera moves ═════════
//
// The pass above takes SCREEN-SPACE instances: the CPU projects every node and
// builds a NodeInstance/EdgeInstance array every frame, so a spinning 1M-node cloud
// re-sends ~96 MB per frame across PCIe to draw the same points from a slightly
// different angle. Measured on oden: 48.8 ms of CPU per frame at 1M just to rebuild
// arrays whose CONTENT did not change — only the yaw did.
//
// Here the model is uploaded ONCE and the yaw is 16 bytes. The vertex shader does the
// projection and the radiance that `Graph3D::gpu_scene` used to do on the CPU. The
// kernels are ports of `project_pt` and the `radiance` closure in facett-graph3d, and
// `tests` assert this path renders the same picture as the instance path.
//
// Node model, ONE buffer, 16 bytes each: xyz = position in the ~[-1,1] cube,
// w = the node's packed RGBA8 colour bit-cast to f32 (so colour costs no second
// buffer and no padding — `array<vec3<f32>>` would be stride 16 anyway).
struct CloudCam {
// xy = pane size px ; zw = pane centre px
viewport: vec4<f32>,
// x = yaw (rad) ; y = scale px ; z = emissive peak ; w = node radius shrink
cam: vec4<f32>,
// x = edge half-width px ; y = edge colour multiplier ; zw = pad
edge: vec4<f32>,
};
@group(0) @binding(0) var<uniform> CC: CloudCam;
@group(0) @binding(1) var<storage, read> PTS: array<vec4<f32>>;
@group(0) @binding(2) var<storage, read> EDG: array<vec2<u32>>;
// The orthographic Y-rotation, byte-for-byte the same maths as `project_pt`:
// x' = x·cos + z·sin, depth = -x·sin + z·cos, and screen y flips (px is y-down).
// Returns (screen_px, depth).
fn cloud_project(p: vec3<f32>) -> vec3<f32> {
let yaw = CC.cam.x;
let s = sin(yaw);
let c = cos(yaw);
let x = p.x * c + p.z * s;
let d = -p.x * s + p.z * c;
let px = CC.viewport.zw + vec2<f32>(x, -p.y) * CC.cam.y;
return vec3<f32>(px, d);
}
// The port of the `radiance` closure: sRGB-ish → linear by squaring, scaled by the
// emissive peak and a depth term so nearer nodes glow hotter.
fn cloud_radiance(packed: u32, depth: f32, mul: f32) -> vec4<f32> {
let r = f32((packed >> 0u) & 255u) / 255.0;
let g = f32((packed >> 8u) & 255u) / 255.0;
let b = f32((packed >> 16u) & 255u) / 255.0;
let dbright = clamp(0.55 + (depth + 1.0) * 0.35, 0.4, 1.3);
let k = CC.cam.z * dbright * mul;
return vec4<f32>(r * r * k, g * g * k, b * b * k, 1.0);
}
@vertex
fn cloud_node_vs(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> NodeVsOut {
let pt = PTS[ii];
let pr = cloud_project(pt.xyz);
// Radius follows depth exactly as the CPU build did, times the crowd shrink.
let radius = (3.0 + (pr.z + 1.0) * 2.5) * CC.cam.w;
let corner = quad_corner(vi);
var out: NodeVsOut;
out.clip = vec4<f32>(px_to_clip_cloud(pr.xy + corner * radius), 0.0, 1.0);
out.local = corner;
out.color = cloud_radiance(bitcast<u32>(pt.w), pr.z, 1.0);
return out;
}
@vertex
fn cloud_edge_vs(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> EdgeVsOut {
let e = EDG[ii];
let pa = PTS[e.x];
let pb = PTS[e.y];
let ra = cloud_project(pa.xyz);
let rb = cloud_project(pb.xyz);
let corner = quad_corner(vi);
let dir = normalize(rb.xy - ra.xy + vec2<f32>(1e-6, 0.0));
let perp = vec2<f32>(-dir.y, dir.x);
let mid = (ra.xy + rb.xy) * 0.5;
let half_len = length(rb.xy - ra.xy) * 0.5;
let px = mid + dir * (corner.x * half_len) + perp * (corner.y * CC.edge.x);
var out: EdgeVsOut;
out.clip = vec4<f32>(px_to_clip_cloud(px), 0.0, 1.0);
out.across = corner.y;
// The edge takes the TAIL node's colour, like the CPU build, at the mean depth.
out.color = cloud_radiance(bitcast<u32>(pa.w), (ra.z + rb.z) * 0.5, CC.edge.y);
return out;
}
// px → clip, against the cloud pass's own viewport (the instance pass reads `SU`).
fn px_to_clip_cloud(px: vec2<f32>) -> vec2<f32> {
let n = px / CC.viewport.xy;
return vec2<f32>(n.x * 2.0 - 1.0, 1.0 - n.y * 2.0);
}