//! Material shader — Glass/frosted-glass rendering path.
//! Handles mode 7 only. Separated from opaque to reduce register pressure
//! (glass shader is ~150 lines of complex math vs ~100 for opaque).
//! Glass samples the backdrop blur mip chain via textureSampleLevel(t_env, s_env, uv, blur_mip).
// ─── Section 1: Physical Optics (Snell's Law Refraction) ─────────────────────
/// Physically accurate refraction using Snell's law.
/// n1 = 1.0 (air), n2 = per-instance IOR from uniforms.
/// Returns the UV offset for the refracted sample direction.
fn snell_refraction(normal: vec2<f32>, incident: vec2<f32>, ior: f32) -> vec2<f32> {
let n_ratio = 1.0 / ior;
let cos_i = -dot(normal, incident);
let sin2_t = n_ratio * n_ratio * (1.0 - cos_i * cos_i);
// Total internal reflection
if sin2_t > 1.0 {
return reflect(incident, normal);
}
let cos_t = sqrt(1.0 - sin2_t);
return n_ratio * incident + (n_ratio * cos_i - cos_t) * normal;
}
// ─── Section 1b: GGX Specular Highlight ─────────────────────────────────────
/// GGX/Trowbridge-Reitz normal distribution function.
fn ggx_ndf(n_dot_h: f32, roughness: f32) -> f32 {
let a = roughness * roughness;
let a2 = a * a;
let denom = n_dot_h * n_dot_h * (a2 - 1.0) + 1.0;
return a2 / (3.14159265 * denom * denom);
}
/// Compute specular highlight for liquid glass surface.
/// light_dir: normalized direction to light source (in screen space)
/// view_dir: normalized view direction
/// normal: surface normal
/// roughness: surface roughness (0 = mirror, 1 = diffuse)
/// intensity: specular intensity multiplier
fn ggx_specular(light_dir: vec2<f32>, view_dir: vec2<f32>, normal: vec2<f32>, roughness: f32, intensity: f32) -> f32 {
let half_vec = normalize(light_dir + view_dir);
let n_dot_h = max(dot(normal, half_vec), 0.0);
let n_dot_l = max(dot(normal, light_dir), 0.0);
let n_dot_v = max(dot(normal, view_dir), 0.0);
// Fresnel (Schlick approximation)
let f0 = 0.04; // IOR 1.5 glass
let fresnel = f0 + (1.0 - f0) * pow(1.0 - n_dot_v, 5.0);
// GGX distribution
let d = ggx_ndf(n_dot_h, roughness);
// Geometry function (Smith's method)
let k = (roughness + 1.0) * (roughness + 1.0) / 8.0;
let g = n_dot_v / (n_dot_v * (1.0 - k) + k);
let g2 = g * g;
let spec = (d * fresnel * g2) / (4.0 * n_dot_l * n_dot_v + 0.001);
return spec * intensity;
}
// ─── Section 1c: Displacement Map (feDisplacementMap analog) ─────────────────
/// Compute displacement offset for liquid glass edge distortion.
/// Uses screen-space derivatives to create the "wet glass" edge effect.
/// The displacement is strongest at the edges and fades toward the center.
fn displacement_offset(
uv: vec2<f32>,
local: vec2<f32>,
lens_dist: f32,
lens_normal: vec2<f32>,
time: f32,
) -> vec2<f32> {
// Edge displacement: strongest at edges, fades inward
let edge_factor = smoothstep(0.3, 0.5, lens_dist);
// Use screen-space derivatives for distortion magnitude
let dx = length(vec2(dpdx(uv.x), dpdy(uv.x)));
let dy = length(vec2(dpdx(uv.y), dpdy(uv.y)));
let deriv_scale = max(dx, dy) * 50.0;
// Displacement direction: along the normal, pushing outward at edges
let disp_mag = edge_factor * deriv_scale * 0.15;
// Add subtle time-varying turbulence
let turb = sin(local.x * 20.0 + time * 0.5) * cos(local.y * 20.0 + time * 0.3) * 0.002;
return lens_normal * (disp_mag + turb * edge_factor);
}
// ─── Section 2: Adaptive Appearance ──────────────────────────────────────────
/// Sample backdrop at 9 positions (3x3 grid) at mip-4 for dominant color.
/// Uses a wider spread than the old 4-sample mip-6 approach for better
/// detection of high-frequency backdrop patterns that can kill legibility.
fn sample_backdrop_dominant(uv: vec2<f32>) -> vec3<f32> {
let offsets = array<vec2<f32>, 9>(
vec2<f32>(-0.15, -0.15), vec2<f32>(0.0, -0.15), vec2<f32>(0.15, -0.15),
vec2<f32>(-0.15, 0.0), vec2<f32>(0.0, 0.0), vec2<f32>(0.15, 0.0),
vec2<f32>(-0.15, 0.15), vec2<f32>(0.0, 0.15), vec2<f32>(0.15, 0.15)
);
var sum = vec3<f32>(0.0);
for (var i = 0u; i < 9u; i++) {
sum += textureSampleLevel(t_env, s_env, uv + offsets[i], 4.0).rgb;
}
return sum / 9.0;
}
/// Compute backdrop variance across the 9 sample positions.
/// Returns 0.0 (uniform) to 1.0 (high variance / busy backdrop).
/// `mean` is the pre-computed dominant color from sample_backdrop_dominant().
fn sample_backdrop_variance(uv: vec2<f32>, mean: vec3<f32>) -> f32 {
let offsets = array<vec2<f32>, 9>(
vec2<f32>(-0.15, -0.15), vec2<f32>(0.0, -0.15), vec2<f32>(0.15, -0.15),
vec2<f32>(-0.15, 0.0), vec2<f32>(0.0, 0.0), vec2<f32>(0.15, 0.0),
vec2<f32>(-0.15, 0.15), vec2<f32>(0.0, 0.15), vec2<f32>(0.15, 0.15)
);
var var_sum = 0.0;
for (var i = 0u; i < 9u; i++) {
let s = textureSampleLevel(t_env, s_env, uv + offsets[i], 4.0).rgb;
let diff = s - mean;
var_sum += dot(diff, diff);
}
return clamp(var_sum / 9.0 * 3.0, 0.0, 1.0);
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
var color = in.color;
let fw = length(vec2(dpdx(in.logical.x), dpdy(in.logical.y)));
// SDF Clipping (same as opaque)
let p_clip_pos = in.clip.xy * scene.scale_factor;
let p_clip_size = in.clip.zw * scene.scale_factor;
let pixel_pos = in.clip_position.xy;
let clip_d = sd_box(pixel_pos - (p_clip_pos + p_clip_size * 0.5), p_clip_size * 0.5);
var clip_alpha = 1.0 - smoothstep(-1.0, 1.0, clip_d);
if (in.clip.z > 15000.0) { clip_alpha = 1.0; }
color.a *= clip_alpha;
// Early exit for zero intensity: skip all expensive glass computation
let gi = in.glass_intensity;
// Geometric Slice (Mjolnir Slice)
if (in.slice.z > 0.5) {
let angle_rad = in.slice.x * 0.01745329251;
let normal_dir = vec2<f32>(cos(angle_rad), sin(angle_rad));
let dist = dot(in.world_pos, normal_dir) - in.slice.y;
if (dist > 0.0) { discard; }
}
// ─── Section 1: Geometry and Clipping ────────────────────────────────────
let uv = clamp(in.uv, vec2<f32>(0.0, 0.0), vec2<f32>(1.0, 1.0));
let screen_uv = in.clip_position.xy / (scene.resolution * scene.scale_factor);
// Clean, constant variation factor to prevent high-frequency Moire/brushed-metal artifacts on rotating cards
let variation = 1.0;
// Local coordinates
let local = in.logical / in.size;
let centered = local - vec2<f32>(0.5, 0.5);
let lens_normal = normalize(centered + vec2<f32>(1e-5, 1e-5));
let lens_dist = length(centered);
let fresnel = pow(lens_dist * 1.8, 2.5);
// ─── Section 2: Physical Optics (Snell's Law) ────────────────────────────
// View direction (simplified: from center toward edge)
let view_dir = normalize(centered + vec2<f32>(1e-5, 1e-5));
// Resolve IOR: prioritize per-instance ior_override, fall back to theme, then standard borosilicate (1.45)
let base_ior = select(1.45, theme.glass_ior, theme.glass_ior > 0.0);
let ior = select(base_ior, in.ior_override, in.ior_override > 0.0);
// Compute refracted direction using Snell's law
let refracted_dir = snell_refraction(lens_normal, view_dir, ior);
// Non-trivial algorithm: Magnifying Lens Refraction
// WHY: Traditional Snell refraction on a 2D quad often shrinks/displaces the backdrop. To simulate
// a premium convex liquid/ice lens, we must shift the texture coordinate inward towards the center.
// Near the center, magnification is strongest. Near the edges, it transitions to refraction roll-off
// to prevent edge-sampling artifacts outside the card bounds.
// CONTRACT: Returns a vec2 offset that contracts screen UV lookups towards the card center.
let mag_strength = 0.16 * (1.0 - smoothstep(0.0, 0.8, lens_dist));
var refraction_offset = refracted_dir * lens_dist * 0.08 * variation;
refraction_offset += -view_dir * lens_dist * mag_strength;
// ─── Section 3: Material Noise and Stress ────────────────────────────────
let hash_noise = vec2<f32>(
fract(sin(dot(local, vec2<f32>(12.9898, 78.233))) * 43758.5453),
fract(sin(dot(local, vec2<f32>(93.9898, 67.345))) * 24634.6345)
) * 0.01;
let noise1 = vnoise(uv * 6.0 + scene.time * 0.2);
let stress_offset = normalize(vec2<f32>(0.5, 0.8)) * noise1 * 0.02;
// ─── Section 5: SDF Edge and Thickness ───────────────────────────────────
let half_size = in.size * 0.5;
let squircle_n = select(0.0, in.slice.y, in.slice.y > 1.5);
var d_sdf: f32;
if (squircle_n > 1.5) {
d_sdf = sd_squircle(in.logical - half_size, half_size, squircle_n);
} else {
d_sdf = sd_round_rect(in.logical - half_size, half_size - in.radius, in.radius);
}
if gi < 0.01 {
let alpha = color.a * (1.0 - smoothstep(-fw, fw, d_sdf));
if alpha <= 0.0 { discard; }
return vec4<f32>(color.rgb, alpha);
}
// ─── Section 4: Backdrop Sampling with Chromatic Aberration ──────────────
// Use per-element blur radius, falling back to theme default if 0
let blur_mip = select(theme.glass_blur_strength, in.blur_radius, in.blur_radius > 0.0);
let env_base = textureSampleLevel(t_env, s_env, screen_uv, blur_mip).rgb;
let brightness = dot(env_base, vec3<f32>(0.299, 0.587, 0.114));
// Combine distortion sources
var distortion = refraction_offset;
distortion += stress_offset * 0.6;
distortion += hash_noise * 0.3;
// Tactical pointer proximity hover pressure/refraction distortion
let frag_logical_pos = in.clip_position.xy / scene.scale_factor;
let dist_to_mouse = distance(frag_logical_pos, scene.mouse);
let hover_radius = 120.0;
if (dist_to_mouse < hover_radius) {
let hover_factor = 1.0 - (dist_to_mouse / hover_radius);
let hover_pulse = smoothstep(0.0, 1.0, hover_factor);
let hover_dir = normalize(frag_logical_pos - scene.mouse + vec2<f32>(1e-5, 1e-5));
let mouse_speed = length(scene.mouse_velocity);
let hover_displacement = hover_dir * hover_pulse * (0.015 + mouse_speed * 0.003);
distortion += hover_displacement;
}
distortion *= (1.0 + brightness * 0.7);
// Dynamic shape masking: Scale down distortion near the glass edges to prevent sampling pixels outside the glass geometry.
// If d_sdf is positive (outside/near edge), we clamp the distortion.
let dist_fade = smoothstep(10.0, 0.0, d_sdf);
let safe_distortion = distortion * dist_fade;
// Chromatic aberration: sample R/G/B at slightly different offsets
let ab_offset = safe_distortion * 0.04;
let r_sample = textureSampleLevel(t_env, s_env, screen_uv + safe_distortion + ab_offset * 1.2, blur_mip).r;
let g_sample = textureSampleLevel(t_env, s_env, screen_uv + safe_distortion, blur_mip).g;
let b_sample = textureSampleLevel(t_env, s_env, screen_uv + safe_distortion - ab_offset * 1.2, blur_mip).b;
var refracted = vec3<f32>(r_sample, g_sample, b_sample);
let border_dist = -d_sdf;
let flicker = 0.9 + vnoise(uv * 20.0 + scene.time * 3.0) * 0.1;
let hard_rim = smoothstep(0.0, 1.0, border_dist) * exp(-border_dist * 0.8);
let soft_glow = smoothstep(0.0, 3.0, border_dist) * exp(-border_dist * 0.1);
let rim_light = (hard_rim * 0.85 + soft_glow * 0.15) * flicker;
// ─── Section 6: Adaptive Tint from Backdrop ──────────────────────────────
// Sample backdrop dominant color and variance for adaptive tinting
let backdrop_dominant = sample_backdrop_dominant(screen_uv);
let backdrop_var = sample_backdrop_variance(screen_uv, backdrop_dominant);
// Adaptive tint: mix static theme tint with backdrop-derived tint.
// High variance (busy backdrop) reduces adaptation to prevent legibility issues.
// glass_tint_adapt controls the max weight (0 = static, 1 = fully adaptive).
let effective_adapt = theme.glass_tint_adapt * (1.0 - backdrop_var);
let adaptive_tint = mix(theme.glass_base.rgb, backdrop_dominant * 0.3 + 0.7, effective_adapt);
// ─── Section 7: Sub-Surface Scattering Approximation ─────────────────────
// Thickness: SDF distance from edge, normalized
// Negative SDF = inside glass. Deeper inside = thinner center.
let thickness = 1.0 - clamp(-d_sdf / (in.size.x * 0.5), 0.0, 1.0);
let sss_tint = mix(vec3<f32>(0.92, 0.96, 1.0), vec3<f32>(0.7, 0.8, 0.95), thickness);
// ─── Section 8: Edge Smear Convolution ───────────────────────────────────
// Keep the edge response soft and avoid a hard white caustic line.
let smear_dist = clamp(-d_sdf, 0.0, 2.0) / 2.0;
let smear_sample = textureSampleLevel(
t_env, s_env,
screen_uv + lens_normal * smear_dist * 0.004,
blur_mip
).rgb;
let smear_contribution = smear_sample * 0.07;
let edge_mask = smoothstep(0.45, 0.0, abs(d_sdf));
let crystal_edge = edge_mask * 0.04;
// ─── Section 9: Displacement + Specular ────────────────────────────────────
// Apply displacement offset to the refraction for "wet glass" edge distortion
let disp = displacement_offset(screen_uv, local, lens_dist, lens_normal, scene.time);
let displaced_screen_uv = screen_uv + disp * dist_fade;
let displaced_refracted = vec3<f32>(
textureSampleLevel(t_env, s_env, displaced_screen_uv + safe_distortion * 0.04, blur_mip).r,
textureSampleLevel(t_env, s_env, displaced_screen_uv, blur_mip).g,
textureSampleLevel(t_env, s_env, displaced_screen_uv - safe_distortion * 0.04, blur_mip).b,
);
// Blend between normal refraction and displaced refraction based on edge proximity
let disp_blend = smoothstep(0.3, 0.5, lens_dist) * 0.6;
refracted = mix(refracted, displaced_refracted, disp_blend);
// GGX specular highlight — dynamic light from fireball position
// Compute light direction from fireball position to fragment world position
let frag_world = in.clip_position.xy / scene.scale_factor;
let to_fireball = scene.fireball_pos - frag_world;
let fireball_dist = length(to_fireball);
// Normalize; default to top-left if fireball is at origin (uninitialized)
let light_dir = select(normalize(vec2<f32>(-0.6, -0.8)), normalize(to_fireball + vec2<f32>(1e-5, 1e-5)), fireball_dist > 1.0);
// Specular intensity falls off with distance (closer fireball = brighter spec)
let fireball_intensity = 2.5 * clamp(300.0 / (fireball_dist + 100.0), 0.0, 1.0);
let spec = ggx_specular(light_dir, view_dir, lens_normal, 0.15, fireball_intensity);
let specular_contribution = spec * vec3<f32>(1.0, 0.98, 0.95) * (1.0 - fresnel * 0.5);
// ─── Section 10: Final Composition ───────────────────────────────────────
// Start with refracted backdrop, apply adaptive tint and SSS
var final_rgb = refracted * adaptive_tint * sss_tint;
// Add subtle brightness variation
final_rgb += (brightness * 0.15) * flicker;
// Add rim lighting
final_rgb += rim_light * vec3<f32>(0.9, 1.1, 1.3);
// Add edge smear and crystalline highlight
final_rgb += smear_contribution + crystal_edge;
// Add GGX specular highlight
final_rgb += specular_contribution * smoothstep(0.0, 0.4, 1.0 - lens_dist);
// Apply SDF anti-aliasing to glass alpha
let glass_alpha = color.a * (1.0 - smoothstep(-fw, fw, d_sdf));
// Modulate glass effect by per-instance glass_intensity.
// intensity=0 -> simple transparent fill (no refraction/blur/rim/spec)
// intensity=1 -> full glass effect
final_rgb = mix(color.rgb, final_rgb, gi);
let final_alpha = mix(color.a * 0.3, glass_alpha, gi);
color = vec4<f32>(final_rgb, final_alpha);
if color.a <= 0.0 { discard; }
return color;
}