bevy-aqua-query 0.1.2

Aqua gameplay probes: GPU-sampled water displacement and normals with async readback.
// Samples the rendered displacement field at gameplay probe positions.
// LOD selection and cascade blending match the vertex path.

#import bevy_aqua_core::river::{river_analytic_displacement}

const LOD_COUNT: u32 = 5u;
const CASCADE_COUNT: u32 = LOD_COUNT + 1u;
const WAVE_COUNT: u32 = 40u;
const MAX_QUERIES: u32 = 256u;
const LOD_TRANSITION_START: f32 = 1.0;
const MORPH_BLACK_POINT: f32 = 0.05;
const MORPH_FADE_SIDES: f32 = 2.0;
const UV_CENTER: f32 = 0.5;
const MIN_SAMPLE_WEIGHT: f32 = 0.001;
const MIN_NORMAL_Y: f32 = 0.0001;

struct CascadeParams {
    center: vec2<f32>,
    scale: f32,
    texture_res: f32,
    inv_texture_res: f32,
    texel_width: f32,
    weight: f32,
    max_wavelength: f32,
}
struct CascadeLayout {
    cascades: array<CascadeParams, CASCADE_COUNT>,
    center: vec4<f32>,
    // XY bed-map first-texel world origin, ZW inverse world extent.
    bed_transform: vec4<f32>,
    // X height minimum, Y height span (negative = no bed map), Z sea level.
    bed_range: vec4<f32>,
}
struct GerstnerWave {
    direction: vec2<f32>,
    amplitude: f32,
    wave_number: f32,
    angular_frequency: f32,
    phase: f32,
    chop_amplitude: f32,
}
struct AnimWavesUniform {
    cascade_layout: CascadeLayout,
    waves: array<GerstnerWave, WAVE_COUNT>,
    ranges: array<vec4<u32>, LOD_COUNT>,
    time: vec4<f32>,
    flow: vec4<f32>,
}

// `flow`: xy current (m/s), z signed bank margin (m), w channel half-width
// (m). Zero current selects cascade sampling.
struct QueryRequest {
    world_xz: vec2<f32>,
    slot: f32,
    kind: f32,
    flow: vec4<f32>,
}

struct QueryResult {
    displacement_slot: vec4<f32>,
    normal_validity: vec4<f32>,
    signals: vec4<f32>,
}

@group(0) @binding(0) var lod_data: texture_2d_array<f32>;
@group(0) @binding(1) var lod_sampler: sampler;
@group(0) @binding(2) var<uniform> params: AnimWavesUniform;
@group(0) @binding(3) var<storage, read> requests: array<QueryRequest>;
@group(0) @binding(4) var<storage, read_write> results: array<QueryResult>;

fn lod_count() -> u32 {
    return u32(params.cascade_layout.center.w);
}

fn lod_alpha(world_xz: vec2<f32>, cascade: CascadeParams) -> f32 {
    let offset = abs(world_xz - params.cascade_layout.center.xy);
    // Chebyshev distance matches the square LOD rings; Euclidean `length` is wrong here.
    let chebyshev_distance = max(offset.x, offset.y);
    let raw_alpha = chebyshev_distance / cascade.scale - LOD_TRANSITION_START;
    let black_point = MORPH_BLACK_POINT;
    let fade_width = 1.0 - MORPH_FADE_SIDES * black_point;
    return clamp((raw_alpha - black_point) / fade_width, 0.0, 1.0);
}

fn world_to_uv(world_xz: vec2<f32>, cascade: CascadeParams) -> vec2<f32> {
    let coverage = cascade.texel_width * cascade.texture_res;
    return (world_xz - cascade.center) / coverage + vec2(UV_CENTER);
}

fn sample_displacement(world_xz: vec2<f32>, lod: u32, alpha: f32) -> vec3<f32> {
    let smaller = params.cascade_layout.cascades[lod];
    let bigger = params.cascade_layout.cascades[lod + 1u];
    let smaller_weight = (1.0 - alpha) * smaller.weight;
    let bigger_weight = (1.0 - smaller_weight) * bigger.weight;
    var displacement = vec3(0.0);

    if smaller_weight > MIN_SAMPLE_WEIGHT {
        let uv = world_to_uv(world_xz, smaller);
        displacement += smaller_weight
            * textureSampleLevel(lod_data, lod_sampler, uv, i32(lod), 0.0).xyz;
    }
    if bigger_weight > MIN_SAMPLE_WEIGHT {
        let uv = world_to_uv(world_xz, bigger);
        displacement += bigger_weight
            * textureSampleLevel(lod_data, lod_sampler, uv, i32(lod + 1u), 0.0).xyz;
    }
    return displacement;
}

fn select_lod(world_xz: vec2<f32>) -> u32 {
    // Use square rings; beyond the coarsest ring its sentinel fades to zero.
    for (var lod = 0u; lod < lod_count(); lod += 1u) {
        let cascade = params.cascade_layout.cascades[lod];
        let offset = abs(world_xz - cascade.center);
        if max(offset.x, offset.y) < cascade.scale {
            return lod;
        }
    }
    return lod_count() - 1u;
}

fn river_surface(world_xz: vec2<f32>, request: QueryRequest) -> vec3<f32> {
    return river_analytic_displacement(
        world_xz,
        request.flow,
        params.time.x,
    );
}

@compute @workgroup_size(64u)
fn sample(@builtin(global_invocation_id) id: vec3<u32>) {
    let index = id.x;
    let count = arrayLength(&requests);
    if index >= count || index >= MAX_QUERIES {
        return;
    }
    let request = requests[index];

    if request.flow.w > 0.0 {
        let analytic = river_surface(request.world_xz, request);
        // Central differences of the same closed form for the normal.
        let epsilon = 0.35;
        let height_x = river_analytic_displacement(
            request.world_xz + vec2(epsilon, 0.0),
            request.flow,
            params.time.x,
        ).x;
        let height_z = river_analytic_displacement(
            request.world_xz + vec2(0.0, epsilon),
            request.flow,
            params.time.x,
        ).x;
        var normal_cross = vec3(
            -(height_x - analytic.x) / epsilon,
            1.0,
            -(height_z - analytic.x) / epsilon,
        );
        normal_cross.y = max(normal_cross.y, MIN_NORMAL_Y);
        results[index] = QueryResult(
            vec4(vec3(0.0, analytic.x, 0.0), request.slot),
            vec4(normalize(normal_cross), 1.0),
            vec4(0.0),
        );
        return;
    }

    if request.kind > 0.5 {
        results[index] = QueryResult(
            vec4(0.0, 0.0, 0.0, request.slot),
            vec4(0.0, 1.0, 0.0, 1.0),
            vec4(0.0),
        );
        return;
    }

    // Mirror cascade.wgsl's advection so probes ride the same moving water
    // the camera sees; the shift is applied once before any wave sampling.
    let world_xz = request.world_xz - params.flow.xy * params.time.x;
    let detail_lod = clamp(params.cascade_layout.center.z, 0.0, f32(lod_count() - 1u));
    let minimum_lod = u32(floor(detail_lod));
    let detail_alpha = fract(detail_lod);
    var lod = select_lod(world_xz);
    var alpha = lod_alpha(world_xz, params.cascade_layout.cascades[lod]);
    if lod < minimum_lod {
        lod = minimum_lod;
        alpha = detail_alpha;
    } else if lod == minimum_lod {
        alpha = max(alpha, detail_alpha);
    }

    let displacement = sample_displacement(world_xz, lod, alpha);
    let texel_width = params.cascade_layout.cascades[lod].texel_width;
    let displacement_x = sample_displacement(
        world_xz + vec2(texel_width, 0.0),
        lod,
        alpha,
    );
    let displacement_z = sample_displacement(
        world_xz + vec2(0.0, texel_width),
        lod,
        alpha,
    );
    let tangent_x = vec3(texel_width, 0.0, 0.0) + displacement_x - displacement;
    let tangent_z = vec3(0.0, 0.0, texel_width) + displacement_z - displacement;
    var normal_cross = cross(tangent_z, tangent_x);
    normal_cross.y = max(normal_cross.y, MIN_NORMAL_Y);
    let normal = normalize(normal_cross);
    let horizontal_x = vec2(texel_width, 0.0)
        + displacement_x.xz - displacement.xz;
    let horizontal_z = vec2(0.0, texel_width)
        + displacement_z.xz - displacement.xz;
    let determinant = (horizontal_x.x * horizontal_z.y
        - horizontal_x.y * horizontal_z.x) / (texel_width * texel_width);
    let crest = clamp(0.55 - determinant, 0.0, 1.0);

    results[index] = QueryResult(
        vec4(displacement, request.slot),
        vec4(normal, 1.0),
        vec4(crest, 0.0, 0.0, 0.0),
    );
}