Waterpool

Wade through an endless tiled pool while physical beach balls float on its waves. Reflections, refraction, caustics, bloom, and adaptive exposure build the damp underground atmosphere.

Click the canvas to enter. Use WASD to move, hold Shift to run, and press Escape to release the mouse.


    
main.lua
-- Waterpool: streamed 4 x 4 metre sectors, a physical FPS player and beach
-- balls floating on the pool by Archimedes rather than by animation.
-- Click: capture mouse | WASD: walk | Shift: run | Escape: release mouse

local natiny = require("natiny")
local demo = require("common.modules.demo")

local BG     = {  21 / 255,  30 / 255,  44 / 255 } -- #151E2C
local ACCENT = { 248 / 255, 249 / 255, 253 / 255 } -- #F8F9FD

-- ── Tuning ───────────────────────────────────────────────────────────────
-- Colours linear, distances metres, times seconds. Nothing below invents a
-- constant, it only spends these.

local WORLD = {
    sector_size = 4.0,
    view_radius = 3,           -- sectors alive each way, so a 7x7 grid
}

local PLAYER = {
    radius = 0.32,
    height = 1.70,
    eye_offset = 0.62,         -- eye above the capsule centre
    walk_speed = 0.95,         -- wading pace, the water is at the hip
    run_speed = 1.70,          -- the same walk, lunging
    acceleration = 2.2,        -- what the legs add
    deceleration = 5.0,        -- what the water takes back
    look_sensitivity = 0.14,
    look_smoothing = 0.05,     -- seconds for the view to catch the mouse
    stride = 1.35,             -- two steps, cadence is speed over this
    bob_vertical = 0.022,      -- head drops per footfall, twice a stride
    bob_lateral = 0.030,       -- weight shifts, once a stride
    bob_roll = 0.7,            -- degrees, towards the stance leg
    bob_max = 2.0,             -- cap on how far speed scales the three
    breath_height = 0.006,     -- standing still is not standing still
    breath_period = 4.2,
}

local CAMERA = {
    fov = 70.0,
    near = 0.04,
    far = 32.0,                -- past this the haze has taken everything
}

local LAMP = {
    height = 0.5,             -- below the eye, so the tiling is lit grazing
    bulb_radius = 0.005,       -- a real bulb, the highlight depends on it
    color = { 1.0, 0.96, 0.91 },
    intensity = 10.0,
    reach = 100.0,               -- value and slope both reach zero here
}

local HALL = {
    sky_color = { 0.60, 0.70, 0.64 },
    sky_level = 0.055,         -- ambient, barely: it only lifts pure black
    fog_color = { 0.52, 0.63, 0.55 },
    fog_density = 0.10,        -- extinction, ends at black
    fog_glow = 0.22,           -- air handing the lamp's light back
    fog_reach = 0.30,          -- damped by distance, so murk near, dark far
    normal_strength = 1.6,
    roughness_scale = 1.15,
}

local WATER = {
    level = 0.95,              -- waist deep, so the eye only looks down on it
    density = 1000.0,          -- weighed against BALL.density, nothing else
    absorb = { 1.30, 0.30, 0.55 },  -- per metre; this is the pool's colour
    tint_depth = 0.5,         -- where the tint stops darkening
    scatter = { 0.020, 0.100, 0.070 },
    fill = 0.128,              -- light in the water to be scattered
    roughness = 0.0,           -- the reflection is a real image, keep it
    reflect_level = 0.95,
    wave_height = 1.0,        -- multiplies WAVES, all of it millimetres
    wave_scale = 1.55,         -- multiplies WAVES frequencies
    refraction = 0.5,          -- how far ripples drag what is under them
    reflect_distortion = 0.30,
    softening = 0.005,         -- blur per metre of water in the way
}

-- The five swells pool_water.nsl shades with, mirrored here so the balls ride
-- the surface that is drawn rather than a plane near it.
-- Direction x, direction z, frequency, speed, amplitude in metres.
local WAVES = {
    {  1.00,  0.00,  1.6, 0.26, 0.01400 },
    {  0.60,  0.80,  3.1, 0.38, 0.00800 },
    { -0.80,  0.60,  6.3, 0.55, 0.00350 },
    {  0.32, -0.95, 11.7, 0.78, 0.00150 },
    {  0.90,  0.44, 19.3, 1.15, 0.00065 },
}

local BALL = {
    radius = 0.1995,           -- the 40cm file, centred on its own origin
    min_per_sector = 0,        -- zero is a real outcome and is meant to be
    max_per_sector = 5,
    density = 210.0,           -- versus water: sets draught, and the mass
    friction = 0.35,
    restitution = 0.45,        -- inflated, though the water eats the bounce
    drag_coefficient = 0.47,   -- a sphere's, on BALL_SECTION
    drag_linear = 6.0,         -- so a nudge does not creep forever
    heave_damping = 0.40,      -- ratio: waves carry the bobbing away
    drop_height = 0.12,        -- dropped in from just above its draught
    drop_drift = 0.35,
    wall_clearance = 0.30,     -- a spawn point needs this much open water
    placement_tries = 8,
    probe_depth = 0.30,        -- floor this far down means water, not wall
    spawn_budget = 6,          -- balls created per frame, at most
    normal_strength = 1.0,     -- plastic: shallower relief than grout
    roughness_scale = 0.90,
}

local CAUSTICS = {
    strength = 0.75,           -- how far the web pushes the lighting
    softness = 0.35,           -- the light has a size, so folds are finite
    scale = 1.0,
    gain = 0.35,               -- the honest fudge: ripples this shallow
}                              -- could not focus inside 85cm of water

local AMBIENCE = {
    volume = 0.55,             -- under the room; heard, not listened to
}

local EFFECTS = {
    rate = 6.0,               -- a floor, not an average: one per slot
    near_distance = 3.5,       -- inside the lamp, where you can see nothing
    far_distance = 8.0,        -- outside it, where you cannot see at all
    height_low = -0.10,        -- on the water, so the ear places it low
    height_high = 0.50,
    volume = 1.0,
    reference_distance = 2.5,  -- where the inverse law is referenced from
    voices = 8,                -- clips do overlap at this rate
}

local WALK = {
    volume = 0.75,             -- at walking pace
    volume_max = 0.95,         -- running is louder on its own
    level_curve = 0.60,        -- under one: standing to walking is the step
    min_speed = 0.15,          -- below this a nudge is not a stride
    attack = 0.07,             -- seconds to close most of the gap up
    release = 0.25,            -- seconds to reach actual silence
    pitch_speed = 0.12,        -- running is a shorter, higher splash
    detune = 0.05,             -- per head, or the copies comb-filter
    heads = 3,                 -- fewest that can hand the sound across
    hold_min = 1.3,            -- seconds a head plays before it jumps
    hold_max = 2.4,
}

local GRADE = {
    exposure_key = 0.15,       -- middle grey
    exposure_min_stop = -2.4,  -- the floor that makes darkness possible
    exposure_max_stop = 0.4,
    adapt_brighten = 0.45,     -- the eye stops down quickly
    adapt_darken = 1.0,        -- and opens up slowly
    -- The grade, as an ASC CDL in the log domain, per channel. Offset lifts
    -- the shadows, power bends the midtones, slope multiplies throughout - so
    -- cool shadows against warm highlights is the three disagreeing about
    -- which way to lean, and no one of them can say it alone.
    look_contrast = 1.25,      -- steepness about middle grey, which it pivots on
    look_slope = { 0.93, 1.06, 0.95 },       -- the wet green of the room
    look_offset = { -0.006, 0.000, 0.012 },  -- shadows towards the water
    look_power = { 1.14, 1.18, 1.22 },       -- midtones, warm at the top
    look_saturation = 0.88,    -- under one, so the grade is not dragged up
    vignette = 0.60,
    vignette_start = 0.45,

    -- The lens. Barrel is the magnification the centre gains, the corner being
    -- held fixed, so a tenth is a wide angle rather than a peephole.
    barrel = 0.10,
    -- Transverse aberration: the scale red and blue disagree by at the corner,
    -- about five pixels apart at 1280 across, and none at all on the axis.
    chroma_dispersion = 0.03,
    chroma_edge = 1.0,         -- radius exponent, so the centre stays exact
    -- Axial aberration, in pixels: no plane is sharp in every colour at once,
    -- so an edge picks up colour on both sides instead of sliding sideways.
    -- This is the half that does not vanish in the middle of the frame.
    chroma_focus = 0.7,        -- on the axis
    chroma_focus_edge = 1.8,   -- what the corner adds
}

local BLOOM = {
    levels = 3,                -- off half res, so about a third of screen
    threshold = 0.05,           -- stops above what the eye has adapted to
    knee = 0.1,
    radius = 1.5,
    intensity = 0.55,
}

local METER = {
    ladder = { 128, 32, 8, 2, 1 },  -- /4 per rung, ending on the mean
}

-- ── Derived ──────────────────────────────────────────────────────────────

local GRID_WIDTH = WORLD.view_radius * 2 + 1
local CELL_COUNT = GRID_WIDTH * GRID_WIDTH
local PLAYER_Y = PLAYER.height * 0.5 + 0.05

-- What physics.init starts the world with; buoyancy has to agree with it.
local GRAVITY = 9.81

local BALL_DIAMETER = BALL.radius * 2.0
local BALL_VOLUME = 4.0 / 3.0 * math.pi * BALL.radius ^ 3
local BALL_SECTION = math.pi * BALL.radius * BALL.radius
local BALL_MASS = BALL.density * BALL_VOLUME

-- How deep a ball floats, solved rather than guessed: a cap of height h holds
-- pi*h^2*(3R - h)/3, and at rest that weighs the whole ball, so h/R is the
-- root of x^2*(3 - x)/4 = density ratio.
local BALL_DRAUGHT = (function()
    local ratio = math.min(BALL.density / WATER.density, 1.0)
    local low, high = 0.0, 2.0
    for _ = 1, 60 do
        local middle = (low + high) * 0.5
        if middle * middle * (3.0 - middle) * 0.25 < ratio then
            low = middle
        else
            high = middle
        end
    end
    return (low + high) * 0.5 * BALL.radius
end)()

-- A float is a spring: rho*g times the waterline area is its stiffness, and it
-- bobs at sqrt(k/m). A damping ratio of one is 2*sqrt(k*m), so spend it there.
local BALL_STIFFNESS = WATER.density * GRAVITY * math.pi
    * BALL_DRAUGHT * (BALL_DIAMETER - BALL_DRAUGHT)
local BALL_HEAVE_DAMPING = 2.0 * BALL.heave_damping
    * math.sqrt(BALL_STIFFNESS * BALL_MASS)

local EFFECT_SLOT = 60.0 / EFFECTS.rate   -- the window holding one sound

-- ── Boot ─────────────────────────────────────────────────────────────────

if not natiny.backend.init(arg and arg[1] or natiny.backend.AUTO) then
    os.exit(1)
end
if not natiny.physics.init() then
    os.exit(1)
end

-- Not worth refusing to run over: with no output device the pool plays in
-- silence and every audio call still keeps its handles. HRTF is the default
-- and is stated anyway, being why a sound behind reads as behind.
local audio_ready = natiny.audio.init()
natiny.audio.set_hrtf(true)

local _, backend_name = natiny.backend.get_current()
print("[waterpool] backend: " .. backend_name)
print("[waterpool] audio: " .. (audio_ready and "on" or "no output device"))

local window = natiny.window.create("Natiny - Waterpool", 1280, 720)
natiny.window.set_scale_mode(window, natiny.window.SCALE_MODE_STRETCH)

math.randomseed(os.time())

-- ── Loading ──────────────────────────────────────────────────────────────
-- Twenty eight files, which on a link rather than a disk is a wait worth
-- admitting to. The shell fetches them a few at a time and holds the window
-- while it does; the three build steps further down run when the last one is
-- in, and nothing before then touches a file.

-- Everything the maze is built out of sits together, and the names are long.
local MODELS = "data/examples/waterpool/models/"
local TEXTURES = "data/examples/waterpool/textures/"
local SHADERS = "data/examples/waterpool/shaders/"
local AUDIO = "data/examples/waterpool/audio/"

local ASSETS = {
    SHADERS .. "waterpool.nsl",
    SHADERS .. "pool_water.nsl",
    SHADERS .. "luminance.nsl",
    SHADERS .. "adapt.nsl",
    SHADERS .. "agx.nsl",
    SHADERS .. "downsample.nsl",
    SHADERS .. "bloom_prefilter.nsl",
    SHADERS .. "bloom_down.nsl",
    SHADERS .. "bloom_up.nsl",

    TEXTURES .. "waterpool_tiles.jpg",
    TEXTURES .. "waterpool_tiles_normal.jpg",
    TEXTURES .. "waterpool_tiles_rough.jpg",
    TEXTURES .. "waterpool_ball_diffuse.jpg",
    TEXTURES .. "waterpool_ball_normal.jpg",
    TEXTURES .. "waterpool_ball_rough.jpg",

    MODELS .. "floor_ceil.glb",
    MODELS .. "waterpool_ball.glb",
    MODELS .. "segment_01.glb",
    MODELS .. "segment_02.glb",
    MODELS .. "segment_03.glb",
    MODELS .. "segment_04.glb",

    AUDIO .. "waterpool.ogg",
    AUDIO .. "waterpool_walk.ogg",
    AUDIO .. "waterpool_effect_01.ogg",
    AUDIO .. "waterpool_effect_02.ogg",
    AUDIO .. "waterpool_effect_03.ogg",
    AUDIO .. "waterpool_effect_04.ogg",
    AUDIO .. "waterpool_effect_05.ogg",
}

-- ── Materials ────────────────────────────────────────────────────────────
-- One forward shader for the maze and the balls in it, lit by the lamp the
-- player carries. Both tagged "waterpool", so every pass that draws the room
-- draws the balls. They differ only in textures and in how hard relief and
-- gloss are pushed, which is all a tiled wall and a plastic ball disagree
-- about. Lamp position and water plane are pushed per frame, not here.

local function hall_material(cull, textures, normal_strength, roughness_scale)
    local mat = natiny.material.create(
        natiny.shader.load(demo:resource(SHADERS .. "waterpool.nsl")), "waterpool")
    natiny.material.set_state(mat, {
        blend = natiny.material.BLEND_NONE,
        cull = cull,
    })
    for slot, path in pairs(textures) do
        natiny.material.set_texture(mat, slot,
                                    natiny.texture.create(demo:resource(path)))
    end

    natiny.material.set_constant(mat, "lamp_color",
        LAMP.color[1], LAMP.color[2], LAMP.color[3], LAMP.intensity)
    natiny.material.set_constant(mat, "lamp_range",
        LAMP.reach, 0.0, normal_strength, roughness_scale)
    natiny.material.set_constant(mat, "sky_color",
        HALL.sky_color[1], HALL.sky_color[2], HALL.sky_color[3], HALL.sky_level)
    natiny.material.set_constant(mat, "fog_color",
        HALL.fog_color[1], HALL.fog_color[2], HALL.fog_color[3],
        HALL.fog_density)
    natiny.material.set_constant(mat, "fog_light",
        HALL.fog_glow, HALL.fog_reach, 0, 0)
    natiny.material.set_constant(mat, "caustic_wave",
        CAUSTICS.scale, CAUSTICS.gain, 0, 0)
    return mat
end

local material, ball_material, hall_materials, water_material
local water_mesh, floor_mesh, ball_mesh, segments

local function build_world()
    -- Pool meshes are open, so nothing may be culled; a beach ball is closed, so
    -- its back faces are work with no picture in them.
    material = hall_material(natiny.material.CULL_NONE, {
        albedo_map = TEXTURES .. "waterpool_tiles.jpg",
        normal_map = TEXTURES .. "waterpool_tiles_normal.jpg",
        roughness_map = TEXTURES .. "waterpool_tiles_rough.jpg",
    }, HALL.normal_strength, HALL.roughness_scale)

    ball_material = hall_material(natiny.material.CULL_BACK, {
        albedo_map = TEXTURES .. "waterpool_ball_diffuse.jpg",
        normal_map = TEXTURES .. "waterpool_ball_normal.jpg",
        roughness_map = TEXTURES .. "waterpool_ball_rough.jpg",
    }, BALL.normal_strength, BALL.roughness_scale)

    -- Both want the lamp and the water plane every frame, stated once over this.
    hall_materials = { material, ball_material }

    -- The water is its own tag: the room must be finished and copied aside before
    -- the surface can read what is under it.
    water_material = natiny.material.create(
        natiny.shader.load(demo:resource(SHADERS .. "pool_water.nsl")), "water")
    natiny.material.set_state(water_material, {
        blend = natiny.material.BLEND_NONE,
        cull = natiny.material.CULL_NONE,
    })

    -- The w of water_absorb is the fallback depth where the room pass put nothing.
    natiny.material.set_constant(water_material, "water_absorb",
        WATER.absorb[1], WATER.absorb[2], WATER.absorb[3], WATER.level)
    natiny.material.set_constant(water_material, "water_scatter",
        WATER.scatter[1], WATER.scatter[2], WATER.scatter[3], WATER.roughness)
    natiny.material.set_constant(water_material, "water_optics",
        WATER.softening, WATER.reflect_distortion, WATER.tint_depth, 1.0)
    natiny.material.set_constant(water_material, "water_sky",
        HALL.sky_color[1] * WATER.fill, HALL.sky_color[2] * WATER.fill,
        HALL.sky_color[3] * WATER.fill, WATER.reflect_level)
    natiny.material.set_constant(water_material, "fog_light",
        HALL.fog_glow, HALL.fog_reach, 0, 0)
    natiny.material.set_constant(water_material, "fog_color",
        HALL.fog_color[1], HALL.fog_color[2], HALL.fog_color[3], HALL.fog_density)
    natiny.material.set_constant(water_material, "lamp_color",
        LAMP.color[1], LAMP.color[2], LAMP.color[3], LAMP.intensity)
    natiny.material.set_constant(water_material, "lamp_range", LAMP.reach, 0, 0, 0)

    -- One flat quad per sector. The waves are a function of world position, so
    -- neighbours agree along their shared edges.
    water_mesh = natiny.mesh.plane(WORLD.sector_size, WORLD.sector_size)
    floor_mesh = natiny.mesh.load(demo:resource(MODELS .. "floor_ceil.glb"))
    ball_mesh = natiny.mesh.load(demo:resource(MODELS .. "waterpool_ball.glb"))

    -- The shapes a sector can take. Whether one floats balls is a fact about the
    -- segment, so the streamer never has to know which it has.
    segments = {
        { mesh = natiny.mesh.load(demo:resource(MODELS .. "segment_01.glb")), balls = true },
        { mesh = natiny.mesh.load(demo:resource(MODELS .. "segment_02.glb")), balls = true },
        { mesh = natiny.mesh.load(demo:resource(MODELS .. "segment_03.glb")), balls = false },
        { mesh = natiny.mesh.load(demo:resource(MODELS .. "segment_04.glb")), balls = true },
    }
end

-- ── Water surface ────────────────────────────────────────────────────────

-- Where the water is and which way it leans, at one point. The slope comes
-- back as the two partial derivatives: a normal is made of them, and so is
-- the direction a ball rolls.
local function water_surface_at(x, z, elapsed)
    local height = 0.0
    local slope_x, slope_z = 0.0, 0.0

    for index = 1, #WAVES do
        local wave = WAVES[index]
        local frequency = wave[3] * WATER.wave_scale
        local phase = (x * wave[1] + z * wave[2]) * frequency
            + elapsed * wave[4]
        local slope = wave[5] * frequency * math.cos(phase)

        height = height + wave[5] * math.sin(phase)
        slope_x = slope_x + wave[1] * slope
        slope_z = slope_z + wave[2] * slope
    end

    return WATER.level + height * WATER.wave_height,
        slope_x * WATER.wave_height,
        slope_z * WATER.wave_height
end

-- ── Balls ────────────────────────────────────────────────────────────────
-- A sphere body the solver owns, a model parented to it, and Archimedes' force
-- handed over once a frame. Nothing here animates anything, which is why they
-- bump each other, get shoved, and come back up when pushed under.

local PROBE_DIRECTIONS = { { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } }

-- Two questions the maze answers without anything knowing how it was made.
-- Down: open water reaches the floor, the top of a wall stops near the
-- waterline. Then sideways, because a point can be over water and still inside
-- the thickness of a wall standing in it.
local function is_open_water(x, z)
    local start_y = WATER.level + 1.0
    local hit = natiny.physics.raycast(x, start_y, z, 0, -1, 0, start_y + 0.5)
    if not hit or hit.y > WATER.level - BALL.probe_depth then
        return false
    end

    local reach = BALL.radius + BALL.wall_clearance
    for index = 1, #PROBE_DIRECTIONS do
        local direction = PROBE_DIRECTIONS[index]
        if natiny.physics.raycast(x, WATER.level, z,
                direction[1], 0, direction[2], reach) then
            return false
        end
    end

    return true
end

-- One ball in the sector centred on the given position, or nil: a segment can
-- be almost entirely wall, so failing is normal.
local function spawn_ball(world_x, world_z)
    -- Kept off the boundary: the neighbour's walls are not visible from here.
    local reach = WORLD.sector_size * 0.5
        - (BALL.radius + BALL.wall_clearance)

    local x, z
    for _ = 1, BALL.placement_tries do
        local try_x = world_x + (math.random() * 2.0 - 1.0) * reach
        local try_z = world_z + (math.random() * 2.0 - 1.0) * reach
        if is_open_water(try_x, try_z) then
            x, z = try_x, try_z
            break
        end
    end
    if not x then
        return nil
    end

    local body = natiny.physics.sphere(BALL.radius)
    natiny.physics.set_material(body, BALL.friction, BALL.restitution,
        BALL.density)
    natiny.physics.set_interpolation(body, true)

    -- At its own resting draught plus a little: one small bob on arrival.
    natiny.entity.set_position(body, natiny.entity.SPACE_WORLD,
        x,
        WATER.level + BALL.radius - BALL_DRAUGHT
            + math.random() * BALL.drop_height,
        z)
    natiny.physics.set_velocity(body,
        (math.random() * 2.0 - 1.0) * BALL.drop_drift, 0.0,
        (math.random() * 2.0 - 1.0) * BALL.drop_drift)

    local model = natiny.model.create(ball_mesh, ball_material)
    natiny.entity.set_parent(model, body)

    return { body = body, model = model }
end

-- Model first: destroying a body frees the entity the model is parented to.
local function clear_balls(cell)
    local balls = cell.balls
    for index = #balls, 1, -1 do
        natiny.model.destroy(balls[index].model)
        natiny.physics.destroy(balls[index].body)
        balls[index] = nil
    end
end

-- One ball, one frame. Gravity is already pulling it down; everything the
-- water does is added here.
local function float_ball(ball, step, elapsed)
    local x, y, z = natiny.entity.get_position(
        ball.body, natiny.entity.SPACE_WORLD)
    local surface, slope_x, slope_z = water_surface_at(x, z, elapsed)

    -- The height of the cap the surface cuts off the sphere. A ball kicked
    -- clear of the pool is left in freefall.
    local submerged = surface - (y - BALL.radius)
    if submerged <= 0.0 then
        return
    end
    if submerged > BALL_DIAMETER then
        submerged = BALL_DIAMETER
    end

    -- Archimedes with no fudge in it, and this is what makes the whole thing
    -- behave: barely touching is barely pushed, at rest is pushed exactly its
    -- own weight, held under is pushed nearly five times that.
    local volume = math.pi * submerged * submerged
        * (3.0 * BALL.radius - submerged) / 3.0
    local force = WATER.density * GRAVITY * volume

    -- Along the surface normal, not straight up, which is what makes these
    -- read as floating rather than hovering: every ripple leans on the ball
    -- sideways, so a sector's worth drift, gather and separate. Normalised,
    -- because the damping below wants the direction too.
    local normal_length = math.sqrt(
        slope_x * slope_x + slope_z * slope_z + 1.0)
    local normal_x = -slope_x / normal_length
    local normal_y = 1.0 / normal_length
    local normal_z = -slope_z / normal_length

    -- Over the wetted fraction only, and light: a shoved beach ball travels.
    local wetted = volume / BALL_VOLUME
    local vx, vy, vz = natiny.physics.get_velocity(ball.body)
    local speed = math.sqrt(vx * vx + vy * vy + vz * vz)
    local drag = wetted * (BALL.drag_linear
        + 0.5 * WATER.density * BALL.drag_coefficient * BALL_SECTION * speed)

    -- And the damping only rising and falling feels. What stops a float
    -- heaving is not friction but that the motion makes waves and the waves
    -- leave with the energy - far stronger than anything viscous, and the
    -- difference between a ball that settles and one that pogos on the spot.
    -- It scales with the waterline area, that area being the ball's whole
    -- ability to make a wave: one held completely under has none, and is left
    -- to the drag above, which is why it comes out fast rather than politely.
    local waterline = submerged * (BALL_DIAMETER - submerged)
    local heave = BALL_HEAVE_DAMPING
        * (waterline / (BALL_DRAUGHT * (BALL_DIAMETER - BALL_DRAUGHT)))
        * (vx * normal_x + vy * normal_y + vz * normal_z)

    -- Force times time, an impulse being the only way to hand the solver a
    -- force. The step is the frame, so a second delivers the same momentum
    -- however fast the frames run.
    local along_normal = force / normal_length - heave
    natiny.physics.apply_impulse(ball.body,
        (normal_x * along_normal - vx * drag) * step,
        (normal_y * along_normal - vy * drag) * step,
        (normal_z * along_normal - vz * drag) * step)
end

-- ── Sectors ──────────────────────────────────────────────────────────────
-- The streamer owns exactly 49 cells, and crossing a boundary reuses only the
-- ones that left the visibility radius.

local cells = {}
local active_cells = {}
local centre_sector_x
local centre_sector_z

local function cell_key(x, z)
    return tostring(x) .. ":" .. tostring(z)
end

local function make_cell()
    local cell = {
        x = nil,
        z = nil,
        floor_model = natiny.model.create(floor_mesh, material),
        floor_body = natiny.physics.mesh(floor_mesh),
        segment_model = natiny.model.create(nil, material),
        segment_body = nil,
        water_model = natiny.model.create(water_mesh, water_material),
        balls = {},
        wanted_balls = 0,
    }
    cells[#cells + 1] = cell
    return cell
end

local function place_cell(cell, grid_x, grid_z)
    -- The starting sector is stable; every other one is random.
    local segment = (grid_x == 0 and grid_z == 0)
        and segments[1]
        or segments[math.random(#segments)]
    local mesh = segment.mesh
    local rotation = math.random(0, 3) * 90
    local world_x = grid_x * WORLD.sector_size
    local world_z = grid_z * WORLD.sector_size

    cell.x, cell.z = grid_x, grid_z

    -- floor_ceil moves with its cell but is never rotated.
    natiny.entity.set_position(cell.floor_model, natiny.entity.SPACE_WORLD,
        world_x, 0, world_z)
    natiny.entity.set_position(cell.floor_body, natiny.entity.SPACE_WORLD,
        world_x, 0, world_z)
    natiny.entity.set_position(cell.water_model, natiny.entity.SPACE_WORLD,
        world_x, WATER.level, world_z)

    natiny.model.set_mesh(cell.segment_model, mesh)
    natiny.entity.set_position(cell.segment_model, natiny.entity.SPACE_WORLD,
        world_x, 0, world_z)
    natiny.entity.set_rotation(cell.segment_model, natiny.entity.SPACE_WORLD,
        0, rotation, 0)

    if cell.segment_body then
        natiny.physics.destroy(cell.segment_body)
    end
    cell.segment_body = natiny.physics.mesh(mesh)
    natiny.entity.set_position(cell.segment_body, natiny.entity.SPACE_WORLD,
        world_x, 0, world_z)
    natiny.entity.set_rotation(cell.segment_body, natiny.entity.SPACE_WORLD,
        0, rotation, 0)

    -- The old balls go with the sector they belonged to, and that is the whole
    -- removal rule: a cell is only placed after leaving the visibility radius,
    -- so nothing is simulated outside the 7x7 the player sees.
    --
    -- The new ones are asked for here, not made: where a ball may float is a
    -- question the walls answer through the solver, which cannot be asked
    -- about a body it has not stepped yet - and the first frame's sectors are
    -- built before the world has run once. update_balls fills the count in.
    clear_balls(cell)
    cell.wanted_balls = segment.balls
        and math.random(BALL.min_per_sector, BALL.max_per_sector)
        or 0
end

local function stream_around(new_centre_x, new_centre_z)
    if new_centre_x == centre_sector_x and new_centre_z == centre_sector_z then
        return
    end

    local wanted = {}
    for z = new_centre_z - WORLD.view_radius, new_centre_z + WORLD.view_radius do
        for x = new_centre_x - WORLD.view_radius, new_centre_x + WORLD.view_radius do
            wanted[cell_key(x, z)] = { x = x, z = z }
        end
    end

    local next_active = {}
    local free = {}
    for key, cell in pairs(active_cells) do
        if wanted[key] then
            next_active[key] = cell
            wanted[key] = nil
        else
            free[#free + 1] = cell
        end
    end

    if not centre_sector_x then
        free = cells
    end

    local free_index = 1
    for key, coordinate in pairs(wanted) do
        local cell = free[free_index]
        free_index = free_index + 1
        place_cell(cell, coordinate.x, coordinate.z)
        next_active[key] = cell
    end

    active_cells = next_active
    centre_sector_x, centre_sector_z = new_centre_x, new_centre_z
    print(string.format("[waterpool] sector %d, %d",
        centre_sector_x, centre_sector_z))
end

local function sector_at(value)
    return math.floor((value + WORLD.sector_size * 0.5) / WORLD.sector_size)
end

local function update_balls(dt, elapsed)
    -- Buoyancy is a stiff spring, so a frame long enough to step it past its
    -- own period would throw a ball out of the pool. Capped, a hitch slows the
    -- balls down instead of launching them.
    local step = math.min(dt, 1.0 / 30.0)
    local budget = BALL.spawn_budget

    for _, cell in pairs(active_cells) do
        local balls = cell.balls

        -- What the sector asked for, made now that there is a stepped world
        -- to ask about the walls. A placement that cannot be found lowers the
        -- count rather than being retried forever.
        while budget > 0 and #balls < cell.wanted_balls do
            budget = budget - 1
            local ball = spawn_ball(cell.x * WORLD.sector_size,
                cell.z * WORLD.sector_size)
            if ball then
                balls[#balls + 1] = ball
            else
                cell.wanted_balls = cell.wanted_balls - 1
            end
        end

        for index = 1, #balls do
            float_ball(balls[index], step, elapsed)
        end
    end
end

-- ── Player ───────────────────────────────────────────────────────────────

local KEY_W = natiny.input.KEY_W
local KEY_A = natiny.input.KEY_A
local KEY_S = natiny.input.KEY_S
local KEY_D = natiny.input.KEY_D
local KEY_SHIFT = natiny.input.KEY_LEFT_SHIFT
local MOUSE_LEFT = natiny.input.MOUSE_BUTTON_LEFT

local player_x, player_z = 0.0, 0.0

-- The 49 cells are made once and then only moved, and the first move is this
-- one: it fills the grid around wherever the player starts.  Both need the
-- meshes, so both wait for them.
local function build_sectors()
    for _ = 1, CELL_COUNT do
        make_cell()
    end
    stream_around(sector_at(player_x), sector_at(player_z))
end

-- The body and the eye that rides it. Made in on_loaded rather than here:
-- the world is stepped every frame from the moment the program starts, the
-- loading screen included, and the floor is a body that only exists once the
-- meshes have landed. A capsule made now would spend the whole download
-- falling through an empty world and meet the floor from underneath.
local player, camera

local function build_player()
    player = natiny.physics.capsule(PLAYER.radius, PLAYER.height)
    natiny.entity.set_position(player, natiny.entity.SPACE_WORLD,
        player_x, PLAYER_Y, player_z)
    natiny.physics.set_material(player, 0.0, 0.0, 80.0)
    natiny.physics.set_fixed_rotation(player, true)
    natiny.physics.set_interpolation(player, true)

    camera = natiny.camera.create()
    natiny.camera.set_fov(camera, CAMERA.fov)
    natiny.camera.set_clip(camera, CAMERA.near, CAMERA.far)
    natiny.entity.set_parent(camera, player)
    natiny.entity.set_position(camera, natiny.entity.SPACE_LOCAL,
        0, PLAYER.eye_offset, 0)
end

-- The same view through the water plane. A horizontal mirror is the one case
-- where a planar reflection is exact, so the pool gets the ceiling and the far
-- end of the corridor with real parallax. Derived every frame, so unparented.
local reflect_camera = natiny.camera.create()
natiny.camera.set_fov(reflect_camera, CAMERA.fov)
natiny.camera.set_clip(reflect_camera, CAMERA.near, CAMERA.far)

-- Where the mouse pointed the view, and where the view has got to: two angles
-- so the second can lag the first. Raw mouse deltas are a stairstep.
local yaw_target, pitch_target = 0.0, 0.0
local yaw, pitch = 0.0, 0.0

-- In strides, advanced by distance rather than time, so the feet land where
-- they land however the speed varies.
local stride_phase = 0.0
local breath_phase = 0.0

local function update_look(dt, captured)
    if captured then
        yaw_target = yaw_target
            - natiny.input.get_mouse_x_speed() * PLAYER.look_sensitivity
        pitch_target = pitch_target
            - natiny.input.get_mouse_y_speed() * PLAYER.look_sensitivity
        pitch_target = math.max(-89.0, math.min(89.0, pitch_target))
    end

    -- The clamp stays on the target, so the limit is exactly 89 degrees and
    -- the follower never has to be told about it.
    local look_follow = math.min(dt / PLAYER.look_smoothing, 1.0)
    yaw = yaw + (yaw_target - yaw) * look_follow
    pitch = pitch + (pitch_target - pitch) * look_follow
end

-- Which way the keys are asking to go, in the yaw's frame, and how fast.
local function read_movement()
    local forward, side = 0.0, 0.0
    if natiny.input.get_key_down(KEY_W) then forward = forward + 1.0 end
    if natiny.input.get_key_down(KEY_S) then forward = forward - 1.0 end
    if natiny.input.get_key_down(KEY_D) then side = side + 1.0 end
    if natiny.input.get_key_down(KEY_A) then side = side - 1.0 end

    local input_length = math.sqrt(forward * forward + side * side)
    if input_length > 0 then
        forward = forward / input_length
        side = side / input_length
    end

    local radians = math.rad(yaw)
    local forward_x, forward_z = -math.sin(radians), -math.cos(radians)
    local right_x, right_z = math.cos(radians), -math.sin(radians)
    local speed = natiny.input.get_key_down(KEY_SHIFT)
        and PLAYER.run_speed or PLAYER.walk_speed

    return forward, side, input_length,
        forward_x, forward_z, right_x, right_z, speed
end

-- Driven by the speed the body really has, so it answers the water and the
-- walls rather than the keys.
--
-- Sideways and vertical are one motion and the phase between them is the part
-- that matters. Sideways peaks at mid-stance, weight fully over one leg.
-- Vertical runs at twice the rate as -cos(2a), so its lowest points land on
-- a = 0 and a = pi, the two weight transfers, and its highest on the two
-- mid-stances: a head drops onto each foot and is lifted over each leg. The
-- roll is that same motion seen as a rotation.
local function update_gait(dt, ground_speed)
    stride_phase = stride_phase + ground_speed / PLAYER.stride * dt
    breath_phase = breath_phase + dt / PLAYER.breath_period

    local gait = math.min(ground_speed / PLAYER.walk_speed, PLAYER.bob_max)
    local angle = stride_phase * 2.0 * math.pi

    local sway = math.sin(angle) * PLAYER.bob_lateral * gait
    local bob = -math.cos(2.0 * angle) * PLAYER.bob_vertical * gait
    local roll = -math.sin(angle) * PLAYER.bob_roll * gait

    -- Faded in exactly as the gait fades out, so the two never argue.
    bob = bob + math.sin(breath_phase * 2.0 * math.pi)
        * PLAYER.breath_height * (1.0 - math.min(gait, 1.0))

    return sway, bob, roll
end

-- What the legs ask for, and then what the body can do about it. A person in
-- hip-deep water does not reach walking pace within a frame and does not stop
-- within one either, and that lag is most of what separates wading from
-- sliding. Only the horizontal is steered, and from the velocity the body
-- really has, so a shove is something the legs work against rather than
-- something quietly overwritten.
local function drive_body(dt, want_x, want_z, pressed, vx, vertical, vz)
    local gap_x, gap_z = want_x - vx, want_z - vz
    local gap = math.sqrt(gap_x * gap_x + gap_z * gap_z)
    local reachable = (pressed and PLAYER.acceleration
        or PLAYER.deceleration) * dt
    if gap > reachable then
        gap_x = gap_x * reachable / gap
        gap_z = gap_z * reachable / gap
    end
    natiny.physics.set_velocity(player, vx + gap_x, vertical, vz + gap_z)
end

local function update_player(dt, captured)
    -- Streamer, camera and lamp all follow this one interpolated transform.
    local body_x, body_y, body_z = natiny.entity.get_position(
        player, natiny.entity.SPACE_WORLD)
    player_x, player_z = body_x, body_z

    local body_vx, vertical_velocity, body_vz =
        natiny.physics.get_velocity(player)

    update_look(dt, captured)

    local forward, side, input_length,
        forward_x, forward_z, right_x, right_z, speed = read_movement()

    local ground_speed = math.sqrt(body_vx * body_vx + body_vz * body_vz)
    local sway, bob, roll = update_gait(dt, ground_speed)

    -- The body has fixed rotation and is never turned, so its axes are the
    -- world's - hence the sway going through the yaw's right vector.
    local eye_x = body_x + right_x * sway
    local eye_y = body_y + PLAYER.eye_offset + bob
    local eye_z = body_z + right_z * sway

    -- Most of what the sway is worth looking at: the pool of light leans with
    -- the stride, and the relief in the tiling answers it.
    for index = 1, #hall_materials do
        natiny.material.set_constant(hall_materials[index], "lamp_position",
            eye_x, body_y + LAMP.height + bob, eye_z, LAMP.bulb_radius)
    end
    natiny.material.set_constant(water_material, "lamp_position",
        eye_x, body_y + LAMP.height + bob, eye_z, LAMP.bulb_radius)

    drive_body(dt,
        (forward_x * forward + right_x * side) * speed,
        (forward_z * forward + right_z * side) * speed,
        input_length > 0.0, body_vx, vertical_velocity, body_vz)

    natiny.entity.set_position(camera, natiny.entity.SPACE_LOCAL,
        right_x * sway, PLAYER.eye_offset + bob, right_z * sway)
    natiny.entity.set_rotation(camera, natiny.entity.SPACE_LOCAL,
        pitch, yaw, roll)

    -- The whole eye through the plane, sway and bob included. Reflecting a
    -- view direction is not the same as reflecting the up vector with it, so
    -- this image is the true mirror flipped about the horizontal - the water
    -- shader reads it back at 1 - v, which undoes that for nothing. Roll
    -- negates with pitch and yaw does not: conjugating Ry*Rx*Rz by a flip of
    -- the vertical leaves the Y turn alone and reverses the other two.
    natiny.entity.set_position(reflect_camera, natiny.entity.SPACE_WORLD,
        eye_x, 2.0 * WATER.level - eye_y, eye_z)
    natiny.entity.set_rotation(reflect_camera, natiny.entity.SPACE_WORLD,
        -pitch, yaw, -roll)

    stream_around(sector_at(player_x), sector_at(player_z))
end

-- ── Ambience ─────────────────────────────────────────────────────────────
-- One looping bed, deliberately not a positioned sound: a room's own noise
-- arrives from every direction at once and has nowhere to be standing, so this
-- ignores every transform, needs no listener, and does not swing around the
-- head as the player turns. The mixer joins the clip's last sample to its
-- first, so seamlessness is a property of the file.

local ambience

local function build_ambience()
    ambience = natiny.audio.create_source(
        natiny.clip.load(demo:resource(AUDIO .. "waterpool.ogg")))
    natiny.audio.source_set_spatial(ambience, false)
    natiny.audio.source_set_loop(ambience, true)
    natiny.audio.source_set_volume(ambience, AMBIENCE.volume)
    natiny.audio.source_play(ambience)
end

-- ── Effects ──────────────────────────────────────────────────────────────
-- Something in the water, somewhere else, a dozen times a minute. The opposite
-- of the bed above in every way that counts: spatial, once, and at a bearing.
-- The ears ride the eye, so that bearing is a real direction - the player can
-- turn towards a sound, walk at it, and find nothing there. Nothing checks
-- whether the point is inside a wall, and nothing should: there is no
-- occlusion, so a sound from beyond a wall is a sound from that direction,
-- which is worse and therefore better.

-- Parented to the camera, whose forward is local -Z as a listener's is. Every
-- spatial source is silent without one, so this is not optional. The camera
-- is one of the things build_player makes, so the parenting waits for it.
local listener = natiny.audio.create_listener()

-- Durations are kept because they tell a voice when it is free again. Asking
-- the mixer would mean asking a device that may not be there: with no output
-- open the position never advances and every voice would read busy forever.
local effect_clips = {}
local effect_voices = {}

local function build_effects()
    natiny.entity.set_parent(listener, camera)

    for _, name in ipairs({ "01", "02", "03", "04", "05" }) do
        local clip = natiny.clip.load(
            demo:resource(AUDIO .. "waterpool_effect_" .. name .. ".ogg"))
        effect_clips[#effect_clips + 1] = {
            handle = clip,
            duration = natiny.clip.get_duration(clip),
        }
    end

    for _ = 1, EFFECTS.voices do
        local source = natiny.audio.create_source(effect_clips[1].handle)
        -- A source starts non-looping, so this changes nothing - but "no
        -- repeat" is a requirement, and one met by an absent line is one edit
        -- from lost.
        natiny.audio.source_set_loop(source, false)
        natiny.audio.source_set_volume(source, EFFECTS.volume)
        natiny.audio.source_set_attenuation(source,
            natiny.audio.ATTENUATION_INVERSE, EFFECTS.reference_distance)
        effect_voices[#effect_voices + 1] = { source = source, free_at = 0.0 }
    end
end

-- Slots are absolute rather than measured from the last sound, which is what
-- keeps the rate exact: a late moment cannot push the next slot's out, and a
-- frame long enough to step over a slot still owes that slot its sound.
local effect_slot = 0
local next_effect = 0.0

local function schedule_effect()
    next_effect = effect_slot * EFFECT_SLOT + math.random() * EFFECT_SLOT
    effect_slot = effect_slot + 1
end

schedule_effect()

local function update_effects(elapsed)
    if elapsed < next_effect then
        return
    end
    schedule_effect()

    local voice
    for index = 1, #effect_voices do
        if effect_voices[index].free_at <= elapsed then
            voice = effect_voices[index]
            break
        end
    end
    -- Every voice still sounding. Skip the turn rather than steal one from a
    -- sound the player is in the middle of hearing.
    if not voice then
        return
    end

    -- A bearing and a distance around wherever the player is standing, then
    -- left there. Parented to nothing, so it does not follow - which is what
    -- makes turning towards it and walking at it mean something.
    local angle = math.random() * 2.0 * math.pi
    local distance = EFFECTS.near_distance
        + math.random() * (EFFECTS.far_distance - EFFECTS.near_distance)
    local x = player_x + math.sin(angle) * distance
    local z = player_z + math.cos(angle) * distance
    local y = water_surface_at(x, z, elapsed) + EFFECTS.height_low
        + math.random() * (EFFECTS.height_high - EFFECTS.height_low)

    local clip = effect_clips[math.random(#effect_clips)]
    natiny.audio.source_set_clip(voice.source, clip.handle)
    natiny.entity.set_position(voice.source, natiny.entity.SPACE_WORLD, x, y, z)
    natiny.audio.source_play(voice.source)
    voice.free_at = elapsed + clip.duration
end

-- ── Walking ──────────────────────────────────────────────────────────────
-- Wading, and the problem with wading: a recording this short played round and
-- round is a pattern inside ten seconds, and once the ear has the pattern it
-- stops hearing water and starts hearing a tape.
--
-- So it is not a loop. Three heads read the same recording at once from
-- unrelated places, each on its own raised-cosine window, and each jumps to a
-- fresh offset - with a fresh detune - at the moment its window closes and it
-- is silent. Nothing is heard for more than a couple of seconds in a row, a
-- new offset enters every half second or so, and no two heads share a pitch.
--
-- Level and pitch follow the speed the body has, not the keys: walking into a
-- wall presses W and makes no sound, and being shoved makes a little.

local walk_clip, walk_length
local walk_heads = {}
local walk_level = 0.0

-- Somewhere else in the recording, for however long, at a slightly different
-- speed. Called when a head's window has closed, and once at setup.
local function walk_move_head(head)
    head.hold = WALK.hold_min
        + math.random() * (WALK.hold_max - WALK.hold_min)
    head.detune = 1.0 + (math.random() * 2.0 - 1.0) * WALK.detune
    natiny.audio.source_seek(head.source, math.random() * walk_length)
end

local function build_walk()
    walk_clip = natiny.clip.load(demo:resource(AUDIO .. "waterpool_walk.ogg"))
    walk_length = natiny.clip.get_duration(walk_clip)

    for index = 1, WALK.heads do
        local source = natiny.audio.create_source(walk_clip)
        natiny.audio.source_set_spatial(source, false)
        natiny.audio.source_set_loop(source, true)
        natiny.audio.source_set_volume(source, 0.0)
        natiny.audio.source_play(source)

        -- Spread round the cycle, so they never close their windows together.
        local head = { source = source, phase = (index - 1) / WALK.heads,
            window = 0.0 }
        walk_move_head(head)
        walk_heads[index] = head
    end
end

-- Rising is a follower; the moment it arrives does not matter, it arrives with
-- the stride. Falling is a rate limit, which is the point of separating them:
-- an exponential never reaches zero, it only gets quieter, so it cannot be
-- asked to stop in a quarter of a second. A bounded rate can, and ends at
-- silence. The rate is walking's, so stopping from a run takes a little
-- longer, there having been more water in motion.
local function walk_follow(dt, target)
    if target > walk_level then
        walk_level = walk_level
            + (target - walk_level) * math.min(dt / WALK.attack, 1.0)
    else
        walk_level = math.max(target,
            walk_level - dt * WALK.volume / WALK.release)
    end
end

local function update_walk(dt)
    local vx, _, vz = natiny.physics.get_velocity(player)
    local speed = math.sqrt(vx * vx + vz * vz)

    local drive = (speed - WALK.min_speed)
        / (PLAYER.walk_speed - WALK.min_speed)
    local target = 0.0
    if drive > 0.0 then
        target = math.min(WALK.volume * drive ^ WALK.level_curve,
            WALK.volume_max)
    end
    walk_follow(dt, target)

    local pitch = 1.0 + (speed / PLAYER.walk_speed - 1.0) * WALK.pitch_speed

    local power = 0.0
    for index = 1, #walk_heads do
        local head = walk_heads[index]
        head.phase = head.phase + dt / head.hold
        if head.phase >= 1.0 then
            head.phase = head.phase % 1.0
            walk_move_head(head)
        end
        -- Zero and smooth at both ends, so a head is silent at exactly the
        -- frame it jumps, which is why the jumps cannot be heard.
        head.window = 0.5 - 0.5 * math.cos(2.0 * math.pi * head.phase)
        power = power + head.window * head.window
    end

    -- Equal power, not equal amplitude: the heads read places with nothing to
    -- do with each other, so they are uncorrelated and it is their powers that
    -- add. The root of the sum of squares holds the loudness steady while the
    -- three hand the sound across; the plain sum would dip whenever two of
    -- them shared it.
    power = math.sqrt(math.max(power, 1e-6))

    for index = 1, #walk_heads do
        local head = walk_heads[index]
        natiny.audio.source_set_volume(head.source,
            walk_level * head.window / power)
        natiny.audio.source_set_pitch(head.source, pitch * head.detune)
    end
end

-- ── HDR and post processing ──────────────────────────────────────────────
-- The material writes linear radiance into a float target, and nothing decides
-- how bright the screen should be until the end of the frame: the ladder
-- measures the scene, adaptation follows that the way an eye does, and AgX
-- puts the result on the display. Half float rather than R32F throughout,
-- because a single channel float is not filterable on WebGPU and the ladder
-- depends on bilinear taps.

local function post_material(path)
    local mat = natiny.material.create(natiny.shader.load(demo:resource(
        "data/examples/waterpool/shaders/" .. path)))
    natiny.material.set_state(mat, {
        blend = natiny.material.BLEND_NONE,
        depth = natiny.material.DEPTH_OFF,
    })
    return mat
end

local hdr_surface
local refraction_surface
local reflect_surface
local bloom_down = {}
local bloom_up = {}
local bloom_size = {}
local bloom_down_mats = {}
local bloom_up_mats = {}
local ladder_surfaces = {}
local ladder_materials = {}
local adapt_surfaces = {}
local adapt_index = 1
local luminance_mat, adapt_mat, agx_mat, bloom_prefilter_mat

-- Everything below is made out of a shader file, so it is made when the files
-- are in rather than here.
local function build_post()
    luminance_mat = post_material("luminance.nsl")
    adapt_mat = post_material("adapt.nsl")
    agx_mat = post_material("agx.nsl")

    natiny.material.set_constant(agx_mat, "tone_params",
        GRADE.exposure_key, GRADE.exposure_min_stop, GRADE.exposure_max_stop, 0)
    natiny.material.set_constant(agx_mat, "look_contrast",
        GRADE.look_contrast, 0, 0, 0)
    natiny.material.set_constant(agx_mat, "look_slope",
        GRADE.look_slope[1], GRADE.look_slope[2], GRADE.look_slope[3],
        GRADE.look_saturation)
    natiny.material.set_constant(agx_mat, "look_offset",
        GRADE.look_offset[1], GRADE.look_offset[2], GRADE.look_offset[3], 0)
    natiny.material.set_constant(agx_mat, "look_power",
        GRADE.look_power[1], GRADE.look_power[2], GRADE.look_power[3], 0)
    natiny.material.set_constant(agx_mat, "vignette",
        GRADE.vignette, GRADE.vignette_start, 0, 0)
    -- The distortion and the dispersion are the lens, not the grade: both are
    -- applied to linear radiance at the top of the pass, ahead of exposure, and
    -- both are functions of the same angle off the axis.
    natiny.material.set_constant(agx_mat, "lens", GRADE.barrel, 0, 0, 0)
    natiny.material.set_constant(agx_mat, "chroma",
        GRADE.chroma_dispersion, GRADE.chroma_edge,
        GRADE.chroma_focus, GRADE.chroma_focus_edge)

    -- Bloom is a pyramid, not a blur. Each level down halves, each level back up
    -- widens and adds its own size back in, so the six halos sum to something near
    -- the long tailed falloff a lens scatters - for a few bilinear taps.

    bloom_prefilter_mat = post_material("bloom_prefilter.nsl")


    for step = 2, BLOOM.levels do
        bloom_down_mats[step] = post_material("bloom_down.nsl")
    end
    for step = 1, BLOOM.levels - 1 do
        bloom_up_mats[step] = post_material("bloom_up.nsl")
        natiny.material.set_constant(bloom_up_mats[step], "bloom_params",
            BLOOM.threshold, BLOOM.knee, BLOOM.radius, BLOOM.intensity)
    end
    natiny.material.set_constant(bloom_prefilter_mat, "bloom_params",
        BLOOM.threshold, BLOOM.knee, BLOOM.radius, BLOOM.intensity)
    natiny.material.set_constant(bloom_prefilter_mat, "tone_params",
        GRADE.exposure_key, GRADE.exposure_min_stop, GRADE.exposure_max_stop, 0)
    natiny.material.set_constant(agx_mat, "bloom_params",
        BLOOM.threshold, BLOOM.knee, BLOOM.radius, BLOOM.intensity)

    for step = 1, #METER.ladder do
        ladder_surfaces[step] = natiny.surface.create(
            METER.ladder[step], METER.ladder[step], natiny.surface.RGBA16F, false)
    end

    -- Adaptation reads what it wrote last frame, so it needs two one-pixel targets
    -- rather than one it would sample and overwrite at once.
    for index = 1, 2 do
        adapt_surfaces[index] = natiny.surface.create(
            1, 1, natiny.surface.RGBA16F, false)
    end

    -- The ladder never changes size, so its wiring is done once - and so are the
    -- first pass's taps, placed by the finished 128x128 and not by the window.
    for step = 2, #METER.ladder do
        local source = ladder_surfaces[step - 1]
        local mat = post_material("downsample.nsl")
        natiny.material.set_texture(mat, "source",
            natiny.surface.get_texture(source))
        natiny.material.set_constant(mat, "source_texel",
            1.0 / METER.ladder[step - 1], 1.0 / METER.ladder[step - 1], 0, 0)
        ladder_materials[step] = mat
    end

    natiny.material.set_texture(adapt_mat, "measured",
        natiny.surface.get_texture(ladder_surfaces[#METER.ladder]))
    natiny.material.set_constant(luminance_mat, "tap_offset",
        0.25 / METER.ladder[1], 0.25 / METER.ladder[1], 0, 0)
end

local surface_width, surface_height = 0, 0

local function resize_hdr(w, h)
    if w == surface_width and h == surface_height then
        return
    end

    local reflect_w = math.max(1, math.floor(w * 0.5))
    local reflect_h = math.max(1, math.floor(h * 0.5))
    for step = 1, BLOOM.levels do
        local divisor = 2 ^ step
        bloom_size[step] = {
            math.max(1, math.floor(w / divisor)),
            math.max(1, math.floor(h / divisor)),
        }
    end

    if not hdr_surface then
        hdr_surface = natiny.surface.create(w, h, natiny.surface.RGBA16F, true)
        -- The room on its own, for the water to look through. It receives the
        -- same geometry as the frame proper, so it needs depth too.
        refraction_surface = natiny.surface.create(
            w, h, natiny.surface.RGBA16F, true)
        -- The room from the mirrored camera. Half resolution: it is only ever
        -- read back through a rippling surface, and softening it slightly is
        -- nearer to right than it is to wrong.
        reflect_surface = natiny.surface.create(
            reflect_w, reflect_h, natiny.surface.RGBA16F, true)
        for step = 1, BLOOM.levels do
            local size = bloom_size[step]
            bloom_down[step] = natiny.surface.create(
                size[1], size[2], natiny.surface.RGBA16F, false)
            if step < BLOOM.levels then
                bloom_up[step] = natiny.surface.create(
                    size[1], size[2], natiny.surface.RGBA16F, false)
            end
        end
    else
        natiny.surface.resize(hdr_surface, w, h)
        natiny.surface.resize(refraction_surface, w, h)
        natiny.surface.resize(reflect_surface, reflect_w, reflect_h)
        for step = 1, BLOOM.levels do
            local size = bloom_size[step]
            natiny.surface.resize(bloom_down[step], size[1], size[2])
            if step < BLOOM.levels then
                natiny.surface.resize(bloom_up[step], size[1], size[2])
            end
        end
    end

    -- A resize replaces the texture underneath, so every reader is repointed
    -- and every texel size recomputed.
    local hdr_texture = natiny.surface.get_texture(hdr_surface)
    natiny.material.set_texture(luminance_mat, "source", hdr_texture)
    natiny.material.set_texture(agx_mat, "hdr", hdr_texture)
    -- The dispersion samples off its own pixel, so it needs to know how far
    -- one texel is to keep its taps inside the frame.
    natiny.material.set_constant(agx_mat, "hdr_texel", 1.0 / w, 1.0 / h, 0, 0)
    natiny.material.set_texture(water_material, "refraction",
        natiny.surface.get_texture(refraction_surface))
    natiny.material.set_texture(water_material, "reflection",
        natiny.surface.get_texture(reflect_surface))

    natiny.material.set_texture(bloom_prefilter_mat, "hdr", hdr_texture)
    -- Half a destination texel, which is half of two source texels.
    natiny.material.set_constant(bloom_prefilter_mat, "source_texel",
        0.5 / w, 0.5 / h, 0, 0)

    for step = 2, BLOOM.levels do
        local source = bloom_size[step - 1]
        natiny.material.set_texture(bloom_down_mats[step], "source",
            natiny.surface.get_texture(bloom_down[step - 1]))
        natiny.material.set_constant(bloom_down_mats[step], "source_texel",
            1.0 / source[1], 1.0 / source[2], 0, 0)
    end

    for step = 1, BLOOM.levels - 1 do
        local smaller = bloom_up[step + 1]
        if step + 1 == BLOOM.levels then
            smaller = bloom_down[BLOOM.levels]
        end
        local size = bloom_size[step]
        natiny.material.set_texture(bloom_up_mats[step], "smaller",
            natiny.surface.get_texture(smaller))
        natiny.material.set_texture(bloom_up_mats[step], "same_size",
            natiny.surface.get_texture(bloom_down[step]))
        natiny.material.set_constant(bloom_up_mats[step], "source_texel",
            1.0 / size[1], 1.0 / size[2], 0, 0)
    end

    natiny.material.set_texture(agx_mat, "bloom_map",
        natiny.surface.get_texture(bloom_up[1]))

    surface_width, surface_height = w, h
end

-- Down the pyramid, then back up. After the adaptation chain: what glows is
-- measured against what the eye settled on, or the bloom would come and go as
-- the player walked from a dim corridor into a lit hall.
local function run_bloom()
    natiny.surface.bind(bloom_down[1], function()
        natiny.material.bind(bloom_prefilter_mat, natiny.render.cover)
    end)
    for step = 2, BLOOM.levels do
        local mat = bloom_down_mats[step]
        natiny.surface.bind(bloom_down[step], function()
            natiny.material.bind(mat, natiny.render.cover)
        end)
    end
    for step = BLOOM.levels - 1, 1, -1 do
        local mat = bloom_up_mats[step]
        natiny.surface.bind(bloom_up[step], function()
            natiny.material.bind(mat, natiny.render.cover)
        end)
    end
end

-- Undefined memory, and the first frame reads one of them. Clearing has to
-- happen inside a frame to be recorded at all.
local adapt_cleared = false

-- Runs the whole chain and leaves the adapted one pixel bound to agx_mat.
local function meter_and_adapt(dt)
    if not adapt_cleared then
        for index = 1, 2 do
            natiny.surface.bind(adapt_surfaces[index], function()
                natiny.render.clear(0, 0, 0, 1)
            end)
        end
        adapt_cleared = true
    end

    natiny.surface.bind(ladder_surfaces[1], function()
        natiny.material.bind(luminance_mat, natiny.render.cover)
    end)
    for step = 2, #METER.ladder do
        local mat = ladder_materials[step]
        natiny.surface.bind(ladder_surfaces[step], function()
            natiny.material.bind(mat, natiny.render.cover)
        end)
    end

    local previous = adapt_surfaces[adapt_index]
    adapt_index = 3 - adapt_index
    local current = adapt_surfaces[adapt_index]

    natiny.material.set_texture(adapt_mat, "previous",
        natiny.surface.get_texture(previous))
    natiny.material.set_constant(adapt_mat, "adapt_params",
        dt, GRADE.adapt_brighten, GRADE.adapt_darken, 0)
    natiny.surface.bind(current, function()
        natiny.material.bind(adapt_mat, natiny.render.cover)
    end)

    natiny.material.set_texture(agx_mat, "adapted",
        natiny.surface.get_texture(current))
end

-- ── Frame ────────────────────────────────────────────────────────────────

local elapsed = 0.0

local function draw_scene(width, height, captured, dt)
    resize_hdr(width, height)

    natiny.material.set_constant(water_material, "wave_params",
        elapsed, WATER.wave_height, WATER.refraction, WATER.wave_scale)
    for index = 1, #hall_materials do
        natiny.material.set_constant(hall_materials[index], "water_plane",
            WATER.level, CAUSTICS.strength, CAUSTICS.softness, elapsed)
    end

    natiny.render.clear(BG[1], BG[2], BG[3], 1.0)

    -- 1. The hall as the water sees it. No water here, or it reflects itself.
    -- Binding a surface keeps what is in it, depth and all, so every pass that
    -- redraws its whole picture from a camera that moved says so: without the
    -- clear this is last frame's depth, and only what happens to be nearer
    -- than last frame gets through.
    natiny.surface.bind(reflect_surface, function()
        natiny.render.clear(0, 0, 0, 1)
        natiny.camera.bind(reflect_camera, function()
            natiny.render.tag("waterpool")
        end)
    end)

    -- 2. The hall from the eye, distance to every pixel in the alpha. What the
    -- water looks through, so no water in this one either.
    -- Alpha zero, not one: the water reads this alpha as the distance to what
    -- is behind it and treats zero as "nothing was drawn here", which is
    -- exactly what a cleared pixel is.
    natiny.surface.bind(refraction_surface, function()
        natiny.render.clear(0, 0, 0, 0)
        natiny.camera.bind(camera, function()
            natiny.render.tag("waterpool")
        end)
    end)

    -- 3. The frame proper. Both tags in one bind, and the clear before them
    -- rather than between: the water needs the depth the room just wrote, or a
    -- wall in front of the pool would stop hiding it.
    natiny.surface.bind(hdr_surface, function()
        natiny.render.clear(0, 0, 0, 1)
        natiny.camera.bind(camera, function()
            natiny.render.tag("waterpool")
            natiny.render.tag("water")
        end)
    end)

    -- 4. Meter it, and let the eye follow the meter.
    meter_and_adapt(dt)

    -- 5. What the lens scatters, measured against what the eye settled on.
    run_bloom()

    -- 6. Exposure, bloom and the AgX display transform, onto the window.
    natiny.material.bind(agx_mat, natiny.render.cover)

    -- 7. The overlay, drawn after the transform so it is not graded.
    local cx, cy = width * 0.5, height * 0.5
    natiny.render.set_color(ACCENT[1], ACCENT[2], ACCENT[3], 0.8)
    natiny.render.line(cx - 5, cy, cx + 5, cy, 1)
    natiny.render.line(cx, cy - 5, cx, cy + 5, 1)
    demo:set_user_text(captured
        and "WASD: move  |  Shift: run  |  Esc: release mouse"
        or "Click to enter Waterpool")
end

local function frame(dt)
    natiny.window.bind(window, function()
        elapsed = elapsed + dt

        local captured = natiny.input.get_mouse_captured()
        if not captured and natiny.input.get_mouse_button_pressed(MOUSE_LEFT) then
            natiny.input.set_mouse_capture(true)
            captured = true
        end

        update_player(dt, captured)
        update_balls(dt, elapsed)
        update_effects(elapsed)
        update_walk(dt)

        draw_scene(natiny.window.get_width(window),
                   natiny.window.get_height(window), captured, dt)
    end)
end

-- The maze, the sound and the post chain, in that order, once the last of the
-- twenty eight files has landed.  Everything above this line is arithmetic.
local function build_waterpool()
    build_world()
    build_sectors()
    build_player()
    build_ambience()
    build_effects()
    build_walk()
    build_post()
end

demo:run({
    window = window,
    title  = "Waterpool",
    resources = ASSETS,

    on_loaded = build_waterpool,
    loop = frame,
})

natiny.backend.shutdown()