Sea
An animated sea with a full day and night cycle.
Based on Alexander Alekseev's Seascape, CC BY-NC-SA 3.0.
--[[ 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()