struct LightData {
position: vec4<f32>,
color: vec4<f32>,
direction: vec4<f32>,
params: vec4<f32>,
}
struct SceneUniforms {
view_proj: mat4x4<f32>,
camera_pos: vec4<f32>,
sun_direction: vec4<f32>,
sun_color: vec4<f32>,
lights: array<LightData, 10>,
light_view_proj: array<mat4x4<f32>, 4>,
cascade_splits: vec4<f32>,
camera_forward: vec4<f32>,
cascade_params: vec4<f32>,
num_lights: u32,
}
@group(0) @binding(0) var<uniform> scene: SceneUniforms;
@group(1) @binding(0) var t_depth: texture_2d<f32>;
@group(1) @binding(1) var s_depth: sampler;
@group(1) @binding(2) var t_thickness: texture_2d<f32>;
@group(1) @binding(3) var s_thickness: sampler;
@group(1) @binding(4) var t_opaque_bg: texture_2d<f32>;
struct VertexOutput {
@builtin(position) clip_position: vec4<f32>,
@location(0) uv: vec2<f32>,
}
@vertex
fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {
var out: VertexOutput;
let x = f32((vertex_index & 1u) << 2u);
let y = f32((vertex_index & 2u) << 1u);
out.uv = vec2<f32>(x * 0.5, y * 0.5);
out.clip_position = vec4<f32>(x - 1.0, 1.0 - y, 0.0, 1.0);
return out;
}
fn compute_normal(uv: vec2<f32>, depth: f32) -> vec3<f32> {
let size = textureDimensions(t_depth);
let texel = 1.0 / vec2<f32>(f32(size.x), f32(size.y));
// Central difference for smoother normals
let d_r = textureSampleLevel(t_depth, s_depth, uv + vec2<f32>(texel.x, 0.0), 0.0).x;
let d_l = textureSampleLevel(t_depth, s_depth, uv - vec2<f32>(texel.x, 0.0), 0.0).x;
let d_u = textureSampleLevel(t_depth, s_depth, uv + vec2<f32>(0.0, texel.y), 0.0).x;
let d_d = textureSampleLevel(t_depth, s_depth, uv - vec2<f32>(0.0, texel.y), 0.0).x;
let dx = (d_r - d_l) * 0.5;
let dy = (d_u - d_d) * 0.5;
// Normal strength. Reduced to prevent overly sharp edges on large tanks
let normal_strength = 150.0;
let N = normalize(vec3<f32>(-dx * normal_strength, dy * normal_strength, 1.0));
return N;
}
// Procedural Noise Functions for Water Surface
fn hash12(p: vec2<f32>) -> f32 {
var p3 = fract(vec3<f32>(p.xyx) * 0.1031);
p3 = p3 + dot(p3, p3.yzx + 33.33);
return fract((p3.x + p3.y) * p3.z);
}
fn noise(p: vec2<f32>) -> f32 {
let i = floor(p);
let f = fract(p);
let u = f * f * (3.0 - 2.0 * f);
return mix(
mix(hash12(i + vec2<f32>(0.0, 0.0)), hash12(i + vec2<f32>(1.0, 0.0)), u.x),
mix(hash12(i + vec2<f32>(0.0, 1.0)), hash12(i + vec2<f32>(1.0, 1.0)), u.x),
u.y
);
}
fn fbm(p: vec2<f32>) -> f32 {
var v = 0.0;
var a = 0.5;
var shift = vec2<f32>(100.0);
// Rotate to reduce axial bias
let cos_r = 0.87758; // cos(0.5)
let sin_r = 0.47942; // sin(0.5)
let rot = mat2x2<f32>(cos_r, sin_r, -sin_r, cos_r);
var p2 = p;
for (var i = 0; i < 4; i = i + 1) {
v += a * noise(p2);
p2 = rot * p2 * 2.0 + shift;
a *= 0.5;
}
return v;
}
// ── Voronoi Caustics (Underwater Light Refraction) ──
fn hash22(p: vec2<f32>) -> vec2<f32> {
let n = sin(dot(p, vec2<f32>(41.0, 289.0)));
return fract(vec2<f32>(262144.0, 32768.0) * n) * 0.8 + 0.1;
}
fn voronoi(p: vec2<f32>, time: f32) -> f32 {
let ip = floor(p);
let fp = fract(p);
var min_d = 1.0;
for (var y = -1; y <= 1; y = y + 1) {
for (var x = -1; x <= 1; x = x + 1) {
let n = vec2<f32>(f32(x), f32(y));
var rp = hash22(ip + n);
// Animate cell centers
rp = 0.5 + 0.5 * sin(time * 0.8 + 6.2831 * rp);
let d = length(n + rp - fp);
min_d = min(min_d, d);
}
}
return min_d;
}
fn caustic_pattern(uv: vec2<f32>, time: f32) -> f32 {
let scale = 10.0;
let c1 = voronoi(uv * scale + vec2<f32>(time * 0.3, time * 0.1), time);
let c2 = voronoi(uv * scale * 1.4 + vec2<f32>(-time * 0.2, time * 0.15), time * 1.3);
// Light concentrates where Voronoi edges overlap
return pow(1.0 - c1, 4.0) * pow(1.0 - c2, 4.0) * 8.0;
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let depth = textureSample(t_depth, s_depth, in.uv).x;
if (depth >= 1.0) {
discard;
}
let thickness = textureSample(t_thickness, s_thickness, in.uv).x;
let N = compute_normal(in.uv, depth);
// Light and View vectors
let L = normalize(-scene.sun_direction.xyz);
let V = vec3<f32>(0.0, 0.0, 1.0); // View vector in screen space approximation
let H = normalize(L + V);
// Beer-Lambert Volume Absorption (Thickness Tinting)
// Deep water is dark blue, shallow is light cyan
let deep_color = vec3<f32>(0.01, 0.1, 0.35);
let shallow_color = vec3<f32>(0.2, 0.7, 0.85);
let absorption_coeff = 2.5;
let transmittance = exp(-thickness * absorption_coeff);
// The base color of the fluid
let fluid_color = mix(deep_color, shallow_color, transmittance);
// Refraction
// Distort UV based on normal XY and thickness
let refraction_strength = 0.05 * clamp(thickness, 0.0, 1.0);
let distorted_uv = in.uv + N.xy * refraction_strength;
let bg_color = textureSample(t_opaque_bg, s_thickness, distorted_uv).rgb;
// Multiply background with our volume absorption color
// Add some self-illumination (ambient boost) so the fluid is visible even against dark backgrounds
var refracted_color = bg_color * fluid_color + fluid_color * 0.4;
// ── Underwater Caustics ──
// Project animated Voronoi caustic pattern onto refracted background
let time = scene.cascade_params.z;
let caustic_uv = distorted_uv + N.xy * 0.02; // Slightly offset by surface normal
let caustic = caustic_pattern(caustic_uv, time);
let caustic_color = vec3<f32>(0.5, 0.7, 1.0) * caustic * transmittance;
refracted_color += caustic_color * (1.0 - clamp(thickness * 5.0, 0.0, 0.8));
// Diffuse Lighting
let diff = max(dot(N, L), 0.0);
// Specular Highlight (Blinn-Phong) broken up by noise
let wave_uv = in.uv * 15.0 + N.xy * 2.0 + vec2<f32>(time * 0.3, time * 0.2);
let surface_noise = fbm(wave_uv);
let shininess = 150.0;
// Modulate normal with noise for sparkly water
let N_turbulent = normalize(N + vec3<f32>(surface_noise - 0.5, surface_noise - 0.5, 0.0) * 0.3);
let spec = pow(max(dot(N_turbulent, H), 0.0), shininess) * 4.0;
let specular_color = scene.sun_color.rgb * spec;
// Procedural Sky Reflection based on view normal
let R = reflect(-V, N);
// R.y maps to up/down in screen space
let reflection_factor = clamp(R.y * 0.5 + 0.5, 0.0, 1.0);
let sky_color = mix(vec3<f32>(0.1, 0.3, 0.6), vec3<f32>(0.8, 0.9, 1.0), reflection_factor);
// Schlick's Fresnel Approximation
let f0 = 0.08;
let cos_theta = max(dot(N, V), 0.0);
let fresnel = f0 + (1.0 - f0) * pow(1.0 - cos_theta, 5.0);
// Procedural Foam
// Foam forms where water is agitated (high noise) and edges (low thickness)
let foam_base = smoothstep(0.5, 0.8, surface_noise);
let edge_foam = smoothstep(0.8, 0.0, thickness); // more foam in thin areas
let foam_mask = clamp(foam_base * edge_foam * 2.0, 0.0, 1.0);
let foam_color = vec3<f32>(0.9, 0.95, 1.0);
// Combine lighting components
let ambient = 0.5;
// Final Lit Color
var lit_color = refracted_color + fluid_color * (diff * 0.4 + ambient);
lit_color += specular_color;
lit_color += sky_color * fresnel; // Beautiful sky reflection on edges
lit_color = mix(lit_color, foam_color, foam_mask); // Foam on top
// Alpha blending based on thickness and fresnel (edges are more opaque)
let alpha = clamp(thickness * 10.0 + fresnel, 0.0, 1.0);
return vec4<f32>(lit_color, alpha);
}