Sea

An animated sea with a full day and night cycle.

Based on Alexander Alekseev's Seascape, CC BY-NC-SA 3.0.


    
main.lua
--[[ Sea - animated water through a full day and night cycle.

     1/2 change the time · 3/4 change the waves · 5 pauses the clock

     The wave field and the water's colour are Alexander Alekseev's "Seascape",
     CC BY-NC-SA 3.0, adapted to a plane instead of raymarching:
     https://www.shadertoy.com/view/Ms2SD1
]]

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

local BG = { 21 / 255, 30 / 255, 44 / 255 } -- #151E2C

-- ── The day ─────────────────────────────────────────────────────────────
local DAY_LENGTH = 600.0   -- seconds for one full day
local DAY_START  = 0.61    -- 0.25 sunrise, 0.5 noon, 0.75 sunset
local DAY_SCRUB  = 0.05    -- how fast the 1/2 keys wind the clock
local SUN_HIGH   = 62.0    -- degrees the sun reaches at noon

-- ── The sun ─────────────────────────────────────────────────────────────
local SUN_SIZE = 1.2       -- degrees across (the real sun is 0.53)

-- How tightly the sunset stays on the sun's side of the sky.  1 spreads it
-- evenly all the way round; higher keeps it where the sun is.
local SKY_WARM_SPREAD = 2.4

-- ── The palette of the day ──────────────────────────────────────────────
-- One row per hour, sorted by how high the sun is.  Each frame finds the two
-- rows the sun is between and mixes them, so the shaders hold no colours.
--   zenith  straight up          horizon  the sky's rim, sun's side
--   away    the rim behind you   ground   below the horizon
--   sun     colour of the disc   radiance how bright the disc is
--   glow    how tight its halo   halo     how much colour the air around it takes
--   haze    how milky the air    exposure how much light the camera lets in
--   stars   how bright they are  deep     water with no sky in it
local HOURS = {
    { elev = -90.0,
      zenith = {0.004, 0.008, 0.020}, horizon = {0.014, 0.024, 0.048}, away = {0.014, 0.024, 0.048},
      ground = {0.003, 0.005, 0.011}, sun = {0.45, 0.55, 0.85},
      radiance = 3.0, glow = 22.0, halo = 0.10, haze = 0.10, exposure = 3.2,
      stars = 1.00, deep = {0.000, 0.007, 0.013} },

    { elev = -12.0,
      zenith = {0.006, 0.012, 0.030}, horizon = {0.030, 0.045, 0.085}, away = {0.028, 0.042, 0.082},
      ground = {0.005, 0.008, 0.015}, sun = {0.55, 0.60, 0.90},
      radiance = 4.0, glow = 20.0, halo = 0.14, haze = 0.12, exposure = 3.0,
      stars = 1.00, deep = {0.000, 0.012, 0.022} },

    { elev = -6.0,
      zenith = {0.010, 0.026, 0.080}, horizon = {0.070, 0.075, 0.170}, away = {0.032, 0.042, 0.105},
      ground = {0.008, 0.012, 0.026}, sun = {0.80, 0.38, 0.34},
      radiance = 5.0, glow = 14.0, halo = 0.35, haze = 0.20, exposure = 2.4,
      stars = 0.75, deep = {0.000, 0.026, 0.045} },

    { elev = -2.0,
      zenith = {0.020, 0.050, 0.150}, horizon = {0.300, 0.150, 0.170}, away = {0.070, 0.078, 0.155},
      ground = {0.018, 0.022, 0.042}, sun = {1.00, 0.32, 0.16},
      radiance = 10.0, glow = 9.0, halo = 0.75, haze = 0.32, exposure = 1.9,
      stars = 0.35, deep = {0.000, 0.046, 0.074} },

    { elev = 0.0,
      zenith = {0.060, 0.140, 0.340}, horizon = {1.000, 0.440, 0.200}, away = {0.150, 0.155, 0.265},
      ground = {0.060, 0.055, 0.080}, sun = {1.00, 0.42, 0.16},
      radiance = 20.0, glow = 6.0, halo = 0.95, haze = 0.44, exposure = 1.40,
      stars = 0.12, deep = {0.000, 0.064, 0.099} },

    { elev = 3.0,
      zenith = {0.080, 0.190, 0.460}, horizon = {1.000, 0.620, 0.340}, away = {0.260, 0.285, 0.405},
      ground = {0.110, 0.105, 0.120}, sun = {1.00, 0.60, 0.30},
      radiance = 26.0, glow = 6.5, halo = 0.85, haze = 0.42, exposure = 1.22,
      stars = 0.00, deep = {0.000, 0.079, 0.128} },

    { elev = 9.0,
      zenith = {0.090, 0.220, 0.520}, horizon = {0.940, 0.740, 0.520}, away = {0.430, 0.490, 0.625},
      ground = {0.160, 0.160, 0.175}, sun = {1.00, 0.76, 0.50},
      radiance = 30.0, glow = 7.5, halo = 0.62, haze = 0.38, exposure = 1.14,
      stars = 0.00, deep = {0.000, 0.090, 0.146} },

    { elev = 25.0,
      zenith = {0.095, 0.255, 0.600}, horizon = {0.720, 0.790, 0.900}, away = {0.650, 0.730, 0.865},
      ground = {0.240, 0.300, 0.380}, sun = {1.00, 0.92, 0.80},
      radiance = 32.0, glow = 9.0, halo = 0.42, haze = 0.32, exposure = 1.12,
      stars = 0.00, deep = {0.000, 0.096, 0.157} },

    { elev = 90.0,
      zenith = {0.075, 0.245, 0.680}, horizon = {0.620, 0.750, 0.920}, away = {0.605, 0.735, 0.915},
      ground = {0.300, 0.380, 0.460}, sun = {1.00, 0.97, 0.93},
      radiance = 34.0, glow = 10.0, halo = 0.34, haze = 0.26, exposure = 1.02,
      stars = 0.00, deep = {0.000, 0.098, 0.162} },
}

-- ── The water ───────────────────────────────────────────────────────────
-- Seascape's own numbers: HEIGHT how tall the swell is in metres, CHOPPY how
-- peaked its crests are, SPEED how fast it travels, FREQ the size of the biggest
-- wave (smaller means longer).
local SEA_HEIGHT, SEA_CHOPPY, SEA_SPEED, SEA_FREQ = 0.7, 4.0, 0.8, 0.16

-- MIRROR caps how much sky the water can give back - a full mirror reads as wet
-- paper - and REFLECT dims what does come back.
local SEA_MIRROR, SEA_REFLECT = 0.5, 0.95

-- How sharp the sun's reflection is up close.  The shader widens it with
-- distance, which turns sparks nearby into a bright road far off.
local SEA_SPECULAR = 2500.0

-- The glow inside a crest thin enough for light to pass through, and how strong.
local GLOW_R, GLOW_G, GLOW_B, GLOW_STRENGTH = 0.45, 0.56, 0.37, 0.18

-- Where the water fades into the sky.  Keep it well under PLANE_SIZE / 2, or the
-- edge of the grid shows as a straight line.
local FOG_DISTANCE = 190.0

-- ── Camera and world ────────────────────────────────────────────────────
local FOV, NEAR_Z, FAR_Z = 65.0, 0.25, 3000.0
local EYE_HEIGHT, EYE_PITCH, EYE_YAW = 3.5, -3.0, 40.0
local LOOK_SENSITIVITY = 0.15

-- 420 m of water in 400 x 400 cells, so about a metre per cell.  Anything
-- smaller than a cell is the fragment shader's job, not the grid's.
local PLANE_SIZE, PLANE_CELLS = 420.0, 400
local SKY_SIZE = 1200.0

local KEY_1, KEY_2 = natiny.input.KEY_1, natiny.input.KEY_2
local KEY_3, KEY_4 = natiny.input.KEY_3, natiny.input.KEY_4
local KEY_5 = natiny.input.KEY_5
local MOUSE_LEFT = natiny.input.MOUSE_BUTTON_LEFT

-- One executable per platform now carries every backend that platform can
-- run, so the script says which one to draw with.  Nothing on the command
-- line means AUTO: Direct3D 12 on Windows, Vulkan on Linux, WebGPU on the web.
--
--     ./natiny opengl        ./natiny vulkan        natiny.exe d3d12
if not natiny.backend.init(arg and arg[1] or natiny.backend.AUTO) then
    os.exit(1)
end
local _, backend_name = natiny.backend.get_current()
print("[main] backend: " .. backend_name)

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

-- ── Shaders, and the models they shade ───────────────────────────────
-- One .nsl file holds both stages and names no backend. The shell fetches its
-- bytes and Natiny compiles them for the backend that is running; which
-- language that is never comes up here.
local sky_mat, water_mat, sky, sea

local function build_sea()
    -- The second argument is the material's tags: what natiny.render.tag()
    -- below selects by.  Naming a pass on the material is what lets the frame
    -- ask for "the sky" without holding a list of what is in it.
    sky_mat = natiny.material.create(
        natiny.shader.load(demo:resource("data/examples/sea/shaders/sky.nsl")), "sky")
    water_mat = natiny.material.create(
        natiny.shader.load(demo:resource("data/examples/sea/shaders/water.nsl")), "water")

    -- The box is seen from inside, so cull the front faces instead of the back
    -- ones; no depth, because everything else is drawn in front of it.
    natiny.material.set_state(sky_mat, {
        blend = natiny.material.BLEND_NONE,
        cull  = natiny.material.CULL_FRONT,
        depth = natiny.material.DEPTH_OFF,
    })

    -- Both faces, so the surface is still there when you look up from under it.
    natiny.material.set_state(water_mat, {
        blend = natiny.material.BLEND_NONE,
        cull  = natiny.material.CULL_NONE,
        depth = natiny.material.DEPTH_ON,
    })

    -- A mesh is geometry and has nowhere to be; a model is the entity.  So the
    -- shapes are built first and each is handed to a model along with the
    -- material that shades it.
    sky = natiny.model.create(natiny.mesh.cube(SKY_SIZE), sky_mat)
    sea = natiny.model.create(
        natiny.mesh.plane(PLANE_SIZE, PLANE_SIZE, PLANE_CELLS, PLANE_CELLS), water_mat)
    natiny.entity.set_position(sky, natiny.entity.SPACE_WORLD, 0.0, EYE_HEIGHT, 0.0)

    print(string.format("sea: %d cells · sky: %.0f m box · day: %.0f s",
                        PLANE_CELLS * PLANE_CELLS, SKY_SIZE, DAY_LENGTH))
end

-- ── Camera ──────────────────────────────────────────────────────────────
local cam = natiny.camera.create()
natiny.camera.set_fov(cam, FOV)

-- The default far plane is 100 m, which would cut the sea in half.
natiny.camera.set_clip(cam, NEAR_Z, FAR_Z)
natiny.entity.set_position(cam, natiny.entity.SPACE_WORLD, 0.0, EYE_HEIGHT, 0.0)
natiny.entity.set_rotation(cam, natiny.entity.SPACE_LOCAL, EYE_PITCH, EYE_YAW, 0.0)

-- ── The hour ────────────────────────────────────────────────────────────
-- Where the sun is, from the phase of the day: it rises in the east at 0.25,
-- stands south at noon, sets in the west at 0.75.
local function sun_angles(phase)
    local turn = (phase - 0.25) * 2.0 * math.pi
    return SUN_HIGH * math.sin(turn), 90.0 + math.deg(turn)
end

local function mix3(a, b, k)
    return a[1] + (b[1] - a[1]) * k,
           a[2] + (b[2] - a[2]) * k,
           a[3] + (b[3] - a[3]) * k
end

-- Find the two rows of HOURS the sun is between, and mix them.
local function hour_at(elev)
    local lo, hi = HOURS[1], HOURS[#HOURS]
    for i = 1, #HOURS - 1 do
        if elev >= HOURS[i].elev and elev <= HOURS[i + 1].elev then
            lo, hi = HOURS[i], HOURS[i + 1]
            break
        end
    end

    local span = hi.elev - lo.elev
    local k    = span > 0.0 and (elev - lo.elev) / span or 0.0
    local function f(name) return lo[name] + (hi[name] - lo[name]) * k end

    return {
        zenith   = { mix3(lo.zenith,  hi.zenith,  k) },
        horizon  = { mix3(lo.horizon, hi.horizon, k) },
        away     = { mix3(lo.away,    hi.away,    k) },
        ground   = { mix3(lo.ground,  hi.ground,  k) },
        sun      = { mix3(lo.sun,     hi.sun,     k) },
        deep     = { mix3(lo.deep,    hi.deep,    k) },
        radiance = f("radiance"), glow     = f("glow"),  halo  = f("halo"),
        haze     = f("haze"),     exposure = f("exposure"), stars = f("stars"),
    }
end

local function hour_name(elevation, rising)
    if elevation < -9.0 then return "night" end
    if elevation < -3.0 then return "blue hour" end
    if elevation < -0.4 then return rising and "dawn" or "twilight" end
    if elevation < 2.0 then return rising and "sunrise" or "sunset" end
    if elevation < 12.0 then return "golden hour" end
    if elevation < 40.0 then return rising and "morning" or "afternoon" end
    return "midday"
end

local day_phase = DAY_START
local day_held  = false
local yaw, pitch = EYE_YAW, EYE_PITCH

local function update_camera(captured)
    if not captured then return end

    yaw = yaw - natiny.input.get_mouse_x_speed() * LOOK_SENSITIVITY
    pitch = pitch - natiny.input.get_mouse_y_speed() * LOOK_SENSITIVITY
    pitch = math.max(-89.0, math.min(89.0, pitch))
    natiny.entity.set_rotation(cam, natiny.entity.SPACE_LOCAL, pitch, yaw, 0.0)
end

-- ── Frame ───────────────────────────────────────────────────────────────
local time  = 0.0
local swell = SEA_HEIGHT

-- The shaders measure the angle to the sun, so they want a radius in radians.
local SUN_RADIUS = math.rad(SUN_SIZE * 0.5)

-- The hour, handed to one material.  Both shaders read these same names, so the
-- sun in the sky and the sun in the water always agree.
local function apply_hour(mat, h, sun_x, sun_y, sun_z)
    natiny.material.set_constant(mat, "sun_dir",     sun_x, sun_y, sun_z, 0.0)
    natiny.material.set_constant(mat, "sun_color",   h.sun[1], h.sun[2], h.sun[3], h.radiance)
    natiny.material.set_constant(mat, "sky_params",  SUN_RADIUS, h.glow, h.haze, h.exposure)
    natiny.material.set_constant(mat, "sky_zenith",  h.zenith[1],  h.zenith[2],  h.zenith[3],  0.0)
    natiny.material.set_constant(mat, "sky_horizon", h.horizon[1], h.horizon[2], h.horizon[3], h.halo)
    natiny.material.set_constant(mat, "sky_away",    h.away[1],    h.away[2],    h.away[3],    SKY_WARM_SPREAD)
    natiny.material.set_constant(mat, "sky_ground",  h.ground[1],  h.ground[2],  h.ground[3],  h.stars)

    -- The clock: the stars twinkle by it and the waves travel by it.  Both
    -- shader stages read scene_time, and one call sets it for both.
    natiny.material.set_constant(mat, "scene_time",  time, 0.0, 0.0, 0.0)
end

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

        if natiny.input.get_key_pressed(KEY_5) then day_held = not day_held end
        if not day_held then day_phase = day_phase + dt / DAY_LENGTH end
        if natiny.input.get_key_down(KEY_1) then day_phase = day_phase - dt * DAY_SCRUB end
        if natiny.input.get_key_down(KEY_2) then day_phase = day_phase + dt * DAY_SCRUB end
        day_phase = day_phase % 1.0

        if natiny.input.get_key_down(KEY_3) then swell = math.max(0.05, swell - dt * 0.5) end
        if natiny.input.get_key_down(KEY_4) then swell = math.min(2.00, swell + dt * 0.5) end

        local captured = natiny.input.get_mouse_captured()
        local elevation, azimuth = sun_angles(day_phase)
        local daylight = string.format(
            "%s %02d:%02d",
            hour_name(elevation, day_phase < 0.5),
            math.floor(day_phase * 24.0),
            math.floor(day_phase * 1440.0) % 60
        )
        demo:set_user_text(daylight .. (captured
            and " · Mouse look · 1/2 time · 3/4 waves · 5 pause · Esc frees mouse"
            or " · Click to take mouse · 1/2 time · 3/4 waves · 5 pause"))
        if not captured and natiny.input.get_mouse_button_pressed(MOUSE_LEFT) then
            natiny.input.set_mouse_capture(true)
        end
        update_camera(captured)

        local hour   = hour_at(elevation)

        -- The sun's two angles, as the direction the shaders want.
        local a, e  = math.rad(azimuth), math.rad(elevation)
        local sun_x = math.sin(a) * math.cos(e)
        local sun_y = math.sin(e)
        local sun_z = math.cos(a) * math.cos(e)

        apply_hour(sky_mat,   hour, sun_x, sun_y, sun_z)
        apply_hour(water_mat, hour, sun_x, sun_y, sun_z)

        -- wave_params reaches both stages too, so the shape the vertices take and
        -- the shape the lighting assumes are always the same sea.
        natiny.material.set_constant(water_mat, "wave_params",
                                     swell, SEA_CHOPPY, SEA_SPEED, SEA_FREQ)
        natiny.material.set_constant(water_mat, "water_deep",
                                     hour.deep[1], hour.deep[2], hour.deep[3], SEA_MIRROR)
        natiny.material.set_constant(water_mat, "water_glow",
                                     GLOW_R, GLOW_G, GLOW_B, GLOW_STRENGTH)
        natiny.material.set_constant(water_mat, "water_params",
                                     SEA_SPECULAR, SEA_REFLECT, FOG_DISTANCE, 0.0)

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

        -- Sky first, then the water over it.
        natiny.camera.bind(cam, function()
            natiny.render.tag("sky")
            natiny.render.tag("water")
        end)
    end)
end

-- The two .nsl files are all this demo loads.  The shell fetches them, says
-- how far along that is in the middle of the window, and only then builds the
-- sea - so nothing here ever asks for a shader that is not in yet.
demo:run({
    window = win,
    title  = "Sea",
    resources = { "data/examples/sea/shaders/sky.nsl", "data/examples/sea/shaders/water.nsl" },

    on_loaded = build_sea,
    loop = frame,
})

natiny.backend.shutdown()