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.
-- 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()