singe/testScripts/gridBackground.singe
2026-09-12 22:36:00 -05:00

197 lines
6.7 KiB
Text

-- Prototype: the menu's grid background drawn live instead of played from a video file.
local CELL = 6.0 -- Spacing between grid lines, world units.
local LINE_W = 0.12 -- Width of a line running away from the camera, at the near end.
local CROSS_W = 0.18 -- Width of a cross line, at the near end.
local SPREAD = 16.0 -- Distance over which a line doubles in width. See quad().
local HALF_X = 12 -- Lines each side of the centre.
local DEPTH = 14 -- Cross lines ahead of the camera, past where the fog ends.
local SPEED = 9.0 -- Units a second the grid moves toward the camera.
local FOG_R = 8
local FOG_G = 3
local FOG_B = 20
local FOG_NEAR = 12
local FOG_FAR = 72
local SUN_R = 10.5
local SUN_Y = 7.6
local SUN_Z = -34.0
local HORIZ_W = 70.0 -- Half the width of the horizon bar; it has to reach both edges.
local HORIZ_H = 0.5 -- Its height. Bloom makes it read thicker than this.
local HORIZ_Z = -38.0 -- Just beyond the sun, so the sun stands in front of it.
local HORIZ_D = 2.2 -- Degrees below eye level the horizon is drawn at. The true one is
-- at eye level, infinitely far away, and the grid fades into the fog
-- well before it; dropping the line to where the grid actually ends
-- closes the gap that would otherwise sit under the bar.
local CAM_Y = 2.7 -- Eye height.
local CAM_Z = 6.0
local font = fontLoad("Singe/FreeSansBold.ttf", 18)
local frames = 0
fontSelect(font)
sceneEnable(true)
sceneSetBackground(FOG_R, FOG_G, FOG_B, 255)
sceneSetAmbient(0, 0, 0)
sceneSetFog(FOG_R, FOG_G, FOG_B, FOG_NEAR, FOG_FAR)
sceneSetBloom(0.25, 0.9)
sceneSetTonemap(TONEMAP_NEUTRAL)
-- One flat quad in the XZ plane, four corners in order, appended to a mesh being built.
local function quad(p, i, x0, z0, x1, z1, x2, z2, x3, z3)
local base = #p / 3
table.insert(p, x0) table.insert(p, 0) table.insert(p, z0)
table.insert(p, x1) table.insert(p, 0) table.insert(p, z1)
table.insert(p, x2) table.insert(p, 0) table.insert(p, z2)
table.insert(p, x3) table.insert(p, 0) table.insert(p, z3)
for _, n in ipairs({ 1, 2, 3, 1, 3, 4 }) do
table.insert(i, base + n)
end
end
-- The height at z that lies on the horizon line, so the bar and the sun's cut land on the same row
-- of the screen even though they are at different depths.
local function horizonY(z)
return CAM_Y - (CAM_Z - z) * math.tan(math.rad(HORIZ_D))
end
-- How much wider than its near end a line is at z, so that it holds its width on screen. A line of
-- constant width in the world thins as it recedes, and once it covers less than a pixel it breaks
-- into a dotted mess that crawls as the grid moves; widening it with distance is what stops that.
local function spread(z)
return 1 + (-z) / SPREAD
end
-- The grid: lines running away from the camera, and cross lines one cell beyond each end so the
-- whole thing can be slid by one cell and wrapped without a seam.
local function gridMesh()
local p = {}
local i = {}
local far = -DEPTH * CELL
local wide = HALF_X * CELL
local hN = LINE_W / 2
local hF = hN * spread(far)
for n = -HALF_X, HALF_X do
local x = n * CELL
quad(p, i, x - hN, CELL, x + hN, CELL, x + hF, far, x - hF, far)
end
for n = -1, DEPTH do
local z = -n * CELL
local h = CROSS_W / 2 * spread(z)
quad(p, i, -wide, z + h, wide, z + h, wide, z - h, -wide, z - h)
end
return meshNew(p, nil, nil, i)
end
-- The sun: the part of a disc above yClip, as a fan from the middle of the chord. Cutting the
-- geometry is what puts the sun behind the horizon; letting the ground plane hide its lower half
-- instead only works while the ground reaches far enough, and the grid's gaps show through it.
local function sunMesh(radius, segments, yClip)
local p = { 0, yClip, 0 }
local i = {}
local a0 = math.asin(math.max(math.min(yClip / radius, 1), -1))
for n = 0, segments do
local a = a0 + (n / segments) * (math.pi - a0 * 2)
table.insert(p, math.cos(a) * radius)
table.insert(p, math.sin(a) * radius)
table.insert(p, 0)
end
for n = 1, segments do
table.insert(i, 1)
table.insert(i, n + 1)
table.insert(i, n + 2)
end
return meshNew(p, nil, nil, i)
end
-- Half the width of the sun at height y above its middle.
local function sunChord(radius, y)
return math.sqrt(math.max(radius * radius - y * y, 0))
end
local neon = materialNew()
materialSetColor(neon, 0, 0, 0)
materialSetEmissive(neon, 255, 45, 190)
materialSetDoubleSided(neon, true)
local sunMat = materialNew()
materialSetColor(sunMat, 0, 0, 0)
materialSetEmissive(sunMat, 255, 130, 55)
materialSetDoubleSided(sunMat, true)
local slotMat = materialNew()
materialSetColor(slotMat, FOG_R, FOG_G, FOG_B)
materialSetUnlit(slotMat, true)
materialSetDoubleSided(slotMat, true)
local grid = nodeNew()
nodeSetMesh(grid, gridMesh(), neon)
-- The glowing line the grid runs to. It is hotter than the grid's own magenta because the fog it
-- sits in halves it: at the grid's colour it sinks into the far rows instead of capping them.
local horizMat = materialNew()
materialSetColor(horizMat, 0, 0, 0)
materialSetEmissive(horizMat, 255, 150, 235)
materialSetDoubleSided(horizMat, true)
local horizon = nodeNew()
nodeSetMesh(horizon, meshPlane(HORIZ_W * 2, HORIZ_H), horizMat)
nodeSetRotation(horizon, 90, 0, 0)
nodeSetPosition(horizon, 0, horizonY(HORIZ_Z), HORIZ_Z)
-- The sun is cut off at the horizon line, so it stands on the bar rather than floating over it.
local sunCut = horizonY(SUN_Z) - SUN_Y
local sun = nodeNew()
nodeSetMesh(sun, sunMesh(SUN_R, 72, sunCut), sunMat)
nodeSetPosition(sun, 0, SUN_Y, SUN_Z)
-- The sun's horizontal slices, widest and thickest at the bottom. Each is cut to the chord of the
-- disc at its own height: a slice wider than the sun shows its corners, and being opaque it also
-- punches a rectangular hole in the horizon bar passing behind.
for n = 1, 6 do
local slot = nodeNew()
local h = 0.62 - (n - 1) * 0.07
local y = sunCut + 0.5 + (n - 1) * 1.3
local w = sunChord(SUN_R, math.abs(y) + h / 2)
nodeSetMesh(slot, meshPlane(w * 2, h), slotMat)
nodeSetRotation(slot, 90, 0, 0)
nodeSetPosition(slot, 0, SUN_Y + y, SUN_Z + 0.4)
end
local camera = nodeNew()
nodeSetPosition(camera, 0, CAM_Y, CAM_Z)
nodeSetRotation(camera, -2.0, 0, 0)
cameraSet(camera)
function onOverlayUpdate()
local t = singeGetTicks() / 1000.0
frames = frames + 1
nodeSetPosition(grid, 0, 0, (t * SPEED) % CELL)
overlayClear()
-- Nothing is drawn into the overlay; the menu draws its own.
if frames == 30 or frames == 48 then
singeScreenshot()
end
if frames == 60 then
singeQuit()
end
return OVERLAY_UPDATED
end