add skybox

This commit is contained in:
DramaticShape
2026-07-29 19:53:12 -04:00
parent c3ba10e17a
commit 8dc046f72e
5 changed files with 418 additions and 31 deletions
+40
View File
@@ -16,6 +16,46 @@
### Added
- **A gradient sky behind the diorama, on every `VOXEL` rung.** The void behind
the world used to be a black plate at every rung but the top, where it became
one flat blue -- enough while that void was a sliver, and a wall of paint once
the horizon came into frame.
It is the 8-bit skybox recipe now: four blues painted as flat horizontal bands,
deepest overhead and palest at the bottom, with a CHECKERBOARD of the next band
dithered into the bottom 40% of each one. Alternating two colours on a pixel
grid is how a machine with four 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. Every channel of the palette is a multiple of 8 -- where a
five-bit GBC channel lands -- so no colour in it is one the hardware could not
have shown. No clouds, nothing moving.
Where the bands END is the camera's own answer. At `75` the ground plane's
vanishing line is genuinely in frame -- projected through the same matrix the
geometry is drawn with -- and the pale end meets it. At the steeper rungs that
line is above the top edge, and what shows up there is the ground running OUT
past the map edge instead, so the bands take a fixed slice of the frame and the
haze fills the rest. One sky across the whole ladder either way.
**Nothing is resampled**, which is why it is drawn the way it is: no baked
160x144 image scaled up to the window, no downsized buffer blown back up, no
texture at all. One rectangle through a shader answers every pixel from its own
canvas coordinate, so a pixel of sky is computed at the size it is displayed
at and there is nothing for a filter to soften. The band edges and the dither
cells are measured in the pass's own pixels-per-world-pixel, handed in fresh
every frame -- so a `ZOOM` keypress is reflected in the frame that follows it,
with nothing cached at the old scale, and the sky's grid is the same grid the
world's own texels sit on.
The palette goes through the display-mode transform like every other palette in
this mod, so GRAY gets four greys and CLASSIC four greens. Below the bands the
void is filled with the palest of them -- which is also what the bottom band
ends on -- so the join has no seam, and a driver that cannot compile the shader
gets flat bands and a logged line rather than a wrong sky.
The overworld only. A battle is a staged shot whose placed camera has the
horizon above the frame, so the arena keeps exactly the flat sky it had.
- **A fade out of a battle, where there used to be a hard cut.** The engine
wipes INTO a fight with one of the original's eight transitions and cuts
straight out of it: `BattleState:finish` pops itself and the map is simply
+26
View File
@@ -38,6 +38,32 @@ menu.
**3D-BTL** is on by default and is independent of **VOXEL**: battles draw
on the world whether or not the free-roam camera is pitched over.
The void behind the diorama is a **gradient sky** at every **VOXEL** rung:
four blues as flat horizontal bands, deepest overhead and palest at the
bottom, with a checkerboard of the next band dithered into the bottom of each
one — the 8-bit way to get more colours out of a palette than it holds. Every
channel is a multiple of 8, where a five-bit GBC channel lands. No clouds, and
no image asset.
At `75` the camera is pitched far enough over that the ground plane's vanishing
line is in frame, and the pale end meets it; at the steeper rungs that line is
above the top edge, so the bands take a fixed slice of the frame and the haze
below them fills the void where the ground runs out. Nothing is resampled —
no baked 160×144 picture scaled up, no texture at all, just one rectangle
through a shader that answers each pixel from its own coordinate — and the
band edges and dither cells are measured in the diorama's own pixels, handed
in every frame, so a `ZOOM` change lands immediately and stays crisp at any
window size. The palette goes through the display-mode transform like
everything else here, so GRAY gets four greys and CLASSIC four greens.
Overworld only — a battle's placed camera looks up from under the horizon and
keeps its flat sky.
Tunables live at the top of `lib/Sky.lua`: `Sky.PALETTE` (lightest first; its
length is the band count), `Sky.DITHER`, `Sky.DITHER_START` (how far down each
band the checker begins — lower is a wider blend, `1` switches it off), and
`Sky.SPAN` (how much of the frame the bands cover on the rungs whose horizon is
off-screen).
While a battle can be staged on the map — **3D-BTL** on, or **VOXEL** on
`FULL`, which owns that row — the engine's **BATTLE LAYOUT** row is set to
`OG` and taken off the OPTIONS menu. The staged shot is composed in the Game
+55 -1
View File
@@ -30,6 +30,7 @@ local Voxel = V.require("VoxelState")
local ShadowMap = V.require("ShadowMap")
local VoxelGrid = V.require("VoxelGrid")
local WorldCurve = V.require("WorldCurve")
local Sky = V.require("Sky")
local Voxel3D = {}
@@ -344,6 +345,9 @@ function Voxel3D.viewProjection(cx, cy, vw, vh)
if cam then
local eye, focus = cam.eye, cam.focus
Voxel3D.eye = eye
-- kept beside the eye for horizonY: where the sky's pale end goes is a
-- question about which way this camera looks, and only these two answer it
Voxel3D.focus = focus
local dx = eye[1] - focus[1]
local dy = eye[2] - focus[2]
local dz = eye[3] - focus[3]
@@ -369,6 +373,7 @@ function Voxel3D.viewProjection(cx, cy, vw, vh)
local eye = { cx, dist * math.cos(a), cy + dist * math.sin(a) }
-- exposed for camera-facing billboards (VoxelScene yaws sprites at it)
Voxel3D.eye = eye
Voxel3D.focus = focus
-- perpendicular to the view direction in the YZ plane: north is screen-up
-- when looking straight down, +Y is screen-up when looking level. Never
-- parallel to the view direction, so there is no degenerate a = 0 case.
@@ -386,6 +391,42 @@ function Voxel3D.viewProjection(cx, cy, vw, vh)
return Mat4.mul(proj, Mat4.lookAt(eye, focus, up))
end
-- ------- the horizon
--
-- Where the ground plane's vanishing line lands, in canvas pixels down from the
-- top edge, or nil when this camera has no horizon to find.
--
-- Not a fraction picked by eye. A direction ALONG the ground is a point at
-- infinity, and putting one through the same matrix the geometry is drawn with
-- gives the line every ground plane in the scene converges on -- so the sky's
-- pale end meets the horizon at any pitch, fov, window shape or zoom, and rides
-- the camera tween instead of having to be retuned against it.
--
-- The world CURVE is not in it, and cannot be: it bends distant ground down in
-- the vertex shader, so the ground's apparent edge sits BELOW this line by
-- however much the bend took. What shows in between is the haze the sky's fill
-- already is, which is what a curved-away horizon should look like.
--
-- nil in two cases, both meaning "no horizon in this frame": a camera looking
-- straight down, whose forward direction has no horizontal part to send to
-- infinity, and one whose vanishing line is behind it.
function Voxel3D.horizonY(h)
local m, eye, focus = Voxel3D.vp, Voxel3D.eye, Voxel3D.focus
if not (m and eye and focus and h and h > 0) then return nil end
local dx = focus[1] - eye[1]
local dz = focus[3] - eye[3]
local len = math.sqrt(dx * dx + dz * dz)
if len < 1e-6 then return nil end
dx, dz = dx / len, dz / len
-- a DIRECTION, so its w is zero and the matrix's translation column drops
-- out; the clip-space Y flip is already baked into m, so this comes out in
-- canvas coordinates rather than needing one
local y = m[5] * dx + m[7] * dz
local w = m[13] * dx + m[15] * dz
if w <= 1e-6 then return nil end
return (y / w * 0.5 + 0.5) * h
end
-- ----------------------------------------------------------------- scene --
-- Begin the 3D pass into a `w` x `h` pixel canvas centred on world
@@ -426,8 +467,20 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot)
pcall(love.graphics.setCanvas)
return false
end
-- Ahead of the clear, because the sky's bands are placed off the ground
-- plane's vanishing line and that is a property of this matrix.
Voxel3D.vp = Voxel3D.viewProjection(cx, cy, vw, vh)
if sky then
love.graphics.clear(sky[1], sky[2], sky[3], sky[4] or 1, true, true)
-- The sky goes down here, in the one window in this function where a
-- rectangle is just a rectangle: the depth mode and the scene shader are
-- both set below. Sky.paint puts them aside anyway -- beginScene is not the
-- only thing that has ever left a shader bound.
--
-- w / vw is this frame's pixels per WORLD pixel, which is the size a diorama
-- pixel is on screen: the sky's dither grid is cut to that, so its squares
-- are the same size as the world's own and follow every resize and zoom.
Sky.paint(w, h, sky, Voxel3D.horizonY(h), w / math.max(1, vw or w))
else
love.graphics.clear(0, 0, 0, 0, true, true)
end
@@ -438,7 +491,6 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot)
love.graphics.setMeshCullMode("none")
love.graphics.setShader(sh)
love.graphics.setColor(1, 1, 1, 1)
Voxel3D.vp = Voxel3D.viewProjection(cx, cy, vw, vh)
pcall(sh.send, sh, "vp", "row", Voxel3D.vp)
pcall(sh.send, sh, "eye", Voxel3D.eye)
-- the sun's frame, filled by ShadowMap just before this pass opened.
@@ -766,6 +818,8 @@ function Voxel3D.invalidate()
end
canvas, canvasW, canvasH = nil, 0, 0
ShadowMap.invalidate()
-- the sky is part of this pass and holds a shader of its own
Sky.invalidate()
end
return Voxel3D
+51 -17
View File
@@ -20,6 +20,7 @@ local SpriteBillboards = V.require("SpriteBillboards")
local TileShape = V.require("TileShape")
local TerrainAtlas = V.require("TerrainAtlas")
local Voxel = V.require("VoxelState")
local Sky = V.require("Sky")
local PaletteFX = require("src.render.PaletteFX")
local Map = require("src.world.Map")
@@ -49,12 +50,16 @@ VoxelScene._modeColors = modeColors -- named for the suite
-- ------------------------------------------------------------------ sky --
--
-- At the top rung the camera is pitched far enough over that the horizon
-- comes into frame and a good part of the picture is void -- so the void
-- becomes the sky, and the diorama reads as standing under something
-- rather than floating on a black plate. Below that rung the camera looks
-- down steeply enough that the horizon is off-screen, and painting the
-- void only tints the gaps between meshes, so it stays transparent.
-- The void behind the diorama is SKY, at every rung -- so the world reads as
-- standing under something rather than floating on a black plate.
--
-- What is up there differs by rung, and the sky follows it rather than being
-- retuned for each. At 75 degrees the camera is pitched far enough over that
-- the horizon is genuinely in frame, and the bands run down to meet it. At the
-- steeper rungs the horizon is above the top edge and the void that shows is
-- where the ground runs OUT -- past the map edge, past the curve -- so the
-- bands take a fixed slice of the frame instead (lib/Sky.lua, Sky.SPAN) and the
-- haze below them fills the rest.
--
-- INDOORS THERE IS NO SKY. A house, a cave or a gym is a room with a
-- ceiling, and the void past its walls is the outside of a box, not open
@@ -67,42 +72,71 @@ VoxelScene._modeColors = modeColors -- named for the suite
-- CLASSIC a green one, GBC INV a dark one, and the colour modes the blue.
-- A hardcoded blue would sit wrong in every non-colour mode -- the same
-- mismatch the terrain bake had.
--
-- This ramp is the FLAT sky -- what a caller clears the void to. The free-roam
-- camera's banded sky has a palette of its own (lib/Sky.lua), transformed the
-- same way by the same seam; they are separate because the flat one also has to
-- serve an indoor void and a battle's arena, which want a colour rather than a
-- sky.
local SKY_SHADES = { { 222, 242, 255 }, { 135, 196, 240 },
{ 64, 120, 192 }, { 16, 40, 80 } }
local SKY_SHADE = 2 -- the ramp's "sky" proper; 1 is its highlight
-- fade across the approach to the top rung, so the sky arrives with the
-- camera tween instead of popping in on the keypress
-- the ramp as the display mode has it, which is the only form anything here
-- should be reading it in
local function skyRamp()
return PaletteFX.effectiveColors(SKY_SHADES) or SKY_SHADES
end
-- Full strength at every rung: the sky is painted wherever the diorama is.
--
-- The ramp that is left is for ARRIVAL alone. Switching the mode on eases the
-- camera up from flat, and the sky comes up with it over the first few degrees
-- rather than appearing whole on the keypress -- which is also what keeps a
-- top-down camera, where there is no void worth speaking of, from painting one.
local SKY_FADE_DEG = 8
local function skyStrength(angleRad)
local deg = math.deg(angleRad or 0)
local from = Voxel.ANGLES_DEG[Voxel.MAX_LEVEL] or 50 -- the rung below
local to = Voxel.ANGLES_DEG[Voxel.MAX_LEVEL + 1] or 75 -- the top rung
if to <= from then return deg >= to and 1 or 0 end
local t = (deg - from) / (to - from)
if t < 0 then return 0 end
if t > 1 then return 1 end
return t
if deg <= 0 then return 0 end
local t = deg / SKY_FADE_DEG
return t < 1 and t or 1
end
-- One shade off the sky ramp, transformed by the display mode, as an
-- {r, g, b, a} in 0..1. `shade` picks the rung (SKY_SHADE is the sky
-- proper; 4 is its darkest, which is what an indoor void wants).
function VoxelScene.skyShade(shade, alpha)
local shades = PaletteFX.effectiveColors(SKY_SHADES) or SKY_SHADES
local shades = skyRamp()
local c = shades[shade] or SKY_SHADES[shade] or SKY_SHADES[SKY_SHADE]
return { c[1] / 255, c[2] / 255, c[3] / 255, alpha or 1 }
end
-- The sky `map` stands under at strength `t`, or nil where there is no sky
-- to paint: indoors, or with the horizon out of frame.
--
-- One flat colour, which is what a caller that only needs something to clear the
-- void to wants -- the overworld battle's arena shot is one of those. The
-- gradient is added on top of this by skyFor, for the free-roam camera alone.
function VoxelScene.skyColor(map, t)
if not (map and map.def and Map.isOutdoor(map.def)) then return nil end
if not t or t <= 0 then return nil end
return VoxelScene.skyShade(SKY_SHADE, t)
end
-- The free-roam sky: the flat one above, dressed with the banded gradient
-- (lib/Sky.lua).
--
-- Only here, and deliberately. This is the sky the walking camera stands under,
-- where the horizon is a quarter of the way down the frame at the top rung and
-- one flat blue reads as a wall of paint. A battle is a staged shot with its own
-- placed camera whose horizon sits above the frame entirely, so it keeps the
-- flat fill it has always had -- there is no gradient to see from down there,
-- and the arena's look is not this rung's to change.
local function skyFor(map)
return VoxelScene.skyColor(map, skyStrength(Voxel.angle))
local sky = VoxelScene.skyColor(map, skyStrength(Voxel.angle))
if not sky then return nil end
return Sky.dress(sky)
end
VoxelScene._skyFor = skyFor -- named for the suite
+246 -13
View File
@@ -143,6 +143,11 @@ T.check(not fullIds["DRAMATIC_SHAPE:battles"], "and 3D-BTL")
-- lays the same battle out on a 304x144 surface and moves every one of them. So
-- the value is set and the ENGINE's row comes off the menu -- the one row this
-- mod takes away that is not its own.
--
-- Scoped in a block of its own, like the sections below: this file is one Lua
-- chunk and a chunk has 200 local slots, so a section that wants half a dozen
-- borrows them rather than spending them for the rest of the run.
do
local Battles = run.loader.exports.DRAMATIC_SHAPE.lib.require("OverworldBattle")
T.eq(Battles.enabled(), true, "3D-BTL is on by default, which is what pins it")
@@ -190,6 +195,7 @@ Runtime.call("ui.options.rows", function(_, r) return r end, layoutGame,
T.eq(layoutGame.save.options.battleLayout, "og",
"FULL pins the layout on its own, because it owns the row that would")
Battles.setting:setIndex(1, layoutGame)
end
-- ------- and off FULL, the rows come back, grouped with the mode
--
@@ -262,6 +268,8 @@ T.check(rowIndex(menu, "pipeline:tiltshift"), "T-SHIFT too")
-- follow the row it was ON rather than the slot it was in -- otherwise the very
-- press that switched staged battles on would leave the cursor a row further
-- down than the player left it.
do
local Battles = run.loader.exports.DRAMATIC_SHAPE.lib.require("OverworldBattle")
Battles.setting:setIndex(2, menuGame) -- staged battles off
menuGame.save.options.battleLayout = "wide"
Pipelines.setLevel("voxel", 2)
@@ -278,6 +286,7 @@ T.check(not rowIndex(layoutMenu, "battleLayout"),
T.eq(menuGame.save.options.battleLayout, "og", "pinned to OG on the way out")
T.eq(layoutMenu.index, rowIndex(layoutMenu, "DRAMATIC_SHAPE:battles"),
"with the cursor still on the row the player just used")
end
-- level 2 is the "15" rung: any rung that is not FULL, so the settings the
-- preset owns are back on the menu
@@ -1046,15 +1055,16 @@ local outside = { def = { id = "PALLET_TOWN", tileset = "OVERWORLD" } }
local inside = { def = { id = "REDS_HOUSE_1F", tileset = "HOUSE" } }
local TOP = math.rad(Voxel.ANGLES_DEG[Voxel.MAX_LEVEL + 1])
-- the ladder, by angle: only the top rung paints anything
-- the ladder, by angle: every rung that tilts at all paints a sky
for level = 0, Voxel.MAX_LEVEL do
Voxel.angle = math.rad(Voxel.ANGLES_DEG[level + 1])
local sky = skyFor(outside)
if level == Voxel.MAX_LEVEL then
T.check(sky ~= nil, "the 75-degree rung paints a sky outdoors")
T.eq(sky[4], 1, "and paints it at full strength")
if level == 0 then
T.eq(sky, nil, "rung 0 is the flat camera: no tilt, no void, no sky")
else
T.eq(sky, nil, "rung " .. level .. " leaves the void alone")
T.check(sky ~= nil, "rung " .. level .. " paints a sky outdoors")
T.eq(sky[4], 1, "and paints it at full strength")
T.check(sky.bands ~= nil, "with its bands on it")
end
end
@@ -1070,14 +1080,18 @@ T.eq(skyFor(inside), nil, "not even at 75 degrees, where outdoors would")
T.eq(skyFor(nil), nil, "no map, no sky")
T.eq(skyFor({}), nil, "a map with no def is not an outdoor map")
-- it fades in with the camera tween rather than popping on the keypress
T.eq(skyStrength(math.rad(50)), 0, "the rung below the top is still skyless")
T.eq(skyStrength(TOP), 1, "the top rung is full sky")
local mid = skyStrength(math.rad(62.5))
T.check(mid > 0 and mid < 1, "and the tween between them is partial")
T.check(skyStrength(math.rad(70)) > skyStrength(math.rad(60)),
"strengthening as the camera pitches over")
T.eq(skyStrength(math.rad(15)), 0, "a shallow pitch paints nothing at all")
-- full strength at every rung, with the ramp left only for the ARRIVAL: the
-- camera eases up from flat when the mode is switched on, and the sky comes up
-- with it rather than appearing whole on the keypress
T.eq(skyStrength(math.rad(15)), 1, "the shallowest rung is full sky")
T.eq(skyStrength(math.rad(50)), 1, "so is the one below the top")
T.eq(skyStrength(TOP), 1, "and the top rung")
T.eq(skyStrength(0), 0, "a camera that has not tilted at all paints none")
local rising = skyStrength(math.rad(4))
T.check(rising > 0 and rising < 1,
"and the first few degrees off flat are the fade-in")
T.check(skyStrength(math.rad(6)) > skyStrength(math.rad(3)),
"which strengthens as the camera lifts")
-- the colour answers to the display mode, exactly as the terrain does: a
-- hardcoded blue would sit wrong in the modes that are not colour modes
@@ -1104,6 +1118,221 @@ T.check(green[2] > green[1] and green[2] > green[3],
T.check(skyRGB("gbc_inv")[3] ~= blue[3],
"GBC INV does not paint the same sky as GBC")
-- ------- and the sky is a banded gradient, with no picture behind it
--
-- One flat blue was enough while the void was a sliver; with the horizon a
-- quarter of the way down the frame it is a wall of paint. So the sky is the
-- 8-bit skybox recipe: a short palette of blues painted as flat bands, deepest
-- overhead, with a checkerboard of the next band dithered into the bottom of
-- each. Nothing is baked to a fixed size and upscaled -- the bands fill the
-- window and the dither grid is cut to the diorama's own pixel scale.
do
local Sky = run.loader.exports.DRAMATIC_SHAPE.lib.require("Sky")
local Voxel3D = run.loader.exports.DRAMATIC_SHAPE.lib.require("Voxel3D")
local function luma(c) return 0.299 * c[1] + 0.587 * c[2] + 0.114 * c[3] end
-- the palette is the band list: add a colour and the sky gains a band
for i, c in ipairs(Sky.PALETTE) do
T.check(c[1] % 8 == 0 and c[2] % 8 == 0 and c[3] % 8 == 0,
"palette entry " .. i .. " is a colour a Game Boy Color could show -- five "
.. "bits a channel, so every one is a multiple of 8")
T.check(c[3] > c[1], "and it is blue: more blue than red")
end
T.eq(#Sky.PALETTE, 4, "four of them, which is one GBC background palette")
Voxel.angle = TOP
local skyGrad = skyRGB("gbc")
T.eq(#(skyGrad.bands or {}), #Sky.PALETTE,
"the sky arrives with one band per palette entry")
for i, band in ipairs(skyGrad.bands) do
if i > 1 then
T.check(luma(band) > luma(skyGrad.bands[i - 1]),
"band " .. i .. " is lighter than the one above it: the sky pales toward "
.. "the horizon")
end
end
-- read backwards out of the palette, which is stored in shade order (lightest
-- first) so a display mode's own four colours drop straight in
local deepest = Sky.PALETTE[#Sky.PALETTE]
T.check(math.abs(skyGrad.bands[1][1] - deepest[1] / 255) < 1e-9,
"the top band is the palette's deep rung, unmixed")
-- the fill a caller clears to IS the palest band, so the haze below the horizon
-- and the bottom of the sky are one colour and the horizon has no seam
local palest = skyGrad.bands[#skyGrad.bands]
T.check(math.abs(skyGrad[1] - palest[1]) < 1e-9
and math.abs(skyGrad[2] - palest[2]) < 1e-9
and math.abs(skyGrad[3] - palest[3]) < 1e-9,
"the flat fill is the palest band, so the horizon line has no seam of its own")
T.eq(skyGrad[4], 1, "and the tween strength still rides on the descriptor")
-- the gradient belongs to the VOXEL 75 rung and the walking camera on it. The
-- arena shot asks for the sky by the flat route (VoxelScene.skyColor) and gets
-- exactly the sky it always had -- its placed camera's horizon is above the
-- frame, so there would be no gradient to see from down there anyway.
local flat = VoxelScene.skyColor(outside, 1)
T.eq(flat.bands, nil, "a battle's arena sky is the flat fill, not the gradient")
T.check(luma(flat) < luma(palest),
"and it is the ramp's own sky rung, unchanged: not the gradient's pale end")
-- the same call, the same numbers, and the same TABLE: the bands are memoised
-- per display mode, so a frame that paints the sky allocates nothing to do it
local again = Sky.bands()
T.eq(Sky.bands(), again, "the bands are computed once and held, not rebuilt")
local greyBands = skyRGB("og").bands
for i, band in ipairs(greyBands) do
T.check(math.abs(band[1] - band[2]) < 1e-9
and math.abs(band[2] - band[3]) < 1e-9,
"GRAY gets four greys, not four blues (band " .. i .. ")")
end
T.check(luma(greyBands[#greyBands]) > luma(greyBands[1]),
"and they still climb toward the horizon")
-- ------- where the bands go is the camera's own answer
--
-- The pale end has to meet the horizon at any pitch, fov or window shape, so it
-- is placed on the ground plane's vanishing line -- which is what projecting a
-- direction ALONG the ground through the scene matrix gives. Checked against the
-- limit of projecting real ground points further and further away, so a retuned
-- camera either still agrees with this or says so.
local function groundY(h, dist)
local m = Voxel3D.vp
local y = m[5] * 0 + m[7] * -dist + m[8]
local w = m[13] * 0 + m[15] * -dist + m[16]
return (y / w * 0.5 + 0.5) * h
end
Voxel.angle = TOP
local vh = 288
Voxel3D.vp = Voxel3D.viewProjection(0, 0, 320, vh)
local horizon = Voxel3D.horizonY(vh)
T.check(horizon and horizon > 0 and horizon < vh,
"at the top rung the horizon is inside the frame, which is why there is a sky")
T.check(math.abs(groundY(vh, 200000) - horizon) < 0.5,
("ground at infinity converges on it: %.2f vs %.2f")
:format(groundY(vh, 200000), horizon))
T.check(groundY(vh, 200) > groundY(vh, 2000)
and groundY(vh, 2000) > horizon,
"and nearer ground is always below it, never above")
T.check(horizon < vh / 2,
"the horizon sits in the upper half: the sky is a band across the top, not "
.. "half the picture")
-- the fraction is a property of the camera, not of the canvas
Voxel3D.vp = Voxel3D.viewProjection(0, 0, 320, vh)
local tall = Voxel3D.horizonY(vh * 3)
T.check(math.abs(tall / (vh * 3) - horizon / vh) < 1e-9,
"a taller canvas puts it at the same fraction, so the bands scale with it")
Voxel.angle = 0
Voxel3D.vp = Voxel3D.viewProjection(0, 0, 320, vh)
T.eq(Voxel3D.horizonY(vh), nil,
"a camera looking straight down has no horizon to find, and paints no bands")
-- ------- where the sky's bottom edge goes at each rung
--
-- The camera's own horizon when that is in frame -- which is the top rung -- and
-- otherwise a fixed slice of the frame, because at the steeper rungs the void
-- that shows is where the ground runs OUT rather than what is above the horizon.
-- One sky across the whole ladder either way.
T.check(math.abs(Sky.region(288, 66.83) - 66.83) < 1e-9,
"a horizon in frame is where the sky ends")
T.eq(Sky.region(288, -930), 288 * Sky.SPAN,
"a horizon above the frame falls back to a fixed slice of it")
T.eq(Sky.region(288, nil), 288 * Sky.SPAN, "and so does no horizon at all")
T.eq(Sky.region(288, 4000), 288, "a horizon below the frame fills it")
T.eq(Sky.region(0, 40), nil, "and a canvas with no height paints nothing")
T.check(Sky.SPAN > 0.1 and Sky.SPAN < 0.5,
"the fallback slice is a band across the top, not half the picture")
-- ------- the pass, as it is actually issued
--
-- One rectangle through one shader: no texture, no baked image, nothing being
-- resampled -- which is the whole reason it is drawn this way rather than
-- generated once and scaled. Every pixel answers from its own canvas coordinate,
-- so it is computed at the size it is shown at.
--
-- And the depth mode is put back to what it was, which is the piece 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 whole world
-- behind the sky.
local realGraphics = love.graphics
local rects, depthCalls, sent, shaderUses = {}, {}, {}, 0
local fakeShader = {
send = function(_, name, a, b, c, d)
sent[name] = { a, b, c, d }
end,
}
love.graphics = {
getShader = function() return nil end,
setShader = function(sh) if sh then shaderUses = shaderUses + 1 end end,
getDepthMode = function() return "lequal", true end,
setDepthMode = function(cmp, write)
depthCalls[#depthCalls + 1] = tostring(cmp) .. "/" .. tostring(write)
end,
setColor = function() end,
newShader = function() return fakeShader end,
rectangle = function(_, x, y, w, h)
rects[#rects + 1] = { x = x, y = y, w = w, h = h }
end,
}
-- 320x288 canvas, horizon at 66.83, diorama pixels 7 canvas pixels square
local painted = Sky.paint(320, 288, skyGrad, 66.83, 7)
love.graphics = realGraphics
T.eq(painted, true, "the sky paints")
T.eq(shaderUses, 1, "through one shader")
T.eq(#rects, 1, "over one rectangle -- not one per band, and not one per cell")
T.eq(rects[1].x, 0, "from the left edge")
T.eq(rects[1].w, 320, "across the full width of the frame")
T.eq(rects[1].y, 0, "and from the top edge")
T.eq(rects[1].h, 67, "down to the horizon")
T.eq(sent.count[1], #skyGrad.bands, "the band count goes to the shader")
T.check(math.abs(sent.edge[1] - 66.83) < 1e-9, "with the sky's bottom edge")
T.eq(sent.cell[1], 7,
"and the diorama's pixel size, which is what puts the bands and the dither "
.. "cells on the world's own grid")
T.eq(sent.start[1], Sky.DITHER_START, "and where in a band the checker begins")
T.eq(sent.alpha[1], 1, "and the tween strength")
T.check(sent.bands[1] and sent.bands[1][1] ~= nil,
"the palette goes as one array rather than a send per band")
T.eq(depthCalls[1], "always/false", "the sky is drawn with depth writes OFF")
T.eq(depthCalls[#depthCalls], "lequal/true",
"and the pass's own depth mode is handed straight back")
-- ------- and it follows the zoom, in the frame the zoom changed
--
-- The cell size is handed in every frame rather than cached, so a ZOOM keypress
-- -- which is what changes the diorama's pixels-per-world-pixel -- lands in the
-- next frame with nothing to rebuild and nothing left over at the old scale.
local zoomed = {}
love.graphics = {
getShader = function() return nil end, setShader = function() end,
getDepthMode = function() return "lequal", true end,
setDepthMode = function() end,
setColor = function() end,
newShader = function() return fakeShader end,
rectangle = function(_, _, _, w, h) zoomed.rect = { w = w, h = h } end,
}
sent = {}
Sky.paint(1920, 1080, skyGrad, 250.6, 12)
love.graphics = realGraphics
T.eq(zoomed.rect.w, 1920, "a bigger window is filled to its own width")
T.eq(zoomed.rect.h, 251, "and its own horizon")
T.eq(sent.cell[1], 12, "with the cell size that came in with it, not a cached one")
T.eq(Sky.paint(320, 288, { 0, 0, 1, 1 }, 40, 7), false,
"a descriptor with no bands on it is the old flat sky, untouched")
T.eq(Sky.paint(320, 0, skyGrad, 40, 7), false,
"and a frame with no height paints nothing at all")
end
Voxel.angle = 0
-- ------- overworld battles: where the fight is staged
@@ -1515,6 +1744,7 @@ T.check(wide > band, "marks further apart hold a deeper slab in focus")
-- foe's to the left edge, the player's to the right. Measured in world-canvas
-- pixels, because that is the surface they are composited into -- the GB canvas
-- they are drawn in cannot reach past its own 160 columns.
do
local hudShot = { lx = 100, ly = 12, scale = 3, pw = 1000, ph = 500 }
local hudRects, bandX = Battles.snapRects(hudShot)
local hudRect = Battles.HUD_RECT
@@ -1566,6 +1796,7 @@ T.check(e[2] + e[4] <= hudRect.player[2],
"the split falls between the two blocks, so neither is cut in half")
T.eq(e[1], 0, "the bands are full width")
T.eq(e[3], 160, "so a shaken HUD or a long name is carried out with its block")
end
-- ------- the way out of a battle is a fade, not a cut
--
@@ -1577,6 +1808,7 @@ T.eq(e[3], 160, "so a shaken HUD or a long name is carried out with its block")
-- The pop ORDER is the part that has to be right: BattleState:finish pops
-- whatever is on top, which is the fade while it is up, so the fade has to be
-- off the stack before the battle finishes and back on it afterwards.
do
local Exit = run.loader.exports.DRAMATIC_SHAPE.lib.require("BattleExit")
T.eq(Data.transitions and Data.transitions[Exit.ID] and
@@ -1668,6 +1900,7 @@ while exitGame.stack:top() ~= exitOw do exitGame.stack:pop() end
T.eq(Exit.veil(), nil, "and a fade popped from under itself veils nothing")
Exit.modeOn = realModeOn
end
Pipelines.reset()
run.release()