DramaticShapeVoxelMod archival mirror · read-only
500556c3fcf4744f8c0224997c8d3870d0750732

iterate version to 1.2.1

DramaticShape · 2026-07-30 · parent eabc8af716

iterate version to 1.2.1

Changed files

CHANGELOG.md                  | 36 ++++++++++++++++++++++++++++++++++++
 main.lua                      |  2 +-
 manifest.json                 |  2 +-
 tests/dramatic_shape_test.lua | 52 +++++++++++++++++++++++++++++++++++++++++++++++-----
 4 files changed, 85 insertions(+), 7 deletions(-)

Diff

diff --git a/CHANGELOG.md b/CHANGELOG.md
index ca54fbd..4557c58 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,41 @@
 # Changelog
 
+## 1.2.1
+
+### Fixed
+
+- **On Android the sky went black below its first couple of bands.** A hard-edged
+  band of black ran from partway down the gradient to the horizon point, with the
+  moon still hanging correctly inside it. Desktop was unaffected.
+
+  What gave it away is that the same colour reached the screen by two routes and
+  only one of them was wrong. The haze filling the void UNDER the horizon is the
+  sky's palest band, and it is delivered by `love.graphics.clear` -- it landed
+  correctly. The bottom of the sky above it is that same band delivered by the
+  shader, and it was black. So the palette was not reaching the fragment shader,
+  and nothing was wrong with the palette, the layout or the camera.
+
+  The bands went in as `uniform vec3 bands[8]`, filled from Lua and read through
+  a loop counter, and on Android's GLSL ES the tail of that array arrived as
+  zero -- which is black. The likeliest reason is the fragment uniform budget:
+  ES 2.0 only guarantees sixteen uniform VECTORS, and eight band slots plus the
+  twilight glow plus LOVE's own built-ins is over it. A driver that truncates a
+  partly-filled array, or one that reflects `bands[0]` and nothing after it,
+  fails identically -- so the fix removes the whole class rather than the one
+  cause.
+
+  The bands are a one-texel-per-band TEXTURE now, sampled nearest, with the
+  band index clamped against the ramp's width. One texture unit replaces eight
+  uniform vectors, there is no array to index and no budget to overrun, and a
+  sample past the last band lands on the last band instead of on nothing. It is
+  still a palette and not a picture -- one texel per band on a single row -- so
+  the sky is still computed per pixel at the size it is displayed at, with
+  nothing resampled and nothing baked.
+
+  Also gone with it: `clamp(x, 0.0, 0.999999)`, which rounds its bound to 1.0 at
+  mediump -- the fragment default on GLSL ES -- and would have indexed one past
+  the last band on the sky's bottom row for the same black result.
+
 ## 1.1.1
 
 ### Fixed
diff --git a/main.lua b/main.lua
index 24f860d..4051295 100644
--- a/main.lua
+++ b/main.lua
@@ -745,7 +745,7 @@ mod.hooks:wrap("world.tod", function(next, tod, ctx)
   return DayNight.tod()
 end)
 
-mod.exports.version = "1.2.0"
+mod.exports.version = "1.2.1"
 -- exposed so a companion mod can pin its own tiles' shapes or read the
 -- camera without reaching into this mod's file layout
 mod.exports.lib = V
diff --git a/manifest.json b/manifest.json
index edf6193..7a5c712 100644
--- a/manifest.json
+++ b/manifest.json
@@ -1,7 +1,7 @@
 {
   "id": "DRAMATIC_SHAPE",
   "name": "Dramatic Shape Voxel Mod",
-  "version": "1.2.0",
+  "version": "1.2.1",
   "api": 2,
   "entry": "main.lua",
   "profile": "content",
diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua
index 89ff230..e38788a 100644
--- a/tests/dramatic_shape_test.lua
+++ b/tests/dramatic_shape_test.lua
@@ -1278,22 +1278,45 @@ T.check(Sky.SPAN > 0.1 and Sky.SPAN < 0.5,
 
 -- ------- the pass, as it is actually issued
 --
--- One rectangle through one shader: no texture, no baked image, nothing being
+-- One rectangle through one shader: no baked picture of a sky, 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.
 --
+-- The one texture bound is the band RAMP, and it is a palette rather than a
+-- picture: one texel per band, sampled nearest. It used to be a uniform array,
+-- and that is the bug this shape exists to have fixed -- on Android the array's
+-- later slots arrived as zero and painted the bottom of the sky black, while the
+-- identical colour delivered by love.graphics.clear (the haze under the horizon)
+-- landed correctly. So the assertions below pin the ramp, not an array.
+--
 -- 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 realGraphics, realImage = love.graphics, love.image
 local rects, depthCalls, sent, shaderUses = {}, {}, {}, 0
 local fakeShader = {
   send = function(_, name, a, b, c, d)
     sent[name] = { a, b, c, d }
   end,
 }
+-- enough of an image to be built and measured; the ramp only ever has pixels
+-- written into it and its dimensions read back
+local function fakeImage(w, h)
+  return {
+    pixels = {},
+    getWidth = function(self) return w end,
+    getHeight = function(self) return h end,
+    getDimensions = function(self) return w, h end,
+    setPixel = function(self, x, _, r, g, b, a)
+      self.pixels[x] = { r, g, b, a }
+    end,
+    setFilter = function() end,
+    setWrap = function() end,
+  }
+end
+love.image = { newImageData = function(w, h) return fakeImage(w, h) end }
 love.graphics = {
   getShader = function() return nil end,
   setShader = function(sh) if sh then shaderUses = shaderUses + 1 end end,
@@ -1303,14 +1326,17 @@ love.graphics = {
   end,
   setColor = function() end,
   newShader = function() return fakeShader end,
+  newImage = function(data) return data end,
   rectangle = function(_, x, y, w, h)
     rects[#rects + 1] = { x = x, y = y, w = w, h = h }
   end,
 }
+Sky.invalidate()   -- so the ramp is built through the fakes above, not held
 
 -- 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
+local ramp = Sky._rampFor(skyGrad.bands)
+love.graphics, love.image = realGraphics, realImage
 
 T.eq(painted, true, "the sky paints")
 T.eq(shaderUses, 1, "through one shader")
@@ -1327,8 +1353,24 @@ T.eq(sent.cell[1], 7,
   .. "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")
+-- The palette goes as ONE ramp texture, not as eight uniform vectors. The width
+-- is the contract the shader divides by: it samples texel (i + 0.5) / count, so
+-- a ramp of any other width reads between two bands or off the end -- and off
+-- the end is exactly the black the Android bug painted.
+T.eq(sent.ramp[1], ramp, "the palette goes to the shader as its ramp texture")
+T.eq(ramp:getWidth(), #skyGrad.bands, "one texel per band, and no spare slots")
+T.eq(ramp:getHeight(), 1, "on a single row -- it is a palette, not a picture")
+T.eq(Sky._rampFor(skyGrad.bands), ramp,
+  "and it is built once and held, not rebuilt per frame")
+-- every texel is a real colour: the failure being fixed here is a slot that
+-- was never written reading back as zero, which is black
+for i = 1, #skyGrad.bands do
+  local texel = ramp.pixels[i - 1]
+  T.check(texel ~= nil, "band " .. i .. " was written into the ramp")
+  T.check(texel[1] == skyGrad.bands[i][1] and texel[2] == skyGrad.bands[i][2]
+          and texel[3] == skyGrad.bands[i][3],
+    "and it is that band's own colour, in the order the sky reads them")
+end
 
 T.eq(depthCalls[1], "always/false", "the sky is drawn with depth writes OFF")
 T.eq(depthCalls[#depthCalls], "lequal/true",