viewport-lib-terrain 1.0.0

Heightmap and splatmap terrain rendering for viewport-lib
Documentation
// Terrain vertex + fragment entries. Concatenated with viewport-lib's
// SHARED_BINDINGS_WGSL, SHARED_PBR_WGSL, and SHARED_MASK_WGSL at runtime.

struct TerrainLayer {
    albedo:      vec3<f32>,
    metallic:    f32,
    roughness:   f32,
    height_bias: f32,
    _pad0:       f32,
    _pad1:       f32,
};

struct TerrainObject {
    model:                     mat4x4<f32>,
    layers:                    array<TerrainLayer, 8>,
    height_blend_strength:     f32,
    height_blend_noise_scale:  f32,
    _pad0:                     f32,
    _pad1:                     f32,
};

@group(1) @binding(0) var<uniform> obj:            TerrainObject;
@group(1) @binding(1) var          splatmap_a_tex: texture_2d<f32>;
@group(1) @binding(2) var          splatmap_b_tex: texture_2d<f32>;
@group(1) @binding(3) var          splatmap_samp:  sampler;

struct VsIn {
    @location(0) position: vec3<f32>,
    @location(1) normal:   vec3<f32>,
    @location(2) uv:       vec2<f32>,
};

struct VsOut {
    @builtin(position) clip_pos:     vec4<f32>,
    @location(0)       world_pos:    vec3<f32>,
    @location(1)       world_normal: vec3<f32>,
    @location(2)       uv:           vec2<f32>,
};

@vertex
fn vs_main(in: VsIn) -> VsOut {
    let world = obj.model * vec4<f32>(in.position, 1.0);
    let n_world = normalize((obj.model * vec4<f32>(in.normal, 0.0)).xyz);
    var out: VsOut;
    out.clip_pos     = camera.view_proj * world;
    out.world_pos    = world.xyz;
    out.world_normal = n_world;
    out.uv           = in.uv;
    return out;
}

// Cheap 2D value noise used to inject per-pixel height variation so the
// height-blend produces irregular, pebble-edged transitions instead of
// straight lines along splatmap boundaries.
fn hash21(p: vec2<f32>) -> f32 {
    var q = fract(p * vec2<f32>(123.34, 456.21));
    q = q + dot(q, q + 45.32);
    return fract(q.x * q.y);
}

fn value_noise(p: vec2<f32>) -> f32 {
    let i = floor(p);
    let f = fract(p);
    let u = f * f * (3.0 - 2.0 * f);
    let a = hash21(i);
    let b = hash21(i + vec2<f32>(1.0, 0.0));
    let c = hash21(i + vec2<f32>(0.0, 1.0));
    let d = hash21(i + vec2<f32>(1.0, 1.0));
    return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}

@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
    if !viewport_clip_test(in.world_pos) { discard; }

    let view_dir = normalize(camera.eye_pos - in.world_pos);
    let n_raw    = normalize(in.world_normal);
    let n_lit    = select(n_raw, -n_raw, dot(n_raw, view_dir) < 0.0);

    // Sample both splatmaps into eight per-layer weights.
    let s0 = textureSample(splatmap_a_tex, splatmap_samp, in.uv);
    let s1 = textureSample(splatmap_b_tex, splatmap_samp, in.uv);
    var w: array<f32, 8> = array<f32, 8>(
        s0.r, s0.g, s0.b, s0.a,
        s1.r, s1.g, s1.b, s1.a,
    );

    // Normalise to a unit sum. Fall back to layer 0 if everything is
    // zero so unpainted pixels still shade.
    var total = 0.0;
    for (var i = 0u; i < 8u; i = i + 1u) {
        total = total + w[i];
    }
    if total < 1e-5 {
        w[0] = 1.0;
        total = 1.0;
    }
    for (var i = 0u; i < 8u; i = i + 1u) {
        w[i] = w[i] / total;
    }

    // Optional height-blend: softmax the per-layer (height_bias + noise)
    // with `height_blend_strength` as temperature. Larger strength
    // sharpens the winning layer's region.
    if obj.height_blend_strength > 0.0 {
        let n = value_noise(in.uv * obj.height_blend_noise_scale);
        var max_h: f32 = -1e9;
        for (var i = 0u; i < 8u; i = i + 1u) {
            if w[i] > 0.0 {
                let h = obj.layers[i].height_bias + n;
                if h > max_h { max_h = h; }
            }
        }
        var sum_h = 0.0;
        for (var i = 0u; i < 8u; i = i + 1u) {
            let h = obj.layers[i].height_bias + n;
            let v = w[i] * exp((h - max_h) * obj.height_blend_strength);
            w[i] = v;
            sum_h = sum_h + v;
        }
        let inv = 1.0 / max(sum_h, 1e-6);
        for (var i = 0u; i < 8u; i = i + 1u) {
            w[i] = w[i] * inv;
        }
    }

    var albedo    = vec3<f32>(0.0);
    var metallic  = 0.0;
    var roughness = 0.0;
    for (var i = 0u; i < 8u; i = i + 1u) {
        let l = obj.layers[i];
        albedo    = albedo    + l.albedo    * w[i];
        metallic  = metallic  + l.metallic  * w[i];
        roughness = roughness + l.roughness * w[i];
    }

    var inputs: PbrInputs;
    inputs.world_pos = in.world_pos;
    inputs.world_n   = n_lit;
    inputs.view_dir  = view_dir;
    inputs.albedo    = albedo;
    inputs.metallic  = metallic;
    inputs.roughness = roughness;
    inputs.ao        = 1.0;
    inputs.emissive  = vec3<f32>(0.0);

    let lit = viewport_pbr_shade(inputs);
    let shadow = viewport_sample_csm(in.world_pos, n_lit);
    return vec4<f32>(lit * shadow, 1.0);
}

// Outline-mask vertex stage. Fragment uses viewport_mask_fs from
// SHARED_MASK_WGSL (constant 1.0 into the R8 mask).
@vertex
fn vs_mask(in: VsIn) -> @builtin(position) vec4<f32> {
    let world = obj.model * vec4<f32>(in.position, 1.0);
    return camera.view_proj * world;
}