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