fix water for android glsl shaders

This commit is contained in:
DramaticShape
2026-07-31 23:39:05 -04:00
parent 92fef2a37e
commit 98f7419b72
6 changed files with 290 additions and 11 deletions
+200
View File
@@ -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"
+27
View File
@@ -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
+16 -7
View File
@@ -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")
+26 -3
View File
@@ -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
+19
View File
@@ -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
+2 -1
View File
@@ -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