mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 15:00:54 +02:00
408 lines
17 KiB
Lua
408 lines
17 KiB
Lua
-- The sky, generated rather than shipped.
|
|
--
|
|
-- The overworld's, on every VOXEL rung. Wherever the diorama is drawn the void
|
|
-- behind it is sky rather than a black plate: at 75 degrees the horizon is
|
|
-- genuinely in frame and the bands run down to meet it, and at the steeper rungs
|
|
-- the void that shows is the ground running out past the map edge, which gets
|
|
-- the same sky above the same haze. A battle's placed camera keeps the flat fill
|
|
-- it has always had -- its horizon is above the frame and its look is not this
|
|
-- rung's to change.
|
|
--
|
|
-- THE RECIPE is the 8-bit skybox one: a short palette of blues painted as flat
|
|
-- horizontal bands, deepest overhead, with a CHECKERBOARD of the next band
|
|
-- dithered into the bottom of each one. Alternating two colours on a pixel grid
|
|
-- is how a machine with four colours to a palette got a fifth, sixth and seventh
|
|
-- out of them, and it is what keeps four bands reading as a gradient rather than
|
|
-- as four stripes. No clouds, nothing moving.
|
|
--
|
|
-- NOTHING IS RESAMPLED, which is the whole of why it is drawn this way. There is
|
|
-- no baked 160x144 picture scaled up to the window, no downsized buffer blown
|
|
-- back up, no texture of any kind: one full-region rectangle through a shader
|
|
-- that answers every pixel from its own canvas coordinate. A pixel of sky is
|
|
-- computed at the size it is displayed at, so there is nothing for a filter to
|
|
-- soften and nothing to go stale when the window or the zoom changes.
|
|
--
|
|
-- THE PIXEL GRID follows the zoom for the same reason. Bands and dither cells
|
|
-- are measured in DIORAMA pixels -- the pass's own pixels-per-world-pixel, handed
|
|
-- in fresh every frame -- so a chunky sky at 4x is a chunky sky at 12x, band
|
|
-- edges land on the same grid the world's own texels do, and a ZOOM keypress is
|
|
-- reflected in the frame that follows it rather than whenever something else
|
|
-- happened to rebuild.
|
|
--
|
|
-- PALETTE ORDER, which is easy to get wrong. Stored LIGHTEST FIRST, because that
|
|
-- is shade order: a display mode transforms a four-colour palette by replacing it
|
|
-- outright (PaletteFX.effectiveColors hands back GRAYS or CLASSIC), and those are
|
|
-- written light to dark. So the sky reads the list backwards -- deepest shade
|
|
-- overhead, shade 1 at the horizon -- and GRAY gets greys the right way up for
|
|
-- nothing.
|
|
--
|
|
-- WHAT TIME IT IS decides the colours. The palette itself lives in DayNight
|
|
-- (four phase palettes, blended along the clock and re-quantised to the
|
|
-- lattice), and this file paints whatever the clock says: blue at noon, gold
|
|
-- and violet through the twilights -- warmed further around the low sun by a
|
|
-- dithered GLOW -- and deep navy under the moon. The sun and moon themselves
|
|
-- hang here too: cell-art discs on the same grid as the dither, scissored to
|
|
-- the sky's own region so a setting body slips below the horizon point and is
|
|
-- gone, never wandering under the map.
|
|
|
|
-- the mod namespace (see main.lua): V.require loads a sibling module
|
|
local V = ...
|
|
|
|
local DayNight = V.require("DayNight")
|
|
local PaletteFX = require("src.render.PaletteFX")
|
|
|
|
local unpack = table.unpack or unpack
|
|
|
|
local Sky = {}
|
|
|
|
-- The shader carries a fixed-size array, because a GLSL uniform array is a
|
|
-- fixed size; eight leaves headroom over DayNight's six-band phase palettes
|
|
-- without paying for more.
|
|
Sky.MAX_BANDS = 8
|
|
|
|
-- The checkerboard between bands. DITHER_START is how far down a band it begins,
|
|
-- as a fraction of that band: lower is a wider blend, and 1 switches it off. 0.6
|
|
-- leaves the top of each band flat -- a band dithered all the way through reads
|
|
-- as one averaged colour instead of as a step with a soft bottom edge.
|
|
Sky.DITHER = true
|
|
Sky.DITHER_START = 0.6
|
|
|
|
-- How much of the frame the bands cover when the horizon is NOT in it, as a
|
|
-- fraction of the canvas height.
|
|
--
|
|
-- At the steeper rungs the camera looks down far enough that the ground plane's
|
|
-- vanishing line is above the top edge -- there is no horizon to hang the pale
|
|
-- end on, but there is still void up there where the map runs out, and it should
|
|
-- read as sky. So the bands take the same slice of the frame the top rung's own
|
|
-- horizon gives them, which keeps the sky looking like one sky across the whole
|
|
-- ladder instead of changing character rung by rung.
|
|
Sky.SPAN = 0.23
|
|
|
|
-- ------- the bands
|
|
--
|
|
-- Top first, each a { r, g, b } in 0..1, as the display mode has them.
|
|
--
|
|
-- Memoised, because this runs once a frame and the answer only moves when the
|
|
-- mode does.
|
|
local cache = { bands = nil, key = {} }
|
|
|
|
function Sky.bands()
|
|
local pal = DayNight.palette()
|
|
local shades = PaletteFX.effectiveColors(pal) or pal
|
|
local n = math.min(#shades, #pal, Sky.MAX_BANDS)
|
|
local key, k = cache.key, 0
|
|
local same = cache.bands ~= nil and #cache.bands == n
|
|
for i = 1, n do
|
|
local c = shades[i]
|
|
for ch = 1, 3 do
|
|
k = k + 1
|
|
if key[k] ~= c[ch] then same = false end
|
|
key[k] = c[ch]
|
|
end
|
|
end
|
|
if same then return cache.bands end
|
|
|
|
local bands = {}
|
|
for i = 1, n do
|
|
-- backwards: the palette's darkest rung is the top band
|
|
local c = shades[n - i + 1]
|
|
bands[i] = { c[1] / 255, c[2] / 255, c[3] / 255 }
|
|
end
|
|
cache.bands = bands
|
|
return bands
|
|
end
|
|
|
|
-- The hour's haze -- the palest band, in 0..1 -- which is both the sky's
|
|
-- bottom edge and the right flat fill for any outdoor void that wants to
|
|
-- match the clock without painting bands (the battle arena's backdrop).
|
|
function Sky.haze()
|
|
local bands = Sky.bands()
|
|
return bands and bands[#bands] or nil
|
|
end
|
|
|
|
-- Put the sky onto a flat descriptor: the bands to paint, plus the flat fill
|
|
-- replaced by the palest of them. That fill is what the caller CLEARS to, so
|
|
-- making it the bottom band's own colour means the haze below the sky and the
|
|
-- bottom of the sky are one colour -- the join has no seam, and a frame that
|
|
-- cannot paint the bands is a hazy sky rather than a wrong one.
|
|
--
|
|
-- Mutates the descriptor, which is a fresh table per frame from its caller.
|
|
function Sky.dress(sky)
|
|
local bands = Sky.bands()
|
|
local haze = bands and bands[#bands]
|
|
if not (sky and haze) then return sky end
|
|
sky[1], sky[2], sky[3] = haze[1], haze[2], haze[3]
|
|
sky.bands = bands
|
|
return sky
|
|
end
|
|
|
|
-- Where the sky's bottom edge goes, in canvas pixels: the camera's own horizon
|
|
-- when that is in frame, and SPAN of the frame when it is not (see SPAN). nil
|
|
-- when there is no room for any of it.
|
|
function Sky.region(h, horizonY)
|
|
if not (h and h > 0) then return nil end
|
|
local edge = horizonY
|
|
if not (edge and edge > 0) then edge = h * Sky.SPAN end
|
|
edge = math.min(edge, h)
|
|
if edge < 1 then return nil end
|
|
return edge
|
|
end
|
|
|
|
-- ------- the pass
|
|
--
|
|
-- One rectangle, one shader, no texture. Every pixel answers for itself from its
|
|
-- canvas coordinate, so the sky is drawn at exactly the resolution it is
|
|
-- displayed at -- there is no image being scaled and so nothing to be soft.
|
|
--
|
|
-- `cell` quantises BOTH the band edges and the dither: the y a pixel is judged
|
|
-- by is the top of its own cell row, so a whole cell row is one colour and every
|
|
-- edge in the sky lands on the diorama's pixel grid.
|
|
local SHADER_SRC = [[
|
|
#define MAXB %d
|
|
uniform vec3 bands[MAXB];
|
|
uniform int count;
|
|
uniform float edge; // the sky's bottom, in canvas pixels
|
|
uniform float cell; // the diorama's pixel size, in canvas pixels
|
|
uniform float start; // where the checker begins inside a band
|
|
uniform float alpha;
|
|
uniform float glowAmt; // twilight warmth around the low sun; 0 = none
|
|
uniform vec2 glowPos; // the sun disc, in canvas pixels
|
|
uniform float glowInvR; // 1 / the glow's reach
|
|
uniform vec3 glowColor;
|
|
|
|
// Indexed through a loop counter, which every GLSL ES compiler accepts for a
|
|
// uniform array; a bare bands[idx] is not portable.
|
|
vec3 bandAt(int idx) {
|
|
vec3 c = bands[0];
|
|
for (int i = 1; i < MAXB; i++) {
|
|
if (i == idx) { c = bands[i]; }
|
|
}
|
|
return c;
|
|
}
|
|
|
|
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
|
|
float n = float(count);
|
|
float row = floor(sc.y / cell) * cell; // top of this cell row
|
|
float pos = clamp(row / max(edge, 1.0), 0.0, 0.999999) * n;
|
|
float base = floor(pos);
|
|
int idx = int(base);
|
|
vec3 c = bandAt(idx);
|
|
float parity = mod(floor(sc.x / cell) + floor(sc.y / cell), 2.0);
|
|
if (idx < count - 1 && (pos - base) > start) {
|
|
if (parity < 0.5) { c = bandAt(idx + 1); }
|
|
}
|
|
// The sunset's warmth, radiating from the disc: posterised to a few rungs
|
|
// and checker-dithered between them -- the same 8-bit move as the bands,
|
|
// so the glow reads as painted light rather than as a smooth airbrush --
|
|
// and measured cell-to-cell, so its rings ride the diorama's own grid.
|
|
if (glowAmt > 0.0) {
|
|
vec2 cc = (floor(sc / cell) + 0.5) * cell;
|
|
float d = length(cc - glowPos) * glowInvR;
|
|
float g = glowAmt * pow(clamp(1.0 - d, 0.0, 1.0), 2.0);
|
|
float lvl = floor(g * 4.0);
|
|
if (g * 4.0 - lvl > 0.5 && parity < 0.5) { lvl += 1.0; }
|
|
c = mix(c, glowColor, min(lvl / 3.0, 1.0) * 0.65);
|
|
}
|
|
return vec4(c, alpha);
|
|
}
|
|
]]
|
|
|
|
local shader = nil -- nil = untried, false = unavailable
|
|
|
|
local function getShader()
|
|
if shader == nil then
|
|
shader = false
|
|
if love.graphics and love.graphics.newShader then
|
|
local ok, sh = pcall(love.graphics.newShader,
|
|
SHADER_SRC:format(Sky.MAX_BANDS))
|
|
if ok and sh then
|
|
shader = sh
|
|
elseif V and V.mod and V.mod.log then
|
|
-- once, and only where it can be read: the fallback below is a sky
|
|
-- without its dither, which is easy to look at and impossible to
|
|
-- diagnose without this line
|
|
V.mod.log:warn("sky shader did not compile: %s -- the bands draw flat, "
|
|
.. "with no dither between them", tostring(sh))
|
|
end
|
|
end
|
|
end
|
|
return shader or nil
|
|
end
|
|
|
|
Sky._getShader = getShader -- named for the suite
|
|
|
|
-- The flat fallback: the same bands as solid rectangles, no checker, on the same
|
|
-- quantised edges. For a driver that could not compile the shader -- which is
|
|
-- also every headless run.
|
|
local function paintFlat(w, h, bands, edge, alpha, cell)
|
|
local g = love.graphics
|
|
local n = #bands
|
|
local prev = 0
|
|
for i = 1, n do
|
|
local cut = (i == n) and math.min(h, math.ceil(edge))
|
|
or math.floor(i / n * edge / cell + 0.5) * cell
|
|
cut = math.max(prev, math.min(cut, math.min(h, math.ceil(edge))))
|
|
if cut > prev then
|
|
local c = bands[i]
|
|
g.setColor(c[1], c[2], c[3], alpha)
|
|
g.rectangle("fill", 0, prev, w, cut - prev)
|
|
end
|
|
prev = cut
|
|
end
|
|
end
|
|
|
|
-- ------- the discs
|
|
--
|
|
-- The sun and moon, as cell art: a circle of whole diorama cells with a
|
|
-- lighter core, a dithered rim, and -- for the moon -- a few fixed crater
|
|
-- cells. Drawn as plain rectangles on the same grid as the sky's own dither,
|
|
-- through the same display-mode transform as every palette here, and
|
|
-- SCISSORED to the sky's region: the horizon point is where a setting body
|
|
-- disappears, so it can never hang under the map at a high pitch.
|
|
--
|
|
-- SIZED BY THE FRAME, not by the world: a celestial body's apparent size is
|
|
-- an angle, so zooming the ground in and out must not swell and shrink the
|
|
-- sun with it. The radius is a fraction of the frame height, converted to
|
|
-- whole cells so the disc still sits on the diorama's grid -- chunky cells
|
|
-- up close, fine ones at survey zoom, the same size body either way.
|
|
Sky.DISC_FRAC = 0.030 -- disc radius, as a fraction of the frame height
|
|
Sky.DISC_MIN = 3 -- but never fewer cells than this across a radius
|
|
|
|
-- crater centres as fractions of the radius, so they ride any disc size
|
|
local MOON_CRATERS = { { -0.4, -0.2 }, { 0.2, 0.45 }, { 0.5, -0.4 },
|
|
{ -0.15, 0.7 }, { 0.05, 0.05 } }
|
|
|
|
local function paintDisc(body, edge, cell, w, h)
|
|
local g = love.graphics
|
|
if not (body and body.y and g.setScissor) then return end
|
|
local src = body.moon and DayNight.MOON_COLORS or DayNight.SUN_COLORS
|
|
local shades = PaletteFX.effectiveColors(src) or src
|
|
local twilight = (body.glowAmt or 0) > 0.25 and not body.moon
|
|
local r = math.max(Sky.DISC_MIN,
|
|
math.floor(h * Sky.DISC_FRAC / cell + 0.5))
|
|
-- the low sun looms: the classic sunset exaggeration, and it reads
|
|
if twilight then r = r + math.max(1, math.floor(r * 0.4)) end
|
|
-- snap the centre to the cell grid, like everything else in this sky
|
|
local bx = math.floor(body.x / cell) * cell + cell / 2
|
|
local by = math.floor(body.y / cell) * cell + cell / 2
|
|
if by - r * cell > edge then return end -- wholly below the horizon point
|
|
local core = shades[1]
|
|
local main = shades[twilight and 3 or 2]
|
|
local sx, sy, sw, sh = g.getScissor()
|
|
g.setScissor(0, 0, math.ceil(w), math.floor(edge))
|
|
local craterR = math.max(1, math.floor(r / 5))
|
|
for dy = -r, r do
|
|
for dx = -r, r do
|
|
local d = math.sqrt(dx * dx + dy * dy)
|
|
if d <= r + 0.1 then
|
|
local c = d <= r * 0.5 and core or main
|
|
-- dithered rim: the outer ring keeps only one parity of its cells
|
|
local keep = d <= r - 0.9 or (dx + dy) % 2 == 0
|
|
if body.moon then
|
|
for _, cr in ipairs(MOON_CRATERS) do
|
|
local cdx = dx - math.floor(cr[1] * r + 0.5)
|
|
local cdy = dy - math.floor(cr[2] * r + 0.5)
|
|
if cdx * cdx + cdy * cdy <= craterR * craterR then
|
|
c = shades[3]
|
|
end
|
|
end
|
|
end
|
|
if keep then
|
|
g.setColor(c[1] / 255, c[2] / 255, c[3] / 255, 1)
|
|
g.rectangle("fill", bx + dx * cell - cell / 2,
|
|
by + dy * cell - cell / 2, cell, cell)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
if sx then g.setScissor(sx, sy, sw, sh) else g.setScissor() end
|
|
g.setColor(1, 1, 1, 1)
|
|
end
|
|
|
|
-- Paint the sky into the bound canvas, filling it from the top edge down to
|
|
-- `horizonY` (or to SPAN of the frame when the horizon is out of it).
|
|
--
|
|
-- `cell` is the diorama's pixel size in canvas pixels -- the pass's own
|
|
-- pixels-per-world-pixel, handed in every frame so a zoom lands immediately.
|
|
--
|
|
-- `body` is the sun or moon to hang, already projected to canvas pixels by
|
|
-- the caller's own camera (Voxel3D.skyBody), with the twilight glow riding
|
|
-- along; nil hangs nothing and warms nothing.
|
|
--
|
|
-- Returns false when there is nothing to paint, in which case the caller's flat
|
|
-- fill is the whole sky. That fill is the palest band, so a frame that declines
|
|
-- this looks like a hazy day rather than like a bug.
|
|
function Sky.paint(w, h, sky, horizonY, cell, body)
|
|
local bands = sky and sky.bands
|
|
if not (bands and bands[1]) then return false end
|
|
if not (w and h and w > 0 and h > 0) then return false end
|
|
local g = love.graphics
|
|
if not (g and g.rectangle) then return false end
|
|
local edge = Sky.region(h, horizonY)
|
|
if not edge then return false end
|
|
local alpha = sky[4] or 1
|
|
cell = math.max(1, math.floor((cell or 1) + 0.5))
|
|
|
|
-- State to put aside. The scene's shader is one, and the blend mode another --
|
|
-- a pass that left "replace" behind would make the fade-in strength meaningless
|
|
-- -- but the DEPTH MODE is the one that would break the frame: a rectangle
|
|
-- drawn under the pass's own ("lequal", true) stamps itself across the depth
|
|
-- buffer at the near plane and hides the entire world behind the sky.
|
|
local prevShader = g.getShader and g.getShader() or nil
|
|
local cmp, write
|
|
if g.getDepthMode then cmp, write = g.getDepthMode() end
|
|
if g.setDepthMode then g.setDepthMode("always", false) end
|
|
local blend, blendAlpha
|
|
if g.getBlendMode then blend, blendAlpha = g.getBlendMode() end
|
|
if g.setBlendMode then g.setBlendMode("alpha") end
|
|
|
|
local glowAmt = body and not body.moon and (body.glowAmt or 0) or 0
|
|
local sh = getShader()
|
|
if sh then
|
|
local sent = pcall(function()
|
|
-- one send per band would be one uniform lookup per band; the array takes
|
|
-- them all at once, and it must be the LAST argument or Lua truncates the
|
|
-- unpack to a single value
|
|
sh:send("bands", unpack(bands))
|
|
sh:send("count", #bands)
|
|
sh:send("edge", edge)
|
|
sh:send("cell", cell)
|
|
sh:send("start", Sky.DITHER and Sky.DITHER_START or 2)
|
|
sh:send("alpha", alpha)
|
|
sh:send("glowAmt", glowAmt)
|
|
if glowAmt > 0 then
|
|
local gc = body.glowColor or { 248, 224, 168 }
|
|
sh:send("glowPos", { body.x, body.y })
|
|
sh:send("glowInvR", 1 / math.max(1, w * 0.55))
|
|
sh:send("glowColor", { gc[1] / 255, gc[2] / 255, gc[3] / 255 })
|
|
end
|
|
end)
|
|
if sent then
|
|
g.setShader(sh)
|
|
g.setColor(1, 1, 1, 1)
|
|
g.rectangle("fill", 0, 0, w, math.min(h, math.ceil(edge)))
|
|
g.setShader()
|
|
else
|
|
sh = nil
|
|
end
|
|
end
|
|
if not sh then paintFlat(w, h, bands, edge, alpha, cell) end
|
|
-- the disc goes over the glow, under nothing: plain rectangles, so it is
|
|
-- there whether or not the shader built
|
|
paintDisc(body, math.min(h, edge), cell, w, h)
|
|
g.setColor(1, 1, 1, 1)
|
|
|
|
if g.setBlendMode and blend then g.setBlendMode(blend, blendAlpha) end
|
|
if g.setDepthMode then g.setDepthMode(cmp or "always", write or false) end
|
|
if prevShader and g.setShader then g.setShader(prevShader) end
|
|
return true
|
|
end
|
|
|
|
-- Drop the compiled shader (window resize, hot reload), so a re-created graphics
|
|
-- context builds a new one instead of drawing with a handle from the old.
|
|
function Sky.invalidate()
|
|
shader = nil
|
|
end
|
|
|
|
return Sky
|