From eabc8af71687c249dc22a47a302ff58c6cc00f92 Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Thu, 30 Jul 2026 00:04:52 -0400 Subject: [PATCH] android fix for day night cycle issue --- lib/Sky.lua | 137 ++++++++++++++++++++++++++++----------- tests/sky_ramp_probe.lua | 55 ++++++++++++++++ 2 files changed, 153 insertions(+), 39 deletions(-) create mode 100644 tests/sky_ramp_probe.lua 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