add skybox
DramaticShape · 2026-07-29 ·
parent c3ba10e17a
add skybox
Changed files
CHANGELOG.md | 40 ++++++++++
README.md | 26 +++++++
lib/Voxel3D.lua | 56 +++++++++++++-
lib/VoxelScene.lua | 68 ++++++++++++-----
tests/dramatic_shape_test.lua | 259 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
5 files changed, 418 insertions(+), 31 deletions(-)
Diff
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f1fe661..f26651c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/README.md b/README.md
index f5c8173..695444a 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/lib/Voxel3D.lua b/lib/Voxel3D.lua
index aa41aff..61951ea 100644
--- a/lib/Voxel3D.lua
+++ b/lib/Voxel3D.lua
@@ -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
diff --git a/lib/VoxelScene.lua b/lib/VoxelScene.lua
index 19724f8..9df470a 100644
--- a/lib/VoxelScene.lua
+++ b/lib/VoxelScene.lua
@@ -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
diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua
index b2f24d6..dd0a5ad 100644
--- a/tests/dramatic_shape_test.lua
+++ b/tests/dramatic_shape_test.lua
@@ -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()