android fix for day night cycle issue
DramaticShape · 2026-07-30 ·
parent 14f844ad8e
android fix for day night cycle issue
Changed files
lib/Sky.lua | 137 ++++++++++++++++++++++++++++++++++++++++++++++++-------------------
tests/sky_ramp_probe.lua | 55 +++++++++++++++++++++++++++
2 files changed, 153 insertions(+), 39 deletions(-)
Diff
diff --git a/lib/Sky.lua b/lib/Sky.lua
index 221e386..ec0ad2d 100644
--- a/lib/Sky.lua
+++ b/lib/Sky.lua
@@ -16,11 +16,13 @@
-- 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.
+-- no baked 160x144 picture scaled up to the window and no downsized buffer blown
+-- back up: 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 shader does bind one texture,
+-- but it is a palette rather than an image -- the bands, one texel each, sampled
+-- nearest (see rampFor, and why it is not a uniform array).
--
-- 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
@@ -51,13 +53,11 @@ 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.
+-- The most bands a phase palette may paint with. Eight leaves headroom over
+-- DayNight's six-band ones without paying for more; the ramp the shader reads
+-- them from is built at the width actually used, so the cap costs nothing.
Sky.MAX_BANDS = 8
-- The checkerboard between bands. DITHER_START is how far down a band it begins,
@@ -84,7 +84,7 @@ Sky.SPAN = 0.23
--
-- Memoised, because this runs once a frame and the answer only moves when the
-- mode does.
-local cache = { bands = nil, key = {} }
+local cache = { bands = nil, key = {}, ramp = nil }
function Sky.bands()
local pal = DayNight.palette()
@@ -102,6 +102,11 @@ function Sky.bands()
end
if same then return cache.bands end
+ -- the ramp is these bands as a texture (see rampFor); a new list is a new
+ -- ramp, and the old one is nothing's to keep
+ if cache.ramp and cache.ramp.release then pcall(cache.ramp.release, cache.ramp) end
+ cache.ramp, cache.rampFor = nil, nil
+
local bands = {}
for i = 1, n do
-- backwards: the palette's darkest rung is the top band
@@ -150,17 +155,18 @@ 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.
+-- One rectangle, one shader. 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. The one texture
+-- bound is the band ramp, which is a PALETTE and not a picture: n texels wide,
+-- sampled nearest, one lookup per pixel (see rampFor).
--
-- `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 Image ramp; // the bands, one texel each, top of the sky first
+uniform float count; // how many texels wide that ramp is
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
@@ -170,26 +176,23 @@ 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;
+// Band `i`, read from its own texel centre. The index is clamped rather than
+// trusted: `pos` below can land exactly on `count` when the arithmetic is
+// carried at mediump -- which is the fragment default on GLSL ES -- and a
+// sample past the last band must be the last band, not whatever is off the
+// end of the image.
+vec3 bandAt(float i) {
+ return Texel(ramp, vec2((clamp(i, 0.0, count - 1.0) + 0.5) / count, 0.5)).rgb;
}
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 pos = min(row / max(edge, 1.0), 1.0) * count;
+ float base = min(floor(pos), count - 1.0);
+ vec3 c = bandAt(base);
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); }
+ if (base < count - 1.0 && (pos - base) > start) {
+ if (parity < 0.5) { c = bandAt(base + 1.0); }
}
// 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,
@@ -207,14 +210,66 @@ vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
}
]]
+-- ------- the ramp
+--
+-- The bands as a one-texel-per-band TEXTURE rather than as a uniform array,
+-- which is what they used to be: `uniform vec3 bands[8]`, filled from Lua and
+-- read through a loop counter. On desktop GL that is as portable as it looks.
+-- On Android it was not. The sky's lower bands came back BLACK -- a hard-edged
+-- strip running from partway down the gradient to the horizon point, with the
+-- moon still drawn correctly over it, and with the haze BELOW the sky (the
+-- palest band again, but delivered by love.graphics.clear instead of by the
+-- array) landing in exactly the right colour. Same colour, two routes, one of
+-- them black: the fault was the array, not the palette.
+--
+-- Which of the ES failure modes it was hardly matters -- a driver that
+-- truncates a partially-filled array, a fragment uniform budget the guaranteed
+-- floor of which is sixteen vectors (eight bands plus the glow plus LOVE's own
+-- built-ins is over it), a reflection that finds bands[0] and nothing after --
+-- because they all have the same shape: slots past the first few read as zero,
+-- and zero is black.
+--
+-- A sampler has none of them. One texture unit replaces eight uniform vectors,
+-- there is no array to index and no budget to overrun, and a texel that does
+-- not exist cannot read as black because the image is built at exactly the
+-- width the shader divides by. Nearest and clamped, so a sample lands on one
+-- band's own colour and an out-of-range one lands on the end band rather than
+-- on nothing.
+--
+-- Rebuilt only when the bands move, which is when the clock or the display
+-- mode does; Sky.bands drops it as it rebuilds the list it is made from.
+local function rampFor(bands)
+ if cache.ramp and cache.rampFor == bands then return cache.ramp end
+ if not (love.image and love.image.newImageData
+ and love.graphics and love.graphics.newImage) then return nil end
+ local n = #bands
+ if n < 1 then return nil end
+ local ok, data = pcall(love.image.newImageData, n, 1)
+ if not (ok and data) then return nil end
+ for i = 1, n do
+ local c = bands[i]
+ pcall(data.setPixel, data, i - 1, 0, c[1], c[2], c[3], 1)
+ end
+ local built, img = pcall(love.graphics.newImage, data)
+ if not (built and img) then return nil end
+ -- nearest: a band is a flat colour, not something to interpolate between.
+ -- clamp: the shader clamps its index too, so this is the second of two
+ -- guards against ever sampling off the end -- and it returns the edge band.
+ pcall(img.setFilter, img, "nearest", "nearest")
+ pcall(img.setWrap, img, "clamp", "clamp")
+ cache.ramp, cache.rampFor = img, bands
+ return img
+end
+
+Sky._rampFor = rampFor -- named for the suite
+
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))
+ local ok, sh = pcall(love.graphics.newShader, SHADER_SRC)
if ok and sh then
shader = sh
elseif V and V.mod and V.mod.log then
@@ -358,12 +413,13 @@ function Sky.paint(w, h, sky, horizonY, cell, body)
local glowAmt = body and not body.moon and (body.glowAmt or 0) or 0
local sh = getShader()
+ local ramp = sh and rampFor(bands)
+ if not ramp then sh = nil end -- no ramp, no gradient: paint it flat
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))
+ -- the bands arrive as a texture, one texel each, and `count` is that
+ -- texture's width -- see rampFor for why they are not a uniform array
+ sh:send("ramp", ramp)
sh:send("count", #bands)
sh:send("edge", edge)
sh:send("cell", cell)
@@ -399,9 +455,12 @@ function Sky.paint(w, h, sky, horizonY, cell, body)
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.
+-- context builds a new one instead of drawing with a handle from the old. The
+-- ramp is a GPU object on the same context and goes with it.
function Sky.invalidate()
shader = nil
+ if cache.ramp and cache.ramp.release then pcall(cache.ramp.release, cache.ramp) end
+ cache.ramp, cache.rampFor = nil, nil
end
return Sky
diff --git a/tests/sky_ramp_probe.lua b/tests/sky_ramp_probe.lua
new file mode 100644
index 0000000..5dea5b3
--- /dev/null
+++ b/tests/sky_ramp_probe.lua
@@ -0,0 +1,55 @@
+-- Driver: prove the banded sky still paints from the ramp texture.
+--
+-- The bands used to reach the shader as `uniform vec3 bands[8]`, which on
+-- Android delivered only its first few slots and painted the rest of the sky
+-- black (see lib/Sky.lua, rampFor). They are a texture now. This checks the
+-- three things that swap could have broken, on the machine it CAN be checked
+-- on: that the shader still compiles, that the ramp is built and is one texel
+-- per band, and that what lands on screen is still a gradient that pales
+-- downward rather than a flat plate or a black one.
+--
+-- SHOT_DIR=.scratchpad/skyramp POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/sky_ramp_probe.lua love .
+return function(game)
+ local U = dofile("tests/drivers/util.lua")
+ local DIR = os.getenv("SHOT_DIR") or ".scratchpad/skyramp"
+
+ local exports = game.mods and game.mods.exports
+ local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
+ if not lib then
+ U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again")
+ return
+ end
+ local Sky = lib.require("Sky")
+ local DayNight = lib.require("DayNight")
+
+ -- which copy of the mod is actually live: only the ramp build has this
+ U.log("live copy has _rampFor:", tostring(Sky._rampFor ~= nil))
+ U.log("shader compiled:", tostring(Sky._getShader() ~= nil))
+
+ U.teleport(game, "PALLET_TOWN", 12, 10, "up")
+ require("src.render.Pipelines").setLevel("voxel", 5)
+ U.wait(150)
+
+ for _, phase in ipairs({ "day", "dusk", "night" }) do
+ DayNight.setting:sync(phase)
+ DayNight.update(0)
+ U.wait(30)
+
+ local bands = Sky.bands()
+ local ramp = Sky._rampFor and Sky._rampFor(bands)
+ local w = ramp and ramp:getWidth() or -1
+ U.log(("%s: %d bands, ramp %dx%d"):format(
+ phase, #bands, w, ramp and ramp:getHeight() or -1))
+ -- one texel per band is the whole contract: the shader divides by `count`
+ -- and samples texel centres, so a ramp of any other width samples between
+ -- bands or off the end
+ if w ~= #bands then U.log("FAIL ramp width does not match band count") end
+ -- and the ramp is the ramp: the same table gives the same image back
+ if ramp ~= Sky._rampFor(bands) then U.log("FAIL ramp rebuilt per call") end
+
+ U.shot(game, ("%s/%s.png"):format(DIR, phase))
+ end
+
+ DayNight.setting:sync("day")
+ U.log("done -- " .. DIR)
+end