frustum_culling.slang

cross platform rendering playground

src/shaders/lib/frustum_culling.slang

5.52 KB
#pragma once

#include "shared.h"
#include "renderer_types.h"

struct Frustum {
    vec4 planes[6]; // left, right, top, bottom, near, far
};

void set_far_plane(inout Frustum frustum, vec3 camera_position, vec3 camera_forward, f32 max_draw_distance) {
    // point on the far plane
    vec3 point = camera_position + camera_forward * max_draw_distance;

    // plane normal points back toward the camera
    vec3 normal = -normalize(camera_forward);

    // plane equation: dot(n, x) + d = 0
    f32 d = -dot(normal, point);

    frustum.planes[5] = vec4(normal, d);
}

Frustum extract_frustum(mat4 view_proj) {
    Frustum f;

    mat4 rows = transpose(view_proj);

    f.planes[0] = rows[3] + rows[0]; // left
    f.planes[1] = rows[3] - rows[0]; // right
    f.planes[2] = rows[3] + rows[1]; // bottom
    f.planes[3] = rows[3] - rows[1]; // top
    f.planes[4] = rows[3] - rows[2]; // near (reverse-z)

    // placeholder, we'll replace this with our finite culling distance
    f.planes[5] = rows[2];

    for (uint i = 0; i < 6; ++i)
        f.planes[i] /= length(f.planes[i].xyz);

    return f;
}

bool sphere_visible_in_frustum(vec3 center, f32 radius, Frustum f) {
    for (int i = 0; i < 6; i++) {
        f32 dist = dot(f.planes[i].xyz, center) + f.planes[i].w;
        if (dist < -radius) {
            return false; // fully outside this plane
        }
    }
    return true; // At least partially visible
}

bool is_object_visible(SubmeshGPU part, TransformGPU transform, Frustum frustum) {
    vec4 sphere_center_ws4 = vec4(part.local_sphere.xyz, 1.0f) * transform.world;
    vec3 sphere_center_ws = sphere_center_ws4.xyz / sphere_center_ws4.w;
    vec3 scale_axis =
        vec3(length(transform.world[0].xyz), length(transform.world[1].xyz), length(transform.world[2].xyz));
    f32 sphere_radius_ws = part.local_sphere.w * max(max(scale_axis.x, scale_axis.y), scale_axis.z);

    bool is_vis = sphere_visible_in_frustum(sphere_center_ws, sphere_radius_ws, frustum);
    return is_vis;
}

// true if the AABB is entirely behind the depth buffer at its screen projection,
// false if any part of the AABB is visible
bool is_object_occluded(
    vec3 local_aabb_min,
    vec3 local_aabb_max,
    TransformGPU transform,
    mat4 view_proj,
    Texture2D hiz_tex,
    u32 hiz_width,
    u32 hiz_height,
    u32 hiz_mip_count
) {
    vec3 corners[8] = {
        vec3(local_aabb_min.x, local_aabb_min.y, local_aabb_min.z),
        vec3(local_aabb_max.x, local_aabb_min.y, local_aabb_min.z),
        vec3(local_aabb_min.x, local_aabb_max.y, local_aabb_min.z),
        vec3(local_aabb_max.x, local_aabb_max.y, local_aabb_min.z),
        vec3(local_aabb_min.x, local_aabb_min.y, local_aabb_max.z),
        vec3(local_aabb_max.x, local_aabb_min.y, local_aabb_max.z),
        vec3(local_aabb_min.x, local_aabb_max.y, local_aabb_max.z),
        vec3(local_aabb_max.x, local_aabb_max.y, local_aabb_max.z),
    };

    vec2 uv_min = vec2(1e30f);
    vec2 uv_max = vec2(-1e30f);
    f32 nearest_depth = 0.0f;

    [unroll]
    for (u32 i = 0; i < 8; ++i) {
        vec4 clip = (vec4(corners[i], 1.0f) * transform.world) * view_proj;

        if (clip.w <= 0.0f)
            return false;

        vec3 ndc = clip.xyz / clip.w;
        vec2 uv = ndc.xy * vec2(0.5f, -0.5f) + vec2(0.5f);

        uv_min = min(uv_min, uv);
        uv_max = max(uv_max, uv);
        nearest_depth = max(nearest_depth, ndc.z);
    }

    if (uv_max.x < 0.0f || uv_max.y < 0.0f || uv_min.x > 1.0f || uv_min.y > 1.0f)
        return false;

    uv_min = clamp(uv_min, 0.0f.xx, 1.0f.xx);
    uv_max = clamp(uv_max, 0.0f.xx, 1.0f.xx);

    vec2 rect_px = (uv_max - uv_min) * vec2(hiz_width, hiz_height);
    f32 max_rect = max(rect_px.x, rect_px.y);
    // u32  mip      = min(u32(floor(log2(max(max_rect, 1.0f)))), hiz_mip_count - 1);
    // mip           = min(mip, hiz_mip_count - 1);
    const u32 MAX_HIZ_MIP = 5;
    // u32       mip         = min(u32(floor(log2(max(max_rect, 1.0f)))), min(hiz_mip_count - 1, MAX_HIZ_MIP));
    //
    // ceil(log2) ensures the projected rect fits within <=~2 texels per axis
    // at the selected mip, making 4-corner sampling conservative.
    // floor(log2) could let the rect span ~3 texels, allowing interior gaps
    // where a different texel's far depth gets missed.
    u32 mip = min(u32(ceil(log2(max(max_rect, 1.0f)))), min(hiz_mip_count - 1, MAX_HIZ_MIP));

    vec2 expand = 0.5f / vec2(max(hiz_width >> mip, 1u), max(hiz_height >> mip, 1u));
    uv_min = clamp(uv_min - expand, 0.0f.xx, 1.0f.xx);
    uv_max = clamp(uv_max + expand, 0.0f.xx, 1.0f.xx);

    uint w, h;
    hiz_tex.GetDimensions(w, h);

    uint mipW = max(1u, (w + (1u << mip) - 1u) >> mip);
    uint mipH = max(1u, (h + (1u << mip) - 1u) >> mip);

    uvec2 p0 = min(uvec2(uv_min * vec2(mipW, mipH)), uvec2(mipW - 1, mipH - 1));
    uvec2 p1 = min(uvec2(vec2(uv_max.x, uv_min.y) * vec2(mipW, mipH)), uvec2(mipW - 1, mipH - 1));
    uvec2 p2 = min(uvec2(vec2(uv_min.x, uv_max.y) * vec2(mipW, mipH)), uvec2(mipW - 1, mipH - 1));
    uvec2 p3 = min(uvec2(uv_max * vec2(mipW, mipH)), uvec2(mipW - 1, mipH - 1));

    f32 d0 = hiz_tex.Load(ivec3(p0, mip)).r;
    f32 d1 = hiz_tex.Load(ivec3(p1, mip)).r;
    f32 d2 = hiz_tex.Load(ivec3(p2, mip)).r;
    f32 d3 = hiz_tex.Load(ivec3(p3, mip)).r;

    f32 hiz_depth = min(min(d0, d1), min(d2, d3));
    f32 bias = 0.1; // / 0.0001;  // TODO: 0.1 is the near plane

    return nearest_depth + bias < hiz_depth;
}

bool sphere_intersects_aabb(vec3 center, f32 radius, vec3 bmin, vec3 bmax) {
    vec3 closest = clamp(center, bmin, bmax);
    vec3 d = center - closest;
    return dot(d, d) <= radius * radius;
}