DramaticShapeVoxelMod archival mirror · read-only
98f7419b729466092912a4d0e5bb352648daeed5

fix water for android glsl shaders

DramaticShape · 2026-07-31 · parent 92fef2a37e

fix water for android glsl shaders

Changed files

.github/workflows/release.yml | 200 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 CHANGELOG.md                  |  27 +++++++++
 lib/Voxel3D.lua               |  23 +++++---
 lib/Water.lua                 |  29 ++++++++-
 tests/dramatic_shape_test.lua |  19 ++++++
 tests/water_reflect_probe.lua |   3 +-
 6 files changed, 290 insertions(+), 11 deletions(-)

Diff

diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..6b36f84
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,200 @@
+name: Release
+
+# Packs the mod into an installable .zip and publishes it as a GitHub Release,
+# once per push to main.
+#
+# Archive layout: every mod file at the archive root, manifest.json included.
+# That is one of the two shapes the game accepts on MODS > Import mod .zip
+# (src/mods/LauncherMods.lua locateRoot: manifest at the root, or inside a
+# single top-level folder). Nothing else is added, so the archive stays
+# installable by hand too.
+#
+# Versioning, first rule that applies wins:
+#   1. the "version" input of a manual run,
+#   2. "[release X.Y.Z]" anywhere in the commit message,
+#   3. manifest.json's own version, when it is ahead of every existing tag,
+#      so bumping the manifest is the normal way to cut a release,
+#   4. otherwise the newest vX.Y.Z tag with its patch incremented
+#      (0.2.99 rolls over to 0.3.0).
+# Whichever wins is written into the manifest.json inside the archive, so a
+# shipped mod never reports a different version than the release it came from.
+#
+# Generated by: python3 tools/modkit.py add-release-workflow <mod-id>
+# MOD_ID below is stamped to this mod's id when the file is copied.
+
+on:
+  push:
+    branches: [master]
+    paths-ignore:
+      - '.github/**'
+      - '**.md'
+  workflow_dispatch:
+    inputs:
+      version:
+        description: "Exact version to release (e.g. 0.3.0). Leave blank to auto-resolve."
+        required: false
+        default: ""
+
+permissions:
+  contents: write
+
+concurrency:
+  group: release
+  cancel-in-progress: false
+
+jobs:
+  release:
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v4
+        with:
+          fetch-depth: 0
+
+      - name: Determine version
+        id: ver
+        env:
+          DISPATCH_VERSION: ${{ github.event.inputs.version }}
+        run: |
+          set -euo pipefail
+          python3 - <<'PY' >> "$GITHUB_OUTPUT"
+          import json, os, re, subprocess, sys
+
+          SEMVER = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")
+
+          def sh(*args):
+              return subprocess.run(args, capture_output=True, text=True).stdout.strip()
+
+          def parse(text):
+              m = SEMVER.match(text)
+              return tuple(int(p) for p in m.groups()) if m else None
+
+          def die(msg):
+              print(f"::error::{msg}", file=sys.stderr)
+              raise SystemExit(1)
+
+          with open("manifest.json", encoding="utf-8") as fh:
+              manifest_version = str(json.load(fh).get("version", ""))
+
+          released = sorted(
+              v for v in (parse(tag[1:]) for tag in sh("git", "tag", "-l", "v*").splitlines()) if v
+          )
+          latest = released[-1] if released else None
+
+          override = os.environ.get("DISPATCH_VERSION", "").strip()
+          if not override:
+              found = re.search(r"\[release\s+(\d+\.\d+\.\d+)\]", sh("git", "log", "-1", "--pretty=%B"))
+              override = found.group(1) if found else ""
+
+          manifest_ver = parse(manifest_version)
+          if override:
+              version = parse(override) or die(f"invalid version override {override!r} (expected X.Y.Z)")
+              source = "the override"
+          elif manifest_ver and (latest is None or manifest_ver > latest):
+              version = manifest_ver
+              source = "manifest.json"
+          elif latest:
+              major, minor, patch = latest
+              patch += 1
+              if patch > 99:
+                  minor, patch = minor + 1, 0
+              version = (major, minor, patch)
+              source = "a patch bump on v%d.%d.%d" % latest
+          else:
+              die(f"manifest.json version {manifest_version!r} is not X.Y.Z "
+                  "and there is no vX.Y.Z tag to count from")
+
+          text = "%d.%d.%d" % version
+          print(f"Releasing {text}, from {source}.", file=sys.stderr)
+          print(f"version={text}")
+          print(f"tag=v{text}")
+          PY
+
+      - name: Refuse to clobber an existing release
+        env:
+          GH_TOKEN: ${{ github.token }}
+          TAG: ${{ steps.ver.outputs.tag }}
+        run: |
+          set -euo pipefail
+          if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
+            echo "::error::Tag $TAG already exists. Pick a different version."
+            exit 1
+          fi
+          if gh release view "$TAG" >/dev/null 2>&1; then
+            echo "::error::Release $TAG already exists. Pick a different version."
+            exit 1
+          fi
+
+      - name: Build the mod .zip
+        env:
+          VERSION: ${{ steps.ver.outputs.version }}
+          MOD_ID: "DRAMATIC_SHAPE"
+        run: |
+          set -euo pipefail
+          staging="$RUNNER_TEMP/pkg"
+          out="$GITHUB_WORKSPACE/dist"
+          rm -rf "$staging" "$out"
+          mkdir -p "$staging" "$out"
+
+          git archive HEAD | tar -x -C "$staging"
+
+          rm -rf "$staging/.github" "$staging/.gitattributes" \
+                 "$staging/.gitignore" "$staging/.luarc.json"
+
+          python3 - "$staging/manifest.json" "$VERSION" <<'PY'
+          import json, sys
+          path, version = sys.argv[1], sys.argv[2]
+          with open(path, encoding="utf-8") as fh:
+              manifest = json.load(fh)
+          manifest["version"] = version
+          with open(path, "w", encoding="utf-8") as fh:
+              json.dump(manifest, fh, indent=2, ensure_ascii=False)
+              fh.write("\n")
+          PY
+
+          zip_path="$out/${MOD_ID}-${VERSION}.zip"
+          (cd "$staging" && zip -qr "$zip_path" .)
+          unzip -l "$zip_path"
+
+          unzip -p "$zip_path" manifest.json > "$RUNNER_TEMP/packed-manifest.json"
+          python3 - "$RUNNER_TEMP/packed-manifest.json" "$VERSION" <<'PY'
+          import json, sys
+          path, expected = sys.argv[1], sys.argv[2]
+          with open(path, encoding="utf-8") as fh:
+              version = json.load(fh)["version"]
+          if version != expected:
+              raise SystemExit(f"::error::packed manifest says {version}, expected {expected}")
+          print(f"manifest.json is at the archive root and reports {version}")
+          PY
+
+          (cd "$out" && sha256sum "${MOD_ID}"-*.zip > sha256sums.txt)
+          cat "$out/sha256sums.txt"
+
+      - name: Publish GitHub Release
+        env:
+          GH_TOKEN: ${{ github.token }}
+          VERSION: ${{ steps.ver.outputs.version }}
+          TAG: ${{ steps.ver.outputs.tag }}
+          MOD_ID: "DRAMATIC_SHAPE"
+        run: |
+          set -euo pipefail
+
+          prev="$(git tag -l 'v*' --sort=-v:refname | grep -v "^${TAG}$" | head -1 || true)"
+          range="${prev:+${prev}..}$GITHUB_SHA"
+          changes="$(git log --no-merges --pretty='- %s' "$range" | head -50 || true)"
+
+          notes=$'Download the .zip and install it from the game: MODS > Import mod .zip.'
+          if [ -n "$changes" ]; then
+            notes+=$'\n\n## Changes\n\n'"$changes"
+          fi
+          printf 'Release notes:\n%s\n' "$notes"
+
+          gh release create "$TAG" \
+            --target "$GITHUB_SHA" \
+            --title "$VERSION" \
+            --notes "$notes" \
+            "dist/${MOD_ID}-${VERSION}.zip" \
+            "dist/sha256sums.txt"
+
+          echo "Published release $TAG"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d03334c..3511684 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -147,6 +147,33 @@
   painted. The reflection lands a hair off the sprite's own depth and exactly
   on its colour, which at a lake's worth of wave is the same picture.
 
+### Fixed
+
+- **On Android the water stayed flat, as if the row were off.** Two GLSL ES
+  defaults the desktop never exercises, both in the water shader:
+
+  Fragment floats default to **mediump** on GLSL ES, and the vertex stage's
+  default is highp -- and the water shader is the mod's first to declare the
+  same uniform, the frame's `vp` matrix, in BOTH stages, one on each default.
+  GLSL ES refuses to link a uniform whose precision the stages disagree on,
+  so the whole shader failed to build and the pass fell back -- quietly, by
+  design -- to the flat water the mode always drew. The pixel stage now lifts
+  its float default to highp (guarded, so a GPU without fragment highp still
+  compiles and falls back flat), which settles the link and is also simply
+  needed: the march works in world coordinates that run to a few thousand,
+  where fp16 has no fraction left, and the world-position varying is
+  qualified highp for the same reason the wireframe's has been all along.
+  Samplers default to **lowp** no matter what floats are set to, so the depth
+  read is lifted too -- eight bits of depth is a march with nothing to land
+  on.
+
+  And the readable depth canvas -- the one hardware requirement the rest of
+  the mode does not already have -- now tries four formats before giving up:
+  depth24, depth24 riding a stencil (a pairing some mobile drivers will
+  texture when they refuse the bare format), depth32f, and depth16 as the
+  floor every GLES3 device can read. Refused all four, the reflections are
+  lost and nothing else, exactly as before.
+
 ### Known
 
 - Screen-space reflections can only reflect what is in the frame. A tree just
diff --git a/lib/Voxel3D.lua b/lib/Voxel3D.lua
index df2f311..471a688 100644
--- a/lib/Voxel3D.lua
+++ b/lib/Voxel3D.lua
@@ -295,15 +295,24 @@ local active = false
 -- never be sampled. An explicit canvas is the same buffer with a texture
 -- handle on it, and costs the same memory.
 --
--- nil where the driver will not make one -- the format is optional in GLES
--- and a canvas is the only honest test of it -- and beginScene falls
--- straight back to the internal buffer, which is exactly the old behaviour
--- minus the reflections.
+-- nil where the driver will not make one -- every depth format is optional
+-- in GLES and a canvas is the only honest test of any of them, so this asks
+-- for several in order of preference: 24 bits, the same 24 riding a stencil
+-- (a pairing some mobile drivers will texture when the bare format they
+-- refuse), 32-bit float, and 16 as the floor every GLES3 device can read.
+-- Refused all four, beginScene falls straight back to the internal buffer,
+-- which is exactly the old behaviour minus the reflections.
+local DEPTH_FORMATS = { "depth24", "depth24stencil8", "depth32f", "depth16" }
+
 local function newDepth(w, h)
   if not (love.graphics and love.graphics.newCanvas) then return nil end
-  local ok, c = pcall(love.graphics.newCanvas, w, h,
-                      { format = "depth24", readable = true })
-  if not (ok and c) then return nil end
+  local c = nil
+  for _, format in ipairs(DEPTH_FORMATS) do
+    local ok, made = pcall(love.graphics.newCanvas, w, h,
+                           { format = format, readable = true })
+    if ok and made then c = made break end
+  end
+  if not c then return nil end
   -- nearest: a depth is a distance, and a blend of two of them is a
   -- distance to nothing. The march wants the texel it landed on.
   pcall(c.setFilter, c, "nearest", "nearest")
diff --git a/lib/Water.lua b/lib/Water.lua
index 96f5368..77a32e6 100644
--- a/lib/Water.lua
+++ b/lib/Water.lua
@@ -345,7 +345,12 @@ Water.EDGE_FADE = 0.14         -- reflection eased off over this much of the fra
 local SHADER_SRC = [[
 varying float vShade;
 varying vec3 vSun;
-varying vec3 vBent;          // world position, as drawn
+// World position, as drawn -- and a varying that cannot ride GLSL ES's
+// mediump fragment default: everything below floors it into columns and
+// marches it through the frame's matrices, and a route's coordinates run
+// to a few thousand, where fp16 has no fraction left at all. The same
+// reasoning the scene shader's vGrid states at length.
+varying LOVE_HIGHP_OR_MEDIUMP vec3 vBent;
 
 #ifdef VERTEX
 uniform mat4 vp;
@@ -368,6 +373,21 @@ vec4 position(mat4 transform_projection, vec4 vertex_position) {
 #endif
 
 #ifdef PIXEL
+// Everything below works in WORLD units through the frame's own matrices,
+// and GLSL ES defaults fragment floats to mediump -- fp16, out of fraction
+// by a coordinate of two thousand and quantising a depth into steps the
+// march falls straight through. Worse than wrong pictures: `vp` is
+// declared by BOTH stages, the vertex side's default is highp, and GLSL ES
+// refuses to LINK a uniform whose precision the two stages disagree on --
+// which is not broken water but NO water shader at all, the flat fallback
+// with nothing in the log. One statement lifts the whole stage; the guard
+// keeps the odd GPU without fragment highp compiling, and such a driver
+// falls back to flat water exactly as it did before this pass existed.
+#ifdef GL_ES
+#ifdef GL_FRAGMENT_PRECISION_HIGH
+precision highp float;
+#endif
+#endif
 uniform mat4 vp;
 uniform vec3 eye;
 uniform vec2 screen;         // the canvas, in pixels
@@ -381,9 +401,12 @@ uniform float sunBias;
 uniform vec2 sunTexel;
 uniform vec3 dayTint;
 
-// the frame as it stood before the water went down, and its depth
+// the frame as it stood before the water went down, and its depth. The
+// depth sampler is qualified because GLSL ES defaults samplers to LOWP no
+// matter what floats are set to, and eight bits of depth is a march with
+// nothing to land on. The frame copy is honest 8-bit colour and can stay.
 uniform Image reflectTex;
-uniform Image depthTex;
+uniform LOVE_HIGHP_OR_MEDIUMP Image depthTex;
 
 uniform float rays;          // 0 = sky only, 1 = march the screen too
 uniform vec3 lookFlat;       // the way the horizon lies from this camera
diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua
index c9f0d0e..2f8bd76 100644
--- a/tests/dramatic_shape_test.lua
+++ b/tests/dramatic_shape_test.lua
@@ -1778,6 +1778,25 @@ T.check(gridded:find("#define VOXEL_GRID", 1, true) ~= nil,
 T.check(plain:find("//@CRATERS", 1, true) == nil,
   "and the crater placeholder is gone by the time a driver sees the source")
 
+-- ANDROID. GLSL ES defaults fragment floats to mediump and samplers to
+-- lowp, and this shader is the one place in the mod where both defaults
+-- are fatal: world coordinates run past fp16's fraction, the depth read
+-- rounds to steps the march falls straight through, and -- the sharp edge
+-- -- `vp` is declared by BOTH stages, whose defaults disagree, which GLSL
+-- ES answers by refusing to LINK the shader at all. Flat lakes, empty log.
+-- The sky's band ramp is this same lesson learned once already.
+T.check(plain:find("precision highp float;", 1, true) ~= nil,
+  "the pixel stage lifts GLSL ES's mediump default to highp, so the march "
+  .. "keeps its fraction and the dual-declared vp links at one precision")
+T.check(plain:find("GL_FRAGMENT_PRECISION_HIGH", 1, true) ~= nil,
+  "guarded, so the odd GPU without fragment highp still compiles and "
+  .. "falls back flat instead of failing loudly")
+T.check(plain:find("LOVE_HIGHP_OR_MEDIUMP vec3 vBent", 1, true) ~= nil,
+  "the world-position varying is qualified like the scene shader's vGrid "
+  .. "rather than left to the fragment default")
+T.check(plain:find("LOVE_HIGHP_OR_MEDIUMP Image depthTex", 1, true) ~= nil,
+  "and the depth sampler is lifted off lowp, which is eight bits of depth")
+
 -- ------- the lift itself
 --
 -- A pond in a field: four water cells recessed below flat ground. The
diff --git a/tests/water_reflect_probe.lua b/tests/water_reflect_probe.lua
index f02040f..4dcf36a 100644
--- a/tests/water_reflect_probe.lua
+++ b/tests/water_reflect_probe.lua
@@ -74,7 +74,8 @@ return function(game)
   --    hardware requirement the rest of the mode does not already have.
   say("depth canvas readable: %s", tostring(Voxel3D.depthReadable()))
   if not Voxel3D.depthReadable() then
-    say("STOP: no readable depth canvas on this driver (depth24/readable).")
+    say("STOP: no readable depth canvas on this driver (tried depth24, ")
+    say("      depth24stencil8, depth32f and depth16).")
     say("      The water falls back to the flat scene shader, which is")
     say("      exactly what it looked like before this feature existed.")
     return