viewport-lib 0.2.1

3D viewport rendering library
Documentation
// contact_shadow.wgsl — screen-space ray march for contact shadows.
// Fullscreen triangle (no vertex buffer), same pattern as SSAO/tone_map.
//
// Input: depth buffer + uniform (inv_proj, proj, light_dir_view, params).
// Output: R8Unorm single-channel (1.0 = lit, 0.0 = shadowed).

struct ContactShadowUniform {
    inv_proj:       mat4x4<f32>,   // NDC -> view
    proj:           mat4x4<f32>,   // view -> clip
    light_dir_view: vec4<f32>,     // xyz = light direction in view space
    world_up_view:  vec4<f32>,     // xyz = world up transformed into view space
    params:         vec4<f32>,     // x=max_distance, y=steps, z=thickness
};

@group(0) @binding(0) var depth_texture: texture_depth_2d;
@group(0) @binding(1) var depth_sampler: sampler;
@group(0) @binding(2) var<uniform> params: ContactShadowUniform;

struct VertexOutput {
    @builtin(position) pos: vec4<f32>,
    @location(0)       uv:  vec2<f32>,
}

@vertex
fn vs_main(@builtin(vertex_index) vi: u32) -> VertexOutput {
    let positions = array<vec2<f32>, 3>(
        vec2<f32>(-1.0, -1.0),
        vec2<f32>( 3.0, -1.0),
        vec2<f32>(-1.0,  3.0),
    );
    let p = positions[vi];
    let uv = vec2<f32>((p.x + 1.0) * 0.5, (1.0 - p.y) * 0.5);
    return VertexOutput(vec4<f32>(p, 0.0, 1.0), uv);
}

// Reconstruct view-space position from depth buffer at given UV.
fn view_pos_from_depth(uv: vec2<f32>, depth: f32) -> vec3<f32> {
    // UV -> NDC.
    let ndc = vec4<f32>(uv.x * 2.0 - 1.0, (1.0 - uv.y) * 2.0 - 1.0, depth, 1.0);
    let view_h = params.inv_proj * ndc;
    return view_h.xyz / view_h.w;
}

@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
    let frag_depth = textureSample(depth_texture, depth_sampler, in.uv);

    // Skip sky fragments (depth ≈ 1.0).
    if frag_depth >= 0.9999 {
        return vec4<f32>(1.0);
    }

    let view_pos = view_pos_from_depth(in.uv, frag_depth);
    var view_normal = normalize(cross(dpdx(view_pos), dpdy(view_pos)));
    let view_to_camera = normalize(-view_pos);
    if dot(view_normal, view_to_camera) < 0.0 {
        view_normal = -view_normal;
    }

    // Ray march from fragment toward light in view space.
    let ray_dir = normalize(params.light_dir_view.xyz);
    let step_size = params.params.x / params.params.y;
    let n_dot_l = dot(view_normal, ray_dir);
    let up_alignment = dot(view_normal, normalize(params.world_up_view.xyz));

    // Restrict contact shadows to ground-like upward-facing receivers.
    // This avoids the inset bands on object side faces in the showcase.
    let receiver_weight = smoothstep(0.75, 0.92, up_alignment);
    if receiver_weight <= 0.001 {
        return vec4<f32>(1.0);
    }

    // Offset the ray origin slightly toward the light along the receiver normal
    // so the first few steps do not immediately re-hit the same visible surface.
    let origin_sign = select(-1.0, 1.0, n_dot_l >= 0.0);
    let origin = view_pos + view_normal * (origin_sign * step_size * 1.5);

    // Use a stable starting offset instead of per-pixel jitter. The previous
    // dither removed banding, but in this showcase it read as sandpaper-like
    // surface noise because the contact shadow buffer is applied directly.
    let bias = 2.0;

    for (var i = 1u; i <= u32(params.params.y); i++) {
        let march_pos = origin + ray_dir * ((f32(i) + bias) * step_size);

        // Project march position back to screen space.
        let clip = params.proj * vec4<f32>(march_pos, 1.0);
        let ndc = clip.xyz / clip.w;
        let sample_uv = vec2<f32>(ndc.x * 0.5 + 0.5, (1.0 - ndc.y) * 0.5);

        // Out-of-screen check.
        if sample_uv.x < 0.0 || sample_uv.x > 1.0 || sample_uv.y < 0.0 || sample_uv.y > 1.0 {
            break;
        }

        let sample_depth = textureSample(depth_texture, depth_sampler, sample_uv);
        let sample_view_pos = view_pos_from_depth(sample_uv, sample_depth);

        // Self-shadow guard: the occluder must be meaningfully closer to the
        // camera than the *fragment* itself, not just the march position.
        // On convex surfaces (spheres), the march moves deeper each step while
        // the depth buffer still shows the same surface at neighbouring UVs —
        // depth_diff vs march_pos grows positive even though no real occluder
        // exists.  Requiring the sample to be > 0.05 m shallower than view_pos
        // rejects same-surface hits while keeping valid contact shadows (where
        // the caster is a genuinely different, closer object).
        if sample_view_pos.z - view_pos.z < 0.10 {
            continue;
        }

        // Check if the ray is behind the depth buffer surface within thickness.
        // In RH view space, visible geometry has negative Z. "Behind" a surface
        // means march_pos has more-negative Z than the surface, so the surface Z
        // is greater (less negative) than the march Z -> depth_diff > 0 when occluded.
        // Require a minimum depth penetration (half a step) to reject grazing self-hits.
        let depth_diff = sample_view_pos.z - march_pos.z;
        let min_depth = max(step_size * 1.0, 0.015);
        if depth_diff > min_depth && depth_diff < params.params.z {
            // Occluded — shadowed.
            // Fade based on step distance and penetration depth to avoid hard
            // binary hits turning into visible grain on gently curved surfaces.
            let fade = 1.0 - (f32(i) / params.params.y);
            let penetration = clamp(
                (depth_diff - min_depth) / max(params.params.z - min_depth, 0.0001),
                0.0,
                1.0,
            );
            let shadow = 1.0 - fade * penetration * 0.6;
            return vec4<f32>(mix(1.0, shadow, receiver_weight));
        }
    }

    return vec4<f32>(1.0);
}