Triangle LED Hero

Analytic edge-glow triangle with LED emitters, floor radiance, and interactive color deploy. Canvas-scoped pointer input drives the lighting while an accessible mode selector chooses highlighted edges.

Open fullscreen
// Light theme floor material. In light mode the LEDs emit white; this shader builds a
// dark-style glow from the near/middle/far SDF falloffs and tints it with the per-edge
// brand colors. The color output is white or the edge-tinted color (blended by
// colorMix); the glow alpha fades it into the ground (premultiplied output).
// AO shadow is computed here (replaces the old SVG drop-shadow): black + alpha darkens
// the ground near the triangle via premultiplied compositing (page * (1 - a)).
import { oklab_to_rgb } from "../../color-utils.wgsl";
import { sdf_triangle_vertices } from "../../geometry.wgsl";
import { middle_falloff, far_falloff, value_map01 } from "../../floor-falloff.wgsl";
 
// Smooth max: like max(a,b) but rounds the transition over a width k, removing the hard crease
// where two edge planes meet (otherwise visible as 3D-looking ridges from the triangle corners).
fn ao_smax(a: f32, b: f32, k: f32) -> f32 {
  let h = clamp(0.5 + 0.5 * (a - b) / max(k, 1e-4), 0.0, 1.0);
  return mix(b, a, h) + k * h * (1.0 - h);
}
const AO_CORNER_SMOOTH: f32 = 0.25; // corner-crease smoothing, as a fraction of the triangle circumradius
 
 
// Packed uniforms from main-scene-pass.ts:
// - screen/light_sources: render and simulation texture dimensions + AO controls
// - tunables: LED intensity, floor albedo, light-mode radiance/AO parameters
// - triangle: triangle center/radius/half-width in screen pixels
// - culling/radiance_fit: probe culling and screen-to-radiance mapping
// - light_ao/radiance_debug: contact AO, highlight, and debug visualization controls
struct Config { screen: vec4f, light_sources: vec4f, tunables: vec4f, triangle: vec4f, culling: vec4f, radiance_fit: vec4f, light_ao: vec4f, radiance_debug: vec4f, sim_transform: vec4f };
@group(0) @binding(0) var<uniform> cfg: Config;
// Low-frequency colored light generated by the cascade/radiance passes.
@group(0) @binding(1) var radiance_tex: texture_2d<f32>;
// Direct light-source/SDF texture. RGB stores source color; alpha stores SDF data.
@group(0) @binding(2) var light_sources_tex: texture_2d<f32>;
@group(0) @binding(3) var linear_samp: sampler;
// Light-glow settings (separate uniform — see main-scene-pass.ts). Packs the
// near/middle/far falloff params + contrast so light builds the dark-style glow and
// tunes it independently of dark.
// The MONO set (grayscale/idle floor) and the COLOR set (colored/deploy floor) carry two
// independent copies of the per-layer glow SHAPE params. The shader blends mono→color per layer by
// colorMix (= glow.y): near_p = mix(near, nearColor, colorMix), same for middle/glow/params/toggles.
// near/middle contribution stays gated by colorMix (as on main), so the colorMix=0 floor is exactly
// main's far-only grayscale; the blended COLOR params shape near/middle as they ramp in.
//   near:    (distanceScale, intensity, mapMin, mapMax)
//   middle:  (power, intensity, highlightNoise, _)   — highlightNoise (.z) is MONO-only, never blended
//   glow:    (far intensity, colorMix, mapMin, mapMax)
//   params:  (nearPower, middleOuterScale, farPower, fadeInner)
//   toggles: (nearEnabled, middleEnabled, farEnabled, contrast)
// glow.y (colorMix) blends the floor tint from white light (0) to fully colored (1);
// the renderer drives it from the hover/deploy factor.
// edgeRed/Green/Blue carry the per-edge floor colors (sRGB in .xyz, .w unused), GUI-editable.
//   nearColor/middleColor/glowColor/paramsColor/togglesColor: the COLOR copy of the matching slots
//   (glowColor.y unused — colorMix stays authoritative on glow.y).
struct LightGlow { near: vec4f, middle: vec4f, glow: vec4f, params: vec4f, toggles: vec4f, ao: vec4f, ao2: vec4f, ao3: vec4f, edgeRed: vec4f, edgeGreen: vec4f, edgeBlue: vec4f, nearColor: vec4f, middleColor: vec4f, glowColor: vec4f, paramsColor: vec4f, togglesColor: vec4f };
@group(0) @binding(4) var<uniform> lg: LightGlow;
 
// Cheap per-pixel hashes (Dave Hoskins hash12 + a reseeded sibling) → [0,1], ported from the dark
// floor. Used to add grain that EXAGGERATES the highlight mask (amount = lg.middle.z = highlightNoise).
fn hash12(p: vec2f) -> f32 {
  var p3 = fract(vec3f(p.xyx) * 0.1031);
  p3 += dot(p3, p3.yzx + 33.33);
  return fract((p3.x + p3.y) * p3.z);
}
fn hash12b(p: vec2f) -> f32 {
  var p3 = fract(vec3f(p.xyx) * 0.1531);
  p3 += dot(p3, p3.yzx + 33.33);
  return fract((p3.x + p3.y) * p3.z);
}
 
struct VSOut { @builtin(position) pos: vec4f };
 
// Draw a fullscreen triangle for the floor material pass.
@vertex fn vs_main(@builtin(vertex_index) vi: u32) -> VSOut {
  var p = array<vec2f, 3>(vec2f(-1.0, -3.0), vec2f(-1.0, 1.0), vec2f(3.0, 1.0));
  var out: VSOut;
  out.pos = vec4f(p[vi], 0.0, 1.0);
  return out;
}
 
fn catmull_rom_weight(x: f32) -> f32 {
  let ax = abs(x);
  if (ax <= 1.0) {
    return 1.5 * ax * ax * ax - 2.5 * ax * ax + 1.0;
  }
  if (ax < 2.0) {
    return -0.5 * ax * ax * ax + 2.5 * ax * ax - 4.0 * ax + 2.0;
  }
  return 0.0;
}
 
// Catmull-Rom 4x4 sampling for the half-resolution direct radiance target.
// Taps are clamped in texel space so edge pixels do not smear wrapped values.
fn sample_radiance_cubic(uv: vec2f) -> vec3f {
  let dims_u = textureDimensions(radiance_tex);
  let dims = vec2f(dims_u);
  let texel = clamp(uv, vec2f(0.0), vec2f(1.0)) * dims - vec2f(0.5);
  let base = floor(texel);
  let f = texel - base;
  var sum = vec3f(0.0);
  var weight_sum = 0.0;
  for (var y: i32 = -1; y <= 2; y = y + 1) {
    let wy = catmull_rom_weight(f.y - f32(y));
    for (var x: i32 = -1; x <= 2; x = x + 1) {
      let wx = catmull_rom_weight(f.x - f32(x));
      let w = wx * wy;
      let tap = clamp(vec2i(base) + vec2i(x, y), vec2i(0), vec2i(dims_u) - vec2i(1));
      sum += textureLoad(radiance_tex, tap, 0).rgb * w;
      weight_sum += w;
    }
  }
  return max(sum / max(weight_sum, 1e-5), vec3f(0.0));
}
 
fn sample_radiance_at(pixel_screen: vec2f) -> vec3f {
  let fitted_uv = (pixel_screen - cfg.radiance_fit.xy) / cfg.radiance_fit.zw;
  let inside_fit =
    fitted_uv.x >= 0.0 && fitted_uv.x <= 1.0 &&
    fitted_uv.y >= 0.0 && fitted_uv.y <= 1.0;
  var radiance = sample_radiance_cubic(fitted_uv);
  if (!inside_fit) {
    radiance = vec3f(0.0);
  }
  return radiance;
}
 
// Light-mode floor tint. In light mode the LEDs emit white, so the floor itself colors
// the light. Each triangle edge maps to a brand color; a per-pixel inverse-distance
// blend of the three edge centers builds the tint (matching dark mode's CPU edge
// mapping: red → top-left, green → bottom, blue → top-right edge midpoints). The per-edge
// colors are GUI-editable sRGB, carried in lg.edgeRed/edgeGreen/edgeBlue (.xyz).
 
// Triangular angular weight: 1 at theta_i, falling linearly to 0 at 120° away. For three
// stops 120° apart this yields exact linear interpolation between the two nearest colors.
fn angular_weight(theta: f32, theta_i: f32) -> f32 {
  let d = theta - theta_i;
  let dist = abs(atan2(sin(d), cos(d))); // shortest wrapped angular distance, [0, π]
  return max(0.0, 1.0 - dist / 2.0943951); // 2.0943951 = 120° (2π/3)
}
 
fn floor_edge_tint(pixel: vec2f, top: vec2f, left: vec2f, right: vec2f) -> vec3f {
  // Conic gradient: the tint depends only on the angle around the triangle center, so the
  // three edge colors interpolate at a constant rate all the way around (no localized
  // "spots" like the old inverse-distance weighting produced). Each edge center is 120°
  // apart, and angular_weight gives exact linear interpolation between the two nearest.
  let center = cfg.triangle.xy;
  let theta = atan2(pixel.y - center.y, pixel.x - center.x);
  let red_center = (top + left) * 0.5;
  let green_center = (left + right) * 0.5;
  let blue_center = (right + top) * 0.5;
  // Overlap control (lg.edgeRed.w = 1/overlap): raise the angular weights before normalizing.
  // 1 = unchanged; >1 (overlap<1) sharpens each edge color; <1 (overlap>1) blends them more.
  let inv_overlap = lg.edgeRed.w;
  let wr = pow(angular_weight(theta, atan2(red_center.y - center.y, red_center.x - center.x)), inv_overlap);
  let wg = pow(angular_weight(theta, atan2(green_center.y - center.y, green_center.x - center.x)), inv_overlap);
  let wb = pow(angular_weight(theta, atan2(blue_center.y - center.y, blue_center.x - center.x)), inv_overlap);
  let wsum = max(wr + wg + wb, 1e-4);
 
  // Vivid OKLCh-style blend. Averaging OKLab a/b directly lets opposing hues cancel,
  // collapsing chroma into muddy/gray midpoints. Instead, average lightness and chroma
  // (magnitude) independently, and take the hue from the weighted sum of the a/b vectors
  // — the short-arc circular mean. Renormalizing that direction back to the averaged
  // chroma keeps transitions saturated (red→blue stays vivid through violet, not gray).
  // rgb↔oklab round-trips exactly, so each corner stays its true brand color.
  // Precomputed CPU-side (cbrt-LMS of the GUI-editable edge colors) and carried in
  // edgeRed/Green/Blue.xyz — hoists the three rgb_to_oklab cube-root triples out of the per-pixel
  // loop, since they depend only on the edge-color uniforms.
  let lab_r = lg.edgeRed.xyz;
  let lab_g = lg.edgeGreen.xyz;
  let lab_b = lg.edgeBlue.xyz;
  let l = (lab_r.x * wr + lab_g.x * wg + lab_b.x * wb) / wsum;
  let chroma =
    (length(lab_r.yz) * wr + length(lab_g.yz) * wg + length(lab_b.yz) * wb) / wsum;
  let ab_sum = lab_r.yz * wr + lab_g.yz * wg + lab_b.yz * wb;
  let ab_len = length(ab_sum);
  // Below the guard the hues cancel (the neutral point where all edges meet equally) —
  // leave it near-gray rather than amplifying numerical noise into a random hue.
  var ab = vec2f(0.0);
  if (ab_len > 1e-5) {
    ab = (ab_sum / ab_len) * chroma;
  }
  return clamp(oklab_to_rgb(vec3f(l, ab.x, ab.y)), vec3f(0.0), vec3f(1.0));
}
 
// Smooth screen-edge envelope: 1 across the interior, easing to 0 near ALL FOUR edges. The fade
// width (packed into cfg.sim_transform.w — .w is free; the shader only reads .xyz for the sim→screen
// transform) is a fraction of the canvas HEIGHT, used as the same pixel band on every edge so the
// border is uniform (corners fade fastest, where both factors drop). Applied to ALPHA (not color)
// because light composites premultiplied over the background; scaling alpha fades the glow into the
// ground at the edges — left/right too, so any color reaching a side edge eases out.
fn edge_fade(pixel_screen: vec2f) -> f32 {
  let w = max(cfg.sim_transform.w * cfg.screen.y, 1.0);
  let dy = min(pixel_screen.y, cfg.screen.y - pixel_screen.y);
  let dx = min(pixel_screen.x, cfg.screen.x - pixel_screen.x);
  return smoothstep(0.0, w, dy) * smoothstep(0.0, w, dx);
}
 
// Signed distance to one triangle edge's outward half-plane (positive outside the triangle).
fn ao_half_plane_distance(p: vec2f, e0: vec2f, e1: vec2f, center: vec2f) -> f32 {
  let edge = e1 - e0;
  var n = normalize(vec2f(-edge.y, edge.x));
  if (dot(n, e0 - center) < 0.0) { n = -n; } // force outward (away from centroid)
  return dot(p - e0, n);
}
// Triangle distance from its three edge half-planes (sharp/miter corners) instead of the true
// SDF. The SDF rounds at the vertices, which reads as unrealistic circular arcs in the AO; the
// smooth-max of the outward edge-plane distances keeps the AO isolines triangle-shaped (straight
// edges) while blending the crease at each corner so the hard-max ridges/pyramid lines disappear.
fn ao_triangle_plane_distance(p: vec2f, a: vec2f, b: vec2f, c: vec2f) -> f32 {
  let center = (a + b + c) * (1.0 / 3.0);
  let k = cfg.triangle.z * AO_CORNER_SMOOTH; // cfg.triangle.z = circumradius (screen px)
  let dab = ao_half_plane_distance(p, a, b, center);
  let dbc = ao_half_plane_distance(p, b, c, center);
  let dca = ao_half_plane_distance(p, c, a, center);
  return ao_smax(ao_smax(dab, dbc, k), dca, k);
}
 
@fragment fn fs_main(in: VSOut) -> @location(0) vec4f {
  // Convert fragment position into normalized screen UVs and screen-pixel space.
  let uv = in.pos.xy / cfg.screen.xy;
  let pixel_screen = in.pos.xy;
  // colorMix: 0 = grayscale/idle (white light), 1 = colored/deploy-active.
  // Defined once here; reused to gate the middle falloff and the AO block.
  let colorMix = clamp(lg.glow.y, 0.0, 1.0);
 
  // Rebuild the triangle vertices in screen space so this pass can compute SDF
  // masks/falloffs that align with the mesh drawn over the floor.
  let top = vec2f(cfg.triangle.x, cfg.triangle.y - cfg.triangle.z);
  let left = vec2f(cfg.triangle.x - cfg.triangle.w, cfg.triangle.y + cfg.triangle.z * 0.5);
  let right = vec2f(cfg.triangle.x + cfg.triangle.w, cfg.triangle.y + cfg.triangle.z * 0.5);
  let triangle_sdf = sdf_triangle_vertices(pixel_screen, top, left, right);
 
  // Map screen pixels into the fitted radiance texture. Outside the fitted rect we
  // zero radiance to avoid edge smearing from clamped texture samples.
  let radiance = sample_radiance_at(pixel_screen);
 
  // Existing raw-radiance debug mode.
  if (cfg.radiance_debug.x > 0.5) {
    return vec4f(radiance, 1.0);
  }
 
  // Combine the three falloffs the same way dark mode does. NOT a plain sum:
  // screen-blend the in-range [0,1] parts (preserves the tuned look), then add any
  // over-1 overflow additively (a plain screen blend goes non-monotonic past 1, so
  // the overflow is split off to keep brightness increasing). `brightness_factor`
  // is the combined result.
  let near_light = dot(radiance, vec3f(0.2126, 0.7152, 0.0722));
  // Per-layer MONO→COLOR param blend by colorMix (0 = grayscale/idle, 1 = colored/deploy): each
  // falloff reads the blended shape params, so the mono set drives the white floor and the color
  // set drives the colored floor. highlightNoise (lg.middle.z) is NOT blended — it is read straight
  // from the mono middle below. The near/middle CONTRIBUTION stays gated by colorMix (as on main):
  // at colorMix=0 only `far` contributes, so the grayscale floor is byte-for-byte main's; near and
  // middle (using their now-blended COLOR params) ramp in only as colorMix → 1.
  let near_p = mix(lg.near, lg.nearColor, colorMix);
  let middle_p = mix(lg.middle, lg.middleColor, colorMix);
  let glow_p = mix(lg.glow, lg.glowColor, colorMix);
  let params_p = mix(lg.params, lg.paramsColor, colorMix);
  let toggles_p = mix(lg.toggles, lg.togglesColor, colorMix);
  let fade_inner = params_p.w;
  // NEAR (colored only): a PURE LUMINANCE remap — NO SDF band. value_map01(near_light, mapMin,
  // mapMax) shaped by nearPower (a contrast curve on the luminance, not a band), scaled by
  // intensity. Gated by colorMix like before, so the grayscale floor (far-only) is unchanged.
  // nearDistanceScale + fadeInner no longer affect near (the SDF band is gone).
  let near = pow(value_map01(near_light, near_p.z, near_p.w), max(params_p.x, 0.001))
    * near_p.y * toggles_p.x * colorMix;
  let middle = middle_falloff(
    triangle_sdf,
    fade_inner,
    cfg.triangle.z,
    params_p.y,
    middle_p,
    toggles_p.y,
  ) * colorMix;
  let far = far_falloff(
    near_light,
    glow_p,
    params_p.z,
    toggles_p.z,
  );
  let screen_blend =
    1.0 - (1.0 - min(near, 1.0)) * (1.0 - min(middle, 1.0)) * (1.0 - min(far, 1.0));
  let overflow =
    max(near - 1.0, 0.0) + max(middle - 1.0, 0.0) + max(far - 1.0, 0.0);
  let brightness_factor = screen_blend + overflow;
 
  var alpha = 1.;
 
  // Foreground triangle occluder, drawn analytically from the same SDF (the SDF gradient length
  // gives a true ~1px edge AA), matching dark mode — no separate geometry pass.
  // cfg.culling.z is the show-triangle flag (0/1). length(dpdx, dpdy) is used instead of fwidth's
  // Manhattan sum |dpdx|+|dpdy|, which over-widens diagonal edges (like the triangle's) by up to sqrt(2).
  let occluder_edge = max(length(vec2f(dpdx(triangle_sdf), dpdy(triangle_sdf))), 1e-4);
  let occluder = clamp(0.5 - triangle_sdf / occluder_edge, 0.0, 1.0) * cfg.culling.z;
  alpha = mix(alpha, 0.0, occluder);
 
  var highlight = near;
  highlight = clamp(highlight, 0.0, 1.0);
  highlight = pow(highlight, 0.5);
  highlight *= 1. - occluder; // Don't highlight where the triangle occluder is.
  // EXAGGERATE the highlight mask with per-pixel hash grain (lg.middle.z = highlightNoise): a
  // multiplicative ±variance that breaks the smooth highlight into a punchy, grainy mask.
  let hl_grain = hash12b(pixel_screen) + 0.5 * hash12(pixel_screen); // ~[0, 1.5]
  highlight = clamp(highlight * (1.0 + (hl_grain - 0.75) * lg.middle.z), 0.0, 1.0);
  // DEBUG (toggle in Glow (color) = lg.glowColor.y): render the raw `highlight` variable as opaque
  // grayscale, bypassing the composite.
  if (lg.glowColor.y > 0.5) {
    return vec4f(vec3f(highlight), 1.0);
  }
  // highlight = 0.;
 
  // Apply dark-style contrast to the combined glow, then use it as the alpha.
  // `alpha` carries the occluder cut. Contrast is the colorMix-blended toggles_p.w.
  let light_glow = (brightness_factor - 0.5) * toggles_p.w + 0.5;
  var glow_alpha = clamp(light_glow, 0.0, 1.0) * alpha;
 
  // contrast when light is white. The old mix(pow(a,1.2)*1.2, a*1.5, 1.0) selected the second
  // arg verbatim (blend factor 1.0), so the pow branch was dead — it is just a * 1.5.
  glow_alpha = glow_alpha * 1.5;
  glow_alpha = clamp(glow_alpha, 0.0, 1.0);
 
  // Vertical screen-edge fade (mirrors dark edge_fade but multiplies ALPHA, not color:
  // light composites premultiplied over the ground, so scaling alpha fades the glow into
  // the background at top/bottom without pushing color toward black).
  glow_alpha = glow_alpha * edge_fade(pixel_screen);
 
  // Color output: white light or the per-edge brand tint, blended by colorMix
  // (lg.glow.y). The glow alpha then fades it into the ground (premultiplied output).
  var edge_tint = floor_edge_tint(pixel_screen, top, left, right);
  edge_tint += edge_tint * highlight;
  edge_tint = clamp(edge_tint, vec3f(0.0), vec3f(1.0));
  edge_tint = mix(edge_tint, vec3f(1.0), pow(highlight, 2.2) * 0.7);
  var color = mix(vec3f(1.0), edge_tint, clamp(lg.glow.y, 0.0, 1.0));
  // Brightness boost only on the tinted floor: ×1 at colorMix=0 (white stays white, no idle
  // blowout) ramping to ×2 at colorMix=1 (fully colored).
  color *= mix(1.0, 2.0, colorMix);
 
  // Three-layer AO: all layers darken the ground near the triangle via premultiplied compositing.
  // ao (layer 1): broad ambient shadow. ao2 (layer 2): smaller contact AO. ao3 (layer 3): tightest contact.
  // Gated to no-light areas (1 - colorMix) and outside the triangle occluder. Combined darkness
  // terms are summed and clamped before the shared gate and single composite step.
  let ao_outside = max(ao_triangle_plane_distance(pixel_screen, top, left, right), 0.0);
  // Layer 1 (the larger broad ambient shadow) uses the true triangle SDF, so its wide isolines
  // round off at the corners; the tighter contact layers (ao2/ao3) keep the straight-edged
  // half-plane distance.
  let ao_outside_sdf = max(triangle_sdf, 0.0);
  let ao1 = lg.ao.x  * pow(1.0 - clamp(ao_outside_sdf / max(cfg.triangle.z * lg.ao.y,  1e-4), 0.0, 1.0), max(lg.ao.z,  0.01)) * step(0.5, lg.ao.w);
  // ao2 is a second (smaller/contact) AO layer that stacks on ao.
  let ao2 = lg.ao2.x * pow(1.0 - clamp(ao_outside / max(cfg.triangle.z * lg.ao2.y, 1e-4), 0.0, 1.0), max(lg.ao2.z, 0.01)) * step(0.5, lg.ao2.w);
  // ao3 is the tightest contact AO layer (smallest radius, sharpest power).
  let ao3 = lg.ao3.x * pow(1.0 - clamp(ao_outside / max(cfg.triangle.z * lg.ao3.y, 1e-4), 0.0, 1.0), max(lg.ao3.z, 0.01)) * step(0.5, lg.ao3.w);
  let ao_dark = clamp(ao1 + ao2 + ao3, 0.0, 1.0) * (1.0 - colorMix) * (1.0 - occluder);
  let out_alpha = glow_alpha + ao_dark * (1.0 - glow_alpha);
  // Paint the foreground triangle as opaque black in the canvas itself (premultiplied "over"
  // the floor), rather than cutting a transparent hole for the SVG triangle behind it. The
  // occluder mask and the SVG triangle don't line up to the sub-pixel, which left a thin ring
  // of uncovered page (white) along the edge. Compositing the opaque-black triangle here means
  // the canvas fully covers that region itself, so there's no seam (works in light + color).
  let final_rgb = color * glow_alpha * (1.0 - occluder);
  let final_alpha = out_alpha * (1.0 - occluder) + occluder;
  return vec4f(final_rgb, final_alpha);
}