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 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 eed2d45..172bfd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,228 @@ # Changelog +## 1.4.0 + +### Added + +- **WATER, a new row on hotkey 9: water reflects the world, the sky, the sun + and the moon.** Every lake, sea and pond in Kanto was a flat animated + texture lying in a hole in the ground. It is now a surface, and it is + reflective. + + What it reflects, in the order the shader resolves them: + + - **The sky.** The reflected direction goes through the very matrix the + frame is drawn with, as a point at infinity, and the canvas row that + lands on is looked up on Sky's own band ramp -- the identical texture, + the identical checkerboard dither, the identical display-mode transform. + So the sky in the lake is the sky over it, and the two meet at the + waterline with no seam at any pitch, field of view, window shape or zoom. + Blue at noon, gold at dusk, navy under the moon; GRAY gets a grey lake + and CLASSIC a green one, for nothing. + + - **The sun and the moon**, hung by ANGLE rather than by screen position, + because a reflected body is usually off the top of the frame entirely + and a projected point stops meaning anything out there. The angular + radius is the painted disc's own radius run back through the camera's + field of view, so the two are the same size -- craters, dithered rim, + the sunset's loom and all, off one shared list. This is also the + specular: a low sun lays a broken gold path across the water on its own, + out of the reflection rather than out of a highlight term nailed on + beside it. + + - **The world, in screen space.** The reflected ray is walked forward in + world space, each step projected through the same matrix, looking for + where it passes behind what the depth buffer holds -- then binary-refined + onto the contact and read out of a copy of the frame as it stood before + the water went down. Shore trees, buildings, ledges and cliffs land in + the water because they are on screen; where the ray leaves the frame or + finds nothing, the sky above answers instead, which is what makes the far + half of a lake sky and the near half scenery with no seam between them. + + Fresnel decides how much of it shows: almost nothing looked straight down + at, almost everything looked along -- so the 15-degree rung is a pond and + the 75-degree rung is a mirror, off the same surface. + + Every rung gets one, though, which took a lean. A reflection off flat water + points as far above the horizon as the eye is above the water: 15 degrees + at the top rung -- grazing the sky's pale end, sweeping the sun's own path, + travelling far enough across the screen for the march to find the shoreline + -- and 75 degrees, straight up, at the steepest. Up there the bands are at + their darkest, the sun and moon sit at about 6 degrees of squashed + elevation and are nowhere near it, and the screen-space ray leaves the top + of the frame in two steps. All three are correct, and together they are a + lake with nothing in it. + + So the reflection now LEANS toward the elevation the top rung reflects at, + by however far the camera is from having a horizon in frame -- **zero** at + the rung where the horizon IS in frame, so the one place the join can be + seen, the waterline, is still the exact reflection it was. Toward an + elevation rather than by a weight, because the ray it starts from differs + at every rung and a fixed fraction lands them all somewhere different: the + middle rungs came out further from the sun than the steepest one. And it + leans the LEVEL reflection with each column's own deflection added back on + top -- leaning the perturbed ray sets its elevation outright, which at full + lean gave every column on the lake the same one, flattened the sky to a + single band and removed the moon entirely. + + Three rungs rather than a toggle. FULL is the whole thing; SKY drops the + ray march and keeps the sky, sun and moon, which is most of the look for a + handful of instructions; OFF is the flat water this mode always drew. The + FULL preset sets it to FULL. + +- **The water surface is a field of pixel-tall columns, and they are real.** + Not a normal map: a heightfield of one-world-pixel bars -- the same unit + every other voxel in this mode is built from, and exactly one texel of the + water tile -- each standing a WHOLE number of pixels high and rising and + falling on its own. + + Three travelling wave trains, and one of them dominates: a wave has a + DIRECTION, and its crest is a line running across it for as far as the + water goes. Three trains of equal weight cancel and reinforce in patches + instead, and the surface comes out as round islands of raised pixels with + no travel to them -- blobs rather than waves. The dominant train's + wavelength is about forty world pixels, five tiles, so a crest is a long + run of columns at one height with a step down either side. + + Drawn with no extra geometry at all: the mesh is still one flat quad per + tile, and the columns are found by walking the view ray down through the + slab in the pixel shader. That is what makes them read as solid -- a tall + bar hides the shorter ones behind it, you see the SIDE of the ones facing + you (wearing the mesh's own direction shading, so a crest is lit like every + other voxel in the world), and the whole field parallaxes against the plane + as the camera moves. The water's art is read at the column the ray landed + on rather than at the flat quad underneath, so the pixels travel with the + bars they are made of. + + The columns are what you SEE; the normal they reflect with is read off the + smooth surface they are a quantisation of. That distinction is the whole + difference between a moon on the water and confetti: whole-pixel heights + have whole-pixel differences, so a normal built from them can only point in + about five directions, and a sun or moon barely two degrees across falls + between them. Still one normal per column, so the surface stays + pixel-quantised in space while the value it reflects with is continuous. + + Crests stand up to five world pixels, well past the 2px recess water sits + in -- deliberately, because the columns are relief drawn inside the water + quad's own footprint, so a bar that reaches above the bank is clipped at + the water's edge rather than spilling over it. What it buys is a surface + with real swell in it instead of a two-rung terrace. + + And it moves in STEPS, at **15 a second** -- the cadence hand-drawn pixel + art is animated at. A surface built out of whole pixels that crawls + smoothly between them gives away that the quantisation is only skin deep. + Each step advances the dominant wave by exactly one world pixel, derived + from that train's own wavelength rather than tuned beside it, so nothing + ever lands half-way between two pixels and changing a wavelength moves the + speed with it. + + +### Changed + +- **The water surface is its own mesh, and its own pass.** A mirror cannot be + drawn until what it reflects exists, so water is lifted out of the terrain + mesh at build time and drawn between the world and the characters. The + shoreline faces around it are untouched -- they belong to the GROUND that + exposes them -- and the sun still sees the surface, so a tree at the water's + edge still throws its shadow onto the lake. + +- **The scene's depth buffer is a readable canvas.** It was an internal buffer + that could be written and tested and never sampled; it is now the same + buffer with a texture handle on it, at the same cost. Drivers that will not + make one fall straight back to the old buffer and lose the reflections and + nothing else. + +- **The cast is reflected too -- by being drawn twice.** Gen 1 draws people + over the world and water is world, so a surfing player has to composite + OVER the water they are sitting on, which puts them after it; and a + reflection can only hold what came before it. So the walkers, the NPCs and + the authored figures are painted into the reflection COPY alone, where they + are in the picture the water reflects and not yet in the picture the water + is drawn into. Both draws go through one function, so they cannot come out + different. The staged battle does the same with its two Pokemon. + + The ray march finds them the honest way round: a sprite is not in the depth + buffer at that point, so a ray aimed at one passes through to the terrain + standing behind it and reads the copy there -- where the sprite is already + 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. + +### Changed + +- **The waves arrive in sets now, and a little slower.** Three fixed trains + are an exactly periodic field -- every forty-odd pixels of sea wore the + same crest at the same height, which reads as wallpaper the moment a lake + is bigger than the repeat. Two long-wavelength fields now ride the + dominant train, four to five carrier wavelengths apiece so neither reads + as a wave itself: a SWELL that breathes its amplitude, so a few tall + crests march through and hand over to a lull that is itself moving, and a + BEND that bows its phase, so a crest line curves across the surface + instead of ruling itself over all of it. The two lesser trains stay + plain: they are texture rather than structure, and a third modulator is + the soup the train weights exist to avoid. The step beat comes down from + 15 to 12 a second -- the crests were hurrying, and a big wave is slower + than a walk cycle -- still a clean divisor of the engine's 60, and still + exactly one world pixel of dominant-crest travel per step. + +- **Staged battles draw their water plain, whatever the WATER row says.** + The reflective pass is tuned for the overworld's ladder of cameras; a + battle's camera is PLACED -- low, tilted, framed like a picture -- and + under it the pass read wrong: Fresnel opened all the way up, the leaned + sky landed on bands the framing never shows, and a lake-sized arena came + out as murk wearing the tile art. The battle is a stage set, and stage + water is painted: the flat animated tiles the mode always drew, with the + mons compositing over them like everything else on the set. + +### Fixed + +- **On Android the water stayed flat, as if the row were off -- and once it + did draw, it came up in blocks with the haze showing through the holes.** + Three separate faults, every one of them invisible on desktop GL, run down + on a Galaxy Z Fold 7 with the driver's own compiler errors in logcat: + + **The shader would not build.** Fragment floats default to **mediump** on + GLSL ES while 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 that, and the + pass fell back, quietly and 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. + The world-position varying is qualified highp for the same reason the + wireframe's always was, and the depth sampler too -- samplers default to + **lowp** whatever the floats are set to, and eight bits of depth is a + march with nothing to land on. One wrinkle inside the fix: LOVE's header + forward-declares `effect()` under ITS default, and Samsung's Xclipse + compiler treats a definition whose parameter precisions have drifted from + the prototype's as an illegal overload -- so effect()'s own float + parameters stay pinned to mediump, matching the declaration, and the + maths above them runs highp regardless. + + **The depth test read the wrong texels.** The shader's own depth test + normalised LOVE's pixel coordinate by the `screen` uniform, which counts + canvas UNITS -- and on a highdpi phone (Android's density here is 2.625) + a canvas holds that many PIXELS per unit, so the lookup ran to 2.6, + clamped, and read edge texels across two thirds of the frame. Water + discarded itself in blocks wherever the mis-read depth landed in front, + and the haze backdrop showed through the holes. The coordinate is now + normalised by `love_ScreenSize.xy` -- the bound canvas's own pixel size, + measured in the same units on every display. + + **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 + off the top edge is not in the water below it, and a reflection whose ray + runs off the side of the screen fades into the sky rather than ending on a + hard line. + ## 1.3.1 ### Fixed diff --git a/README.md b/README.md index 6886926..db18394 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,22 @@ as leaning sprite slabs, a shadow map throws real cast shadows across whatever they land on, and an optional tilt-shift pass sells the miniature-model look. +Water is a surface rather than a texture lying in a hole. It is a field of +one-pixel-wide voxel columns, each standing a whole number of pixels tall and +rising and falling as waves — found by walking the view ray through them in +the shader, so a crest hides what is behind it and shows you its lit side, +with no extra geometry anywhere. + +And it reflects. The sky it stands under, in the same bands, the same dither +and off the same clock, so the lake and the sky above it meet at the +waterline with no seam. The sun or moon hanging in it, at the size the +painted disc is drawn, craters and all. Whoever is standing beside it — +walkers, NPCs, the two Pokémon in a staged battle. And on **FULL**, a +screen-space ray march adds the rest of what is on screen: the shoreline, the +trees behind it, the buildings across the bay. How much of it shows is +Fresnel, so the top rung is a mirror and a looking-straight-down rung is a +pond, off the same water. + And battles fought on that world rather than on a white field. When something picks a fight the map's NPCs are culled, the engine's own wipe plays over the empty map, and the battle draws over the nearest patch of @@ -34,6 +50,7 @@ menu. | `6`, or the **T-SHIFT** options row | OFF → 1 → 2 → 3 → OFF (miniature blur) | | `7`, or the **V-CURVE** options row | OFF → 1 → 2 → 3 — bend the world over the horizon | | `8`, or the **3D-BTL** options row | ON / OFF — fight on the map instead of on a white field | +| `9`, or the **WATER** options row | FULL / SKY / OFF — waves and reflections on water. **SKY** gives the surface its pixel-tall wave columns and puts the sky, the sun, the moon and the cast in them; **FULL** adds a screen-space ray march that also reflects the shoreline, the trees and the buildings standing behind it | | the **BACK SPRITES** options row | OFF / ON — keep your own Pokémon on the battle menu, seen from behind in its classic slot, instead of standing it on the map; the foe is still out there. Only on the menu while **3D-BTL** is on, because it decides nothing without it | | the **DAYTIME** options row | SYNC / DAY / NIGHT / DUSK / DAWN / CYCLE — what time it is outdoors, on the diorama *and* on the flat 2D world; held at SYNC (and off the menu) while VOXEL is FULL | diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua index d8d0170..170caac 100644 --- a/lib/BattleScene.lua +++ b/lib/BattleScene.lua @@ -141,9 +141,10 @@ local function prefetchArena(state, host) for _, nb in ipairs(state.neighbors or {}) do live[nb.map.id] = true end ChunkMesher.setLive(live) TerrainAtlas.setLive(live) - local terrain = ChunkMesher.request(host, false, nil, true) - or ChunkMesher.peek(host, true) - return terrain, {} + ChunkMesher.request(host, false, nil, true) + local terrain, water = ChunkMesher.pair(host, false) + if not terrain then terrain, water = ChunkMesher.pair(host, true) end + return terrain, {}, water, {} end -- ------- the sun @@ -227,7 +228,8 @@ local function shadowSignature(state, arena, terrain, nbMesh, token) end local function castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh, - atlasFor, cards, token, host, neighbors) + atlasFor, cards, token, host, neighbors, + water, nbWater) if not ShadowMap.available() then return end local sig = shadowSignature(state, arena, terrain, nbMesh, token) if not ShadowMap.stale(sig) then return end @@ -237,6 +239,14 @@ local function castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh, for i, nb in ipairs(neighbors) do ShadowMap.draw(nbMesh[i], atlasFor(nb.map), Mat4.translate(nb.ox, 0, nb.oy)) end + -- the water surface is its own reflective pass now (see Water) and so is + -- no longer inside the terrain mesh; the sun still has to see it, or the + -- light's map has a hole at every lake + ShadowMap.draw(water, atlasFor(host), nil) + for i, nb in ipairs(neighbors) do + ShadowMap.draw(nbWater and nbWater[i], atlasFor(nb.map), + Mat4.translate(nb.ox, 0, nb.oy)) + end -- thin cards are snugged toward the sun (ShadowMap.snug) so their shadows -- keep contact with their bases instead of starting a bias-width away ShadowMap.draw(ChunkMesher.flowers(host), atlasFor(host), @@ -249,10 +259,15 @@ local function castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh, -- the mons themselves, as the same cards the camera will see. Their alpha -- is the silhouette, so what lands on the ground is the shape of the -- Pokemon rather than a blob standing in for one. + -- marked as the CAST, so a fight staged at the water's edge does not lay a + -- cut-out of a Pokemon across the lake (see ShadowMap.sprites); the arena's + -- own floor still takes them, which is the shadow that matters here + ShadowMap.sprites(true) for _, card in ipairs(cards or {}) do ShadowMap.draw(BattleBillboard.mesh(), card.tex, ShadowMap.snug(card.model)) end + ShadowMap.sprites(false) ShadowMap.finish(sig) end @@ -299,9 +314,36 @@ end BattleScene.FLASH_COLOR = { 1, 1, 1 } BattleScene.FLASH_STRENGTH = 0.5 +-- ------- the tile clock, while the overworld is not the one drawing +-- +-- Water and flowers animate off TileRenderer's 60Hz counter, and the ENGINE +-- only advances it from OverworldState:drawWorld -- which runs under dialogs +-- and menus, but not under a battle, because a battle draws instead of the +-- overworld rather than over it. So for the length of a staged fight the +-- counter stood still: the water tiles stopped rotating their pixels and the +-- wave field, which is driven off the same number so the two cannot drift +-- (see Water), stopped with them. A lake in the background of a battle was a +-- photograph. +-- +-- Ticked HERE rather than from the mod's update hook, because here is the +-- one place that means "a staged battle is drawing this frame, and the +-- overworld is not". From the update hook the condition would have to be +-- guessed at, and a frame where both ran would double the rate. +local function tickTiles() + local Game = require("src.core.Game") + local ow = Game and Game.overworld + local top = Game and Game.stack and Game.stack:top() + -- during the wipe INTO a battle the overworld can still be the one + -- drawing, and it is ticking the clock itself; two ticks in a frame would + -- run the water at double speed + if top and ow and top == ow then return end + pcall(require("src.render.TileRenderer").tick) +end + function BattleScene.render(state, arena, textures, token) if not (state and state.map and arena) then return nil end if not Voxel3D.available() then return nil end + tickTiles() -- the floor the fight is staged on: normally the player's own, sometimes -- another floor of the same cave or building (see BattleArena) @@ -326,7 +368,7 @@ function BattleScene.render(state, arena, textures, token) -- shares the free-roam mode's request/evict bookkeeping, so a battle warms -- exactly the meshes walking around would have and nothing extra - local terrain, nbMesh = prefetchArena(state, host) + local terrain, nbMesh, water, nbWater = prefetchArena(state, host) if not terrain then return nil end local lx, ly, s, pw, ph = BattleScene.letterbox() @@ -356,7 +398,7 @@ function BattleScene.render(state, arena, textures, token) local cards = monCards(arena, groundY, textures) Voxel3D.camera = nil castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh, atlasFor, - cards, token, host, neighbors) + cards, token, host, neighbors, water, nbWater) -- An opaque void either way. Outdoors the camera is low enough that the -- horizon is genuinely in frame, so it is sky; indoors it is the dark end @@ -393,6 +435,22 @@ function BattleScene.render(state, arena, textures, token) Voxel3D.draw(nbMesh[i], atlasFor(nb.map), Mat4.translate(nb.ox, 0, nb.oy)) end + -- and the water over it -- PLAIN, always: the flat animated tiles, never + -- the reflective pass, whatever the WATER row says. The reflection is + -- tuned for the overworld's ladder of cameras; this shot's is PLACED -- + -- low, tilted and framed like a picture -- and under it the pass reads + -- wrong: Fresnel opens all the way up, the leaned sky lands on bands the + -- framing never shows, and a lake-sized arena comes out as murk wearing + -- the tile art. The battle is a stage set, and stage water is painted. + -- (No mirror also means the mons need no second draw into one -- they + -- just composite over the water below, like everything else on the set.) + if water then Voxel3D.draw(water, atlasFor(host)) end + for i, nb in ipairs(neighbors) do + if nbWater and nbWater[i] then + Voxel3D.draw(nbWater[i], atlasFor(nb.map), + Mat4.translate(nb.ox, 0, nb.oy)) + end + end -- The mons, standing on their tiles. Depth-tested like everything else, -- so a ledge or a tree between the camera and a Pokemon really is in -- front of it, and the alpha discard cuts the sprite's own outline out of diff --git a/lib/ChunkMesher.lua b/lib/ChunkMesher.lua index d8b2310..73ed890 100644 --- a/lib/ChunkMesher.lua +++ b/lib/ChunkMesher.lua @@ -221,8 +221,18 @@ end -- Kept free of any GPU call so it can be exercised headless -- the -- geometry is the part with the interesting invariants, and a suite that -- needed a real GL context to check them would never run in CI. -local function runGeometry(map, bodyOnly, masks, sink) +-- `waterSink`, when given, takes the WATER SURFACE quads instead of the +-- main sink -- the one class in this world that is drawn as its own pass +-- (see Water: a mirror cannot be drawn until what it reflects exists). +-- Nothing else moves: the quads are the same quads, emitted by the same +-- corner and uv arithmetic at the same recessed height, and the shoreline +-- faces around them still belong to the GROUND that exposes them. +-- +-- Omitted, water stays in the terrain mesh exactly as it always did, which +-- is what the headless geometry() below and the sun's own pass both want. +local function runGeometry(map, bodyOnly, masks, sink, waterSink) local push = sink.push + local waterPush = waterSink and waterSink.push or nil local tileset = map.tileset local S = Structures.forMap(map) local perRow = tileset.tilesPerRow or 16 @@ -358,12 +368,14 @@ local function runGeometry(map, bodyOnly, masks, sink) return aoSide end - local function topQuad(x0, z0, h, tile, shade) + -- `to` routes the quad somewhere other than the main sink -- the water + -- surface is the only caller that ever does (see runGeometry's header). + local function topQuad(x0, z0, h, tile, shade, to) local u0, u1, v0, v1 = uvRect(tile, 0, 8) - push({ { x0, h, z0 }, { x0 + 8, h, z0 }, - { x0 + 8, h, z0 + 8 }, { x0, h, z0 + 8 } }, - { { u0, v0 }, { u1, v0 }, { u1, v1 }, { u0, v1 } }, - aoShades(x0 / 8, z0 / 8, h, shade)) + ;(to or push)({ { x0, h, z0 }, { x0 + 8, h, z0 }, + { x0 + 8, h, z0 + 8 }, { x0, h, z0 + 8 } }, + { { u0, v0 }, { u1, v0 }, { u1, v1 }, { u0, v1 } }, + aoShades(x0 / 8, z0 / 8, h, shade)) end -- vertical quad for face direction `d` of the tile column at (x0, z0), @@ -558,8 +570,14 @@ local function runGeometry(map, bodyOnly, masks, sink) end topTile = S.tileAt[keyOf(tx, row)] end + -- water's surface, and only water's: the recessed sheet itself, + -- never the ground's shoreline bands around it. A cell an object + -- stands on took the branch above and paints synthesized GROUND, + -- which is right -- a sign at the waterline stands on a plot, not + -- on the pond. topQuad(x0, z0, h, topTile, - s.art == "upright" and VOLUME_TOP_SHADE or 1) + s.art == "upright" and VOLUME_TOP_SHADE or 1, + (s.class == "water") and waterPush or nil) end -- sides: 8px bands wherever the neighbour is lower. Band k spans @@ -764,18 +782,34 @@ end -- The raw geometry for `map`: (vertex list, triangle index list, quad -- count). Synchronous and GPU-free -- the headless suite and the probes -- exercise the invariants through this. -function ChunkMesher.geometry(map, bodyOnly, masks) +-- +-- `split` lifts the water surface out, as it is lifted out for the +-- reflective pass, and appends that sink's own three values -- so the suite +-- can check the same separation the GPU path relies on without a GPU. +-- Without it the water is in the first list, which is what every existing +-- caller reads. +function ChunkMesher.geometry(map, bodyOnly, masks, split) local sink = newTableSink() - runGeometry(map, bodyOnly, masks, sink) - return sink.results() + local waterSink = split and newTableSink() or nil + runGeometry(map, bodyOnly, masks, sink, waterSink) + if not waterSink then return sink.results() end + local v, i, n = sink.results() + local wv, wi, wn = waterSink.results() + return v, i, n, wv, wi, wn end -- Build the mesh for `map` synchronously. Returns nil when there is -- nothing to draw or meshes are unavailable (headless). -function ChunkMesher.build(map, bodyOnly, masks) +-- +-- `split` asks for the water surface as a SECOND mesh, returned after the +-- terrain one -- the shape the reflective pass needs (see Water). Without +-- it the water is inside the terrain mesh, which is the historical +-- contract and what every other caller still wants. +function ChunkMesher.build(map, bodyOnly, masks, split) local sink = newSink() - runGeometry(map, bodyOnly, masks, sink) - return sink.finish() + local waterSink = split and newSink() or nil + runGeometry(map, bodyOnly, masks, sink, waterSink) + return sink.finish(), waterSink and waterSink.finish() or nil end local function quadsMesh(quads) @@ -858,8 +892,17 @@ local function entry(id) return c end +-- The water surface that came out of a terrain slot's own build. Kept +-- beside it rather than in a slot of its own because the two are ONE +-- answer: a full mesh drawn beside a body build's water would draw the +-- ring's ponds twice and miss the body's own. +local function waterSlot(slot) + return slot .. "Water" +end + local function releaseEntry(c) - for _, slot in ipairs({ "full", "body", "grass", "flowers" }) do + for _, slot in ipairs({ "full", "body", "fullWater", "bodyWater", + "grass", "flowers" }) do local mesh = c[slot] if mesh and mesh.release then pcall(mesh.release, mesh) end c[slot] = nil @@ -924,13 +967,17 @@ local function runJob(job) if c.stale then c.stale.aux = nil end end local sink = newSink() - runGeometry(map, job.slot == "body", job.masks, sink) + local waterSink = newSink() + runGeometry(map, job.slot == "body", job.masks, sink, waterSink) local mesh = sink.finish() + local water = waterSink.finish() if (gen[job.id] or 0) ~= job.gen then if mesh and mesh.release then pcall(mesh.release, mesh) end + if water and water.release then pcall(water.release, water) end return end swapSlot(c, job.slot, mesh or false) + swapSlot(c, waterSlot(job.slot), water or false) if c.stale then c.stale[job.slot] = nil if not (c.stale.full or c.stale.body or c.stale.aux) then @@ -1032,12 +1079,14 @@ function ChunkMesher.get(map, bodyOnly, masks) if c.stale then c.stale.aux = nil end end if c[slot] == nil or (c.stale and c.stale[slot]) then - local ok, mesh = pcall(ChunkMesher.build, map, bodyOnly, masks) + local ok, mesh, water = pcall(ChunkMesher.build, map, bodyOnly, masks, + true) if not ok then print("[warn] voxel mesh build failed for " .. tostring(map.id) .. ": " .. tostring(mesh)) end swapSlot(c, slot, (ok and mesh) or false) + swapSlot(c, waterSlot(slot), (ok and water) or false) if c.stale then c.stale[slot] = nil if not (c.stale.full or c.stale.body or c.stale.aux) then @@ -1058,6 +1107,21 @@ function ChunkMesher.peek(map, bodyOnly) return mesh or nil end +-- A slot's terrain mesh AND the water surface lifted out of it, as one +-- answer. Never builds, like peek. +-- +-- Both or neither, always from the SAME slot: the water was cut out of that +-- exact geometry, so pairing a full mesh with a body build's water would +-- draw the border ring's ponds twice and leave the body's as holes. Callers +-- that fall back from one variant to the other fall back through this, so +-- there is nowhere for the two to be chosen separately. +function ChunkMesher.pair(map, bodyOnly) + local c = cache[map.id] + if not c then return nil, nil end + local slot = bodyOnly and "body" or "full" + return c[slot] or nil, c[waterSlot(slot)] or nil +end + function ChunkMesher.grass(map) local c = cache[map.id] return c and c.grass or nil diff --git a/lib/ShadowMap.lua b/lib/ShadowMap.lua index 9798462..18ceffd 100644 --- a/lib/ShadowMap.lua +++ b/lib/ShadowMap.lua @@ -130,17 +130,23 @@ local SHADER = [[ } #endif #ifdef PIXEL + uniform float sprite; // 1 while the CAST is being drawn; see ShadowMap.sprites vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) { // the same alpha discard the main pass uses: a sprite card casts its // silhouette, not its 16x16 bounding box if (Texel(tex, tc).a < 0.5) discard; - // pack into two channels: the high byte in red, the low in green + // pack into two channels: the high byte in red, the low in green. + // Blue says WHAT cast this, which costs a channel that was zero anyway + // and lets a surface decline one kind of caster -- water does, for the + // people (see Water's sunLit). float d = clamp(vDepth, 0.0, 1.0) * 255.0; - return vec4(floor(d) / 255.0, fract(d), 0.0, 1.0); + return vec4(floor(d) / 255.0, fract(d), sprite, 1.0); } #endif ]] +ShadowMap._source = function() return SHADER end -- named for the suite + local shader = nil -- nil = untried, false = unavailable local canvas = nil -- nil = untried, false = unavailable local canvasRes = 0 -- the edge `canvas` was made at @@ -440,6 +446,9 @@ function ShadowMap.begin(cx, cy, vw, vh) love.graphics.setShader(sh) love.graphics.setColor(1, 1, 1, 1) pcall(sh.send, sh, "lightVP", "row", ShadowMap.clipVP) + -- the world until a cast pass says otherwise, reset per pass so one that + -- forgot to put it back cannot leak into the next map's terrain + pcall(sh.send, sh, "sprite", 0) drawing = true ready = false return true @@ -448,6 +457,25 @@ end -- Draw one caster. Same signature as Voxel3D.draw minus the camera-ward -- pull, which is a trick for the VIEW's depth buffer and would drag a -- shadow off whatever throws it. +-- Whether what is drawn next is one of the CAST -- a walker, an authored +-- figure, a battle's Pokemon -- rather than part of the world. false for the +-- length of such a pass, true to put it back. +-- +-- The map records it per texel (the shader's blue channel) so a surface can +-- decline that kind of caster, and exactly one does: water. A character +-- standing at a lake's edge threw a hard cut-out of its own sprite across +-- the surface, which on something showing the sky and the shoreline reads as +-- a sticker rather than as a shadow in the water. Everything else -- ground, +-- roofs, ledges, the characters themselves -- still takes them. +-- +-- Sent rather than branched, so a caller that forgets to put it back only +-- mislabels casters rather than losing them; begin() resets it per pass. +function ShadowMap.sprites(on) + if not drawing then return end + local sh = getShader() + if sh then pcall(sh.send, sh, "sprite", on and 1 or 0) end +end + function ShadowMap.draw(mesh, texture, model) if not (drawing and mesh) then return end local sh = getShader() diff --git a/lib/Sky.lua b/lib/Sky.lua index ec0ad2d..79431a4 100644 --- a/lib/Sky.lua +++ b/lib/Sky.lua @@ -263,6 +263,27 @@ end Sky._rampFor = rampFor -- named for the suite +-- The band ramp for the CURRENT bands, plus how many texels wide it is -- +-- for a pass that wants to read the same sky this one paints. The water's +-- reflection is the one caller: it looks the reflected direction up on this +-- very ramp, so the sky on the lake and the sky over it are one palette, +-- through one display-mode transform, off one clock. +-- +-- nil where the ramp could not be built, which is exactly when Sky.paint +-- falls back to flat bands -- so a driver that loses the gradient loses the +-- reflected gradient with it rather than showing two different skies. +function Sky.ramp() + local bands = Sky.bands() + if not (bands and bands[1]) then return nil end + local img = rampFor(bands) + if not img then return nil end + return img, #bands, bands +end + +-- How far the twilight glow reaches around the disc, in canvas pixels, for +-- a `w`-wide frame. The same number Sky.paint sends as `glowInvR`. +Sky.GLOW_REACH = 0.55 + local shader = nil -- nil = untried, false = unavailable local function getShader() @@ -323,20 +344,51 @@ end Sky.DISC_FRAC = 0.030 -- disc radius, as a fraction of the frame height Sky.DISC_MIN = 3 -- but never fewer cells than this across a radius --- crater centres as fractions of the radius, so they ride any disc size -local MOON_CRATERS = { { -0.4, -0.2 }, { 0.2, 0.45 }, { 0.5, -0.4 }, - { -0.15, 0.7 }, { 0.05, 0.05 } } +-- crater centres as fractions of the radius, so they ride any disc size. +-- Public because the water's reflection draws the same moon (see Water): +-- one list, so the disc on the lake cannot drift from the one in the sky. +Sky.MOON_CRATERS = { { -0.4, -0.2 }, { 0.2, 0.45 }, { 0.5, -0.4 }, + { -0.15, 0.7 }, { 0.05, 0.05 } } + +-- a crater's radius, as a fraction of the disc's -- the r/5 paintDisc uses +Sky.CRATER_FRAC = 0.2 + +local MOON_CRATERS = Sky.MOON_CRATERS + +-- The disc's four shades as the display mode has them, lightest first. +-- Shared with the reflection pass, so the sun on the water is the same sun +-- that is in the sky, in the same mode's palette. +function Sky.discShades(moon) + local src = moon and DayNight.MOON_COLORS or DayNight.SUN_COLORS + return PaletteFX.effectiveColors(src) or src +end + +-- Whether this body is the LOOMING low sun -- the sunset exaggeration. +local function looming(body) + return (body.glowAmt or 0) > 0.25 and not body.moon +end + +-- The disc's radius for a `h`-tall frame on a `cell`-pixel grid: in CANVAS +-- PIXELS, and in whole cells. Sized by the FRAME rather than by the world +-- (see DISC_FRAC), so a zoom does not swell the sun. +-- +-- Read by paintDisc below and by the reflection, which needs the same +-- number in radians -- a disc drawn one size and mirrored another would +-- read as two different suns. +function Sky.discRadius(h, cell, body) + cell = math.max(1, cell or 1) + local r = math.max(Sky.DISC_MIN, + math.floor(h * Sky.DISC_FRAC / cell + 0.5)) + if body and looming(body) then r = r + math.max(1, math.floor(r * 0.4)) end + return r * cell, r +end local function paintDisc(body, edge, cell, w, h) local g = love.graphics if not (body and body.y and g.setScissor) then return end - local src = body.moon and DayNight.MOON_COLORS or DayNight.SUN_COLORS - local shades = PaletteFX.effectiveColors(src) or src - local twilight = (body.glowAmt or 0) > 0.25 and not body.moon - local r = math.max(Sky.DISC_MIN, - math.floor(h * Sky.DISC_FRAC / cell + 0.5)) - -- the low sun looms: the classic sunset exaggeration, and it reads - if twilight then r = r + math.max(1, math.floor(r * 0.4)) end + local shades = Sky.discShades(body.moon) + local twilight = looming(body) + local _, r = Sky.discRadius(h, cell, body) -- snap the centre to the cell grid, like everything else in this sky local bx = math.floor(body.x / cell) * cell + cell / 2 local by = math.floor(body.y / cell) * cell + cell / 2 @@ -429,7 +481,7 @@ function Sky.paint(w, h, sky, horizonY, cell, body) if glowAmt > 0 then local gc = body.glowColor or { 248, 224, 168 } sh:send("glowPos", { body.x, body.y }) - sh:send("glowInvR", 1 / math.max(1, w * 0.55)) + sh:send("glowInvR", 1 / math.max(1, w * Sky.GLOW_REACH)) sh:send("glowColor", { gc[1] / 255, gc[2] / 255, gc[3] / 255 }) end end) diff --git a/lib/Voxel3D.lua b/lib/Voxel3D.lua index 62e98be..471a688 100644 --- a/lib/Voxel3D.lua +++ b/lib/Voxel3D.lua @@ -283,8 +283,66 @@ local activeShader = nil -- the variant this pass bound -- resize, so the pair is stable for a session. local slots = {} local canvas, canvasW, canvasH = nil, 0, 0 -- the slot this pass bound +local held = nil -- and the whole record for it local active = false +-- A READABLE depth canvas, so a later pass in the same frame can ask the +-- buffer questions rather than only write to it -- which is the whole of +-- what makes screen-space reflections possible (see Water). +-- +-- `depth = true` in the target list, which is what this used to bind, +-- allocates an internal depth buffer that is written and tested and can +-- 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 -- 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 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") + pcall(c.setWrap, c, "clamp", "clamp") + -- and no compare mode: with one set, Texel returns a 0/1 shadow verdict + -- instead of the depth, which is not what any reader here wants + pcall(c.setDepthSampleMode, c) + return c +end + +-- The bound target for the slot this pass holds: the colour canvas plus +-- either the readable depth canvas or the internal buffer. +local function depthTarget() + if held and held.depth then + return { held.canvas, depthstencil = held.depth } + end + return { canvas, depth = true } +end + +-- Every GPU object one slot owns. The mirror is the copy of the frame the +-- water pass reads (see beginWater); it is only ever made if something asks +-- for one, so a session that never sees a lake never pays for it. +local function releaseSlot(slotHeld) + for _, key in ipairs({ "canvas", "depth", "mirror" }) do + local obj = slotHeld[key] + if obj and obj.release then pcall(obj.release, obj) end + slotHeld[key] = nil + end +end + local IDENTITY = Mat4.identity() -- Whether the driver admits to supporting derivatives. Only a hint -- @@ -379,6 +437,41 @@ end -- way either way. Voxel3D.camera = nil +-- ------- which way, and how steeply, this camera looks +-- +-- Two facts about the view direction, set alongside the eye and the focus +-- because they ARE the eye and the focus, and read by anything that has to +-- reason about the camera's ATTITUDE rather than about a point in front of +-- it: +-- +-- lookFlat the view direction flattened onto the ground plane and +-- normalized -- "the way the horizon lies from here", which is +-- what a reflection leans toward at the steeper rungs (Water). +-- descent how far below horizontal the view runs, as a sine: 0 looking +-- level, 1 looking straight down. It is the number that says +-- whether there is a horizon in frame at all, and it answers +-- the same way for the orbit and for a placed battle camera -- +-- which is why this is derived from the two vectors rather than +-- read off Voxel.angle, a rung the battle camera does not have. +-- +-- A camera looking exactly straight down has no horizontal direction at all, +-- and lookFlat then keeps whatever it last held rather than becoming a zero +-- vector nothing downstream could normalize. +Voxel3D.lookFlat = { 0, 0, -1 } +Voxel3D.descent = 0 + +local function setLook(eye, focus) + local dx = focus[1] - eye[1] + local dy = focus[2] - eye[2] + local dz = focus[3] - eye[3] + local len = math.sqrt(dx * dx + dy * dy + dz * dz) + if len < 1e-6 then return end + Voxel3D.descent = math.max(0, math.min(1, -dy / len)) + local flat = math.sqrt(dx * dx + dz * dz) + if flat < 1e-6 then return end + Voxel3D.lookFlat = { dx / flat, 0, dz / flat } +end + -- View and projection for a `vw` x `vh` world-pixel view centred on -- (cx, cy) in world pixels. Returns the combined matrix. function Voxel3D.viewProjection(cx, cy, vw, vh) @@ -389,10 +482,15 @@ function Voxel3D.viewProjection(cx, cy, vw, vh) -- kept beside the eye for horizonY: where the sky's pale end goes is a -- question about which way this camera looks, and only these two answer it Voxel3D.focus = focus + setLook(eye, focus) local dx = eye[1] - focus[1] local dy = eye[2] - focus[2] local dz = eye[3] - focus[3] local dist = math.max(1, math.sqrt(dx * dx + dy * dy + dz * dz)) + -- kept for the passes that measure an ANGLE against this camera rather + -- than a position: the water's reflected sun is sized in radians, and + -- radians per canvas pixel is exactly this over the frame height + Voxel3D.fovY = cam.fov local proj = Mat4.perspective(cam.fov, vw / vh, math.max(1, dist * 0.05), dist * 4 + 4096) -- the same clip-space Y flip the orbit needs, for the same reason: we @@ -409,12 +507,14 @@ function Voxel3D.viewProjection(cx, cy, vw, vh) -- the FOV that makes a straight-down camera at `dist` frame exactly `vh` -- world pixels, which is the framing the flat view already has local fov = 2 * math.atan(1 / (2 * focal)) + Voxel3D.fovY = fov local focus = { cx, 0, cy } local eye = { cx, dist * math.cos(a), cy + dist * math.sin(a) } -- exposed for camera-facing billboards (VoxelScene yaws sprites at it) Voxel3D.eye = eye Voxel3D.focus = focus + setLook(eye, focus) -- perpendicular to the view direction in the YZ plane: north is screen-up -- when looking straight down, +Y is screen-up when looking level. Never -- parallel to the view direction, so there is no degenerate a = 0 case. @@ -538,22 +638,29 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot) end if not sh then return false end local name = slot or "world" - local held = slots[name] - if not (held and held.w == w and held.h == h) then + local slotHeld = slots[name] + if not (slotHeld and slotHeld.w == w and slotHeld.h == h) then local ok, c = pcall(love.graphics.newCanvas, w, h) if not ok then return false end c:setFilter("nearest", "nearest") - if held and held.canvas and held.canvas.release then - pcall(held.canvas.release, held.canvas) - end - held = { canvas = c, w = w, h = h } - slots[name] = held + if slotHeld then releaseSlot(slotHeld) end + -- the depth canvas is sized with its colour, so a window resize + -- reallocates the pair together and they can never disagree + slotHeld = { canvas = c, w = w, h = h, depth = newDepth(w, h) } + slots[name] = slotHeld end + held = slotHeld canvas, canvasW, canvasH = held.canvas, w, h -- a depth buffer is what makes occlusion real: walk behind a building and -- the building wins, with no y-sorting anywhere - local ok = pcall(love.graphics.setCanvas, - { canvas, depth = true }) + local ok = pcall(love.graphics.setCanvas, depthTarget()) + if not ok and held.depth then + -- the readable canvas would not bind; fall back to the internal buffer + -- for the rest of this session rather than losing the whole 3D pass + pcall(held.depth.release, held.depth) + held.depth = nil + ok = pcall(love.graphics.setCanvas, depthTarget()) + end if not ok then pcall(love.graphics.setCanvas) return false @@ -561,6 +668,14 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot) -- Ahead of the clear, because the sky's bands are placed off the ground -- plane's vanishing line and that is a property of this matrix. Voxel3D.vp = Voxel3D.viewProjection(cx, cy, vw, vh) + -- This frame's pixels per WORLD pixel: the size a diorama pixel is on + -- screen. The sky's dither grid is cut to it, and so is the water's -- + -- one number, so the two break up on the same checkerboard. + Voxel3D.cell = w / math.max(1, vw or w) + -- and where the sky's bottom edge lands, which is what the reflection + -- reads its bands against (see Water). nil when nothing painted bands. + Voxel3D.skyEdge = (sky and sky.bands) + and Sky.region(h, Voxel3D.horizonY(h)) or nil if sky then love.graphics.clear(sky[1], sky[2], sky[3], sky[4] or 1, true, true) -- The sky goes down here, in the one window in this function where a @@ -573,7 +688,7 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot) -- are the same size as the world's own and follow every resize and zoom. -- The banded sky also hangs the hour's sun or moon (skyBody projects it -- through this very camera); a flat sky has no bands and hangs nothing. - Sky.paint(w, h, sky, Voxel3D.horizonY(h), w / math.max(1, vw or w), + Sky.paint(w, h, sky, Voxel3D.horizonY(h), Voxel3D.cell, sky.bands and Voxel3D.skyBody(w, h) or nil) else love.graphics.clear(0, 0, 0, 0, true, true) @@ -720,6 +835,108 @@ function Voxel3D.flatten(color, amount) end end +-- ------------------------------------------------------- the water pass -- +-- +-- A reflective surface has to READ the frame it is being drawn into: the +-- colour of what is standing around it and the depth that says where. Both +-- are attachments of the target this pass is bound to, and a texture cannot +-- be sampled while it is one -- so for the length of the water draw the +-- frame is taken apart: +-- +-- the COLOUR is copied to a mirror canvas, which is a texture like any +-- other and is what the reflection samples. +-- +-- the DEPTH is simply detached. The water shader does the test itself +-- against the texture (see Water), which is the same comparison the +-- hardware would have made -- what it gives up is depth WRITES, and water +-- is flat, never overlaps itself, and has nothing drawn under it later. +-- +-- `paint`, when given, is called with the MIRROR bound and the scene shader +-- set, to add things that must be REFLECTED without being composited yet. +-- +-- The characters are the whole reason it exists. Gen 1 draws people over +-- the world and water is world, so the cast has to composite AFTER the +-- water -- but a reflection can only contain what was drawn BEFORE it, and +-- a lake with everyone standing beside it and nobody in it reads as glass. +-- Painting them into the mirror alone settles both: they are in the picture +-- the water reflects and not yet in the picture the water is drawn into. +-- +-- They go down depth-TESTED and depth-WRITE-FREE. Tested, so a figure behind +-- a building is behind it in the reflection too; write-free because the very +-- next thing to read that buffer is the water's own depth test, and a cast +-- that had written to it would punch itself out of the water it is standing +-- beside. +-- +-- Returns the two textures, or nil when there is nothing to hand over: no +-- readable depth canvas on this driver, or no pass open. A caller that gets +-- nil draws its water like ordinary terrain, which is what this mode always +-- did. +-- +-- MUST be paired with endWater, which puts the frame back together. +function Voxel3D.beginWater(paint) + if not (active and canvas and held and held.depth) then return nil end + if not held.mirror then + local ok, c = pcall(love.graphics.newCanvas, held.w, held.h) + if not (ok and c) then return nil end + pcall(c.setFilter, c, "nearest", "nearest") + pcall(c.setWrap, c, "clamp", "clamp") + held.mirror = c + end + love.graphics.setShader() + -- the frame's own depth rides along, so the paint below can test against + -- it; the copy underneath switches the test off rather than detaching it + local ok = pcall(love.graphics.setCanvas, + { held.mirror, depthstencil = held.depth }) + if not ok then + pcall(love.graphics.setCanvas, depthTarget()) + return nil + end + love.graphics.setDepthMode("always", false) + -- COLOUR only. The last two arguments are what keep the depth buffer the + -- frame's rather than this canvas's: cleared here, the water's own depth + -- test a few lines later would find nothing in front of anything and every + -- lake would draw straight through the buildings standing in it. + love.graphics.clear(0, 0, 0, 0, false, false) + -- premultiplied over a cleared target is a straight copy: every channel + -- lands exactly as it stood, including the alpha, so the mirror is the + -- frame rather than the frame composited against something + love.graphics.setBlendMode("alpha", "premultiplied") + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(canvas) + love.graphics.setBlendMode("alpha") + if paint and activeShader then + love.graphics.setDepthMode("lequal", false) + love.graphics.setShader(activeShader) + pcall(paint) + love.graphics.setShader() + end + love.graphics.setDepthMode() + -- and back to the scene canvas WITHOUT its depth: that texture is about + -- to be read + if not pcall(love.graphics.setCanvas, canvas) then + pcall(love.graphics.setCanvas, depthTarget()) + return nil + end + return held.mirror, held.depth +end + +-- Put the frame back: depth reattached, depth test and the scene shader as +-- the pass had them. Safe to call after a beginWater that returned nil. +function Voxel3D.endWater() + if not active then return end + pcall(love.graphics.setCanvas, depthTarget()) + pcall(love.graphics.setDepthMode, "lequal", true) + love.graphics.setColor(1, 1, 1, 1) + if activeShader then love.graphics.setShader(activeShader) end +end + +-- Whether a reflective water pass can run in this frame at all -- there is +-- a depth texture to read. Callers use it to choose between the water +-- shader and an ordinary terrain draw before they start moving canvases. +function Voxel3D.depthReadable() + return (active and held and held.depth) and true or false +end + -- Whether what is drawn next carries the voxel wireframe. false for the -- length of a draw, true to put it back. -- @@ -931,18 +1148,25 @@ function Voxel3D.canvas() return canvas end +-- The bound canvas's pixel size, for a pass that has to work in screen +-- coordinates (the water's reflection marches in them). +function Voxel3D.size() + return canvasW, canvasH +end + -- Drop the GPU objects (window resize, hot reload). function Voxel3D.invalidate() - for name, held in pairs(slots) do - if held.canvas and held.canvas.release then - pcall(held.canvas.release, held.canvas) - end + for name, slotHeld in pairs(slots) do + releaseSlot(slotHeld) slots[name] = nil end canvas, canvasW, canvasH = nil, 0, 0 + held = nil ShadowMap.invalidate() -- the sky is part of this pass and holds a shader of its own Sky.invalidate() + -- and so does the water, for the same reason + V.require("Water").invalidate() -- and the glass masks are textures of this context too GlassMask.invalidate() end diff --git a/lib/VoxelScene.lua b/lib/VoxelScene.lua index 01cfa0f..daa4131 100644 --- a/lib/VoxelScene.lua +++ b/lib/VoxelScene.lua @@ -21,6 +21,8 @@ local TileShape = V.require("TileShape") local TerrainAtlas = V.require("TerrainAtlas") local Voxel = V.require("VoxelState") local Sky = V.require("Sky") +local Water = V.require("Water") +local VoxelGrid = V.require("VoxelGrid") local DayNight = V.require("DayNight") local PaletteFX = require("src.render.PaletteFX") local Map = require("src.world.Map") @@ -404,17 +406,25 @@ function VoxelScene.prefetch(state) -- crossing demotes the map just left, and it must not vanish from -- behind the player while its body variant builds; its ring is -- already masked out under this map's body, so the stand-in is safe. - local terrain = ChunkMesher.request(state.map, false, masks, true) + -- The water surface rides along with whichever variant answers: it was + -- cut out of that build's own geometry (ChunkMesher.pair), so the two + -- always come from the same slot and a lake is never drawn twice or left + -- as a hole. + ChunkMesher.request(state.map, false, masks, true) + local terrain, water = ChunkMesher.pair(state.map, false) if not terrain then - terrain = ChunkMesher.peek(state.map, true) + terrain, water = ChunkMesher.pair(state.map, true) end - local nbMesh = {} + local nbMesh, nbWater = {}, {} for i, nb in ipairs(state.neighbors or {}) do - nbMesh[i] = ChunkMesher.request(nb.map, true) - or ChunkMesher.peek(nb.map, false) + ChunkMesher.request(nb.map, true) + nbMesh[i], nbWater[i] = ChunkMesher.pair(nb.map, true) + if not nbMesh[i] then + nbMesh[i], nbWater[i] = ChunkMesher.pair(nb.map, false) + end end Voxel.ready = terrain ~= nil - return terrain, nbMesh + return terrain, nbMesh, water, nbWater end -- Capture every entity's pose for this frame. pose() advances the hop / @@ -490,6 +500,124 @@ end local glint = {} +-- ------- the cast +-- +-- Everybody standing on the map: the walkers, and the authored FIGURES the +-- tileset draws into its own furniture (they ARE characters as far as the +-- artwork is concerned, just ones drawn by the tileset instead of by a +-- sprite sheet, so they get the same lean and the same camera-ward pull). +-- +-- One function because it is drawn TWICE and the two must be identical: once +-- into the frame, and once into the water's reflection copy (see drawWater -- +-- Gen 1 draws people over the world, and water is world, so the cast cannot +-- be composited before the water it has to appear in). +-- +-- Characters carry no wireframe out here, whatever the V-GRID row says. The +-- seams are what makes the WORLD read as built out of voxels, and the people +-- walking around in it are the one thing that should read as drawn instead -- +-- a grid over a 16x16 sprite lands a line every couple of display pixels and +-- turns a face into a mesh. (The battle pass makes the opposite call for its +-- own combatants, deliberately -- see BattleBillboard.) +-- +-- Sprite sheets until the figure pass: their texture coordinates mean +-- nothing to the tileset-shaped glass mask, so the glass is off or the +-- panes' atlas positions stripe the cast with lamplight at night. +local function drawCast(state, posed, atlasFor) + Voxel3D.glass(false) + Voxel3D.seams(false) + -- Characters, normally depth-tested: the camera-ward pull inside + -- drawEntity resolves the lean-over-the-wall-in-front case, and a + -- character genuinely behind a building is far deeper and loses the + -- test, so buildings and trees really occlude. + for _, p in ipairs(posed) do + drawEntity(p.sprite, p.px, p.py, p.facing, p.phase, p.flip, p.gh, + p.colors, p.lift) + end + -- back on for everything textured from the atlas again -- figures, grass + -- and flowers all sample it, where the mask's coordinates are honest + Voxel3D.glass(true) + -- Figures after the walkers, so a player standing in front of the couch + -- wins the overlap -- the order the flat game draws them in. + local figPull = billboardPull() + eachFigure(state.map, 0, 0, function(mesh, model, caster) + Voxel3D.draw(mesh, atlasFor(state.map), model, figPull, + ShadowMap.snug(caster)) + end) + for _, nb in ipairs(state.neighbors or {}) do + eachFigure(nb.map, nb.ox, nb.oy, function(mesh, model, caster) + Voxel3D.draw(mesh, atlasFor(nb.map), model, figPull, + ShadowMap.snug(caster)) + end) + end + -- and the seams are back on for the terrain art that follows: grass and + -- flowers are the world's own drawing, not people + Voxel3D.seams(true) +end + +-- ------- the water pass +-- +-- Between the terrain and everything that stands on it, because water is a +-- MIRROR and a mirror can only reflect what is already down: the ground, the +-- shoreline, the trees and buildings behind it, and the sky the frame opened +-- with. +-- +-- THE CAST IS THE AWKWARD ONE, and it is settled by drawing it twice. Gen 1 +-- draws people over the world and water is world, so a surfing player has to +-- composite OVER the water they are sitting on -- which puts them after it, +-- and a reflection can only hold what came before it. So `cast` is painted +-- into the reflection copy alone (Voxel3D.beginWater), where it is in the +-- picture the water reflects and not yet in the picture the water is drawn +-- into. Both draws go through drawCast, so they cannot come out different. +-- +-- The ray march finds them the honest way round: a sprite is not in the +-- DEPTH buffer at that point, so a ray aimed at one passes through to the +-- terrain standing behind it and reads the copy there -- where the sprite is +-- already painted. The reflection lands a hair off the sprite's own depth +-- and exactly on its colour, which at a lake's worth of ripple is the same +-- picture. +-- +-- `draws` is a list of { mesh, texture, model }. Nothing is a special case: +-- with the row OFF, no depth texture to read, or a shader that would not +-- build, the same meshes go through the ordinary scene shader and come out +-- as the flat animated water this mode always drew. +-- The overworld's alone: the staged battle draws its water plain, always -- +-- its placed camera reads this pass wrong, and a stage set wants painted +-- water anyway (see BattleScene, where the choice is argued). +function VoxelScene.drawWater(draws, cast) + local plain = true + if Water.enabled() and Voxel3D.depthReadable() then + local mirror, depth = Voxel3D.beginWater(cast) + local w, h = Voxel3D.size() + local ok = mirror and depth and Water.begin({ + reflect = mirror, depth = depth, + vp = Voxel3D.vp, eye = Voxel3D.eye, curve = { Voxel3D.curveX or 0, + Voxel3D.curveZ or 0, + Voxel3D.curveK or 0 }, + screen = { w, h }, cell = Voxel3D.cell, fov = Voxel3D.fovY, + skyEdge = Voxel3D.skyEdge, grid = VoxelGrid.enabled(), + lookFlat = Voxel3D.lookFlat, descent = Voxel3D.descent, + }) + if ok then + for _, d in ipairs(draws) do + Water.draw(d[1], d[2], d[3]) + end + Water.finish() + plain = false + end + -- Unconditionally, and OUTSIDE the success branch: beginWater unbinds + -- the shader and the depth mode BEFORE it can discover it cannot go on, + -- so a frame that bails halfway through has to be put back together + -- exactly like one that succeeded -- otherwise the plain draw below (and + -- every pass after it) runs with no shader and no depth test. + Voxel3D.endWater() + end + if plain then + for _, d in ipairs(draws) do + Voxel3D.draw(d[1], d[2], d[3]) + end + end +end + -- A stamp of everything the sun pass depends on. Nothing in it moving -- means the shadow map it produced last frame is still exactly right, and -- redrawing the whole world from the sun would buy nothing -- which is @@ -540,7 +668,7 @@ end -- left out on purpose: thousands of tufts would cast a speckle no bigger -- than the pixels it lands on, at the cost of the mesh being drawn twice. local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, - atlasFor) + atlasFor, water, nbWater) if not ShadowMap.available() then return end local sig = shadowSignature(terrain, nbMesh, posed, cx, cy, vw, vh) if not ShadowMap.stale(sig) then return end @@ -551,6 +679,15 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, ShadowMap.draw(nbMesh[i], atlasFor(nb.map), Mat4.translate(nb.ox, 0, nb.oy)) end + -- The water surface, which the terrain mesh no longer carries (it is its + -- own reflective pass now -- see Water). The sun still has to see it, or + -- the map the light records has a hole at every lake and the frustum's + -- far plane answers for the surface a shoreline tree's shadow falls on. + ShadowMap.draw(water, atlasFor(state.map), nil) + for i, nb in ipairs(state.neighbors or {}) do + ShadowMap.draw(nbWater and nbWater[i], atlasFor(nb.map), + Mat4.translate(nb.ox, 0, nb.oy)) + end -- flower billboards live outside the terrain mesh (they draw after the -- characters, pulled -- see render), but the sun still sees them: a -- handful of cutouts per meadow, unlike the grass left out below. @@ -563,6 +700,11 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, ShadowMap.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map), ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy))) end + -- From here down it is the CAST, marked as such in the map (see + -- ShadowMap.sprites) so water can decline them: everything the world casts + -- still shades a lake, a silhouette of somebody standing beside it does + -- not. Ground, roofs and the characters themselves take them as before. + ShadowMap.sprites(true) -- authored figures cast too, for the same reason the flowers do: a -- handful of cards per map, and a person with no shadow reads as pasted on eachFigure(state.map, 0, 0, function(mesh, _, caster) @@ -584,6 +726,7 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, mirror))) end end + ShadowMap.sprites(false) ShadowMap.finish(sig) end @@ -593,7 +736,7 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor) -- return nil: the engine keeps the 2D path for the frame and -- Voxel.ready holds the camera tween at flat, so the switch waits -- invisibly instead of freezing or tilting an empty stage. - local terrain, nbMesh = VoxelScene.prefetch(state) + local terrain, nbMesh, water, nbWater = VoxelScene.prefetch(state) if not terrain then return nil end local cam = state.camera @@ -630,7 +773,8 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor) end local posed, me = posesOf(state, spriteColors) - castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, atlasFor) + castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, atlasFor, + water, nbWater) if not Voxel3D.beginScene(w, h, cx, cy, vw, vh, skyFor(state.map)) then return nil @@ -658,6 +802,34 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor) Voxel3D.endShadows() end + -- and the water over the top of it, reflecting everything just drawn plus + -- the sky the frame opened with (see drawWater). + -- + -- After the fallback decals deliberately: those are the stand-in drop + -- shadows for a frame with no shadow map, they write no depth, and a + -- lake would otherwise wear one as a black smear. Water covers them, + -- which is the same answer the shadow map's own pass gives (see + -- ShadowMap.sprites) -- people do not shadow water either way. + local waterDraws = {} + if water then + waterDraws[#waterDraws + 1] = { water, atlasFor(state.map), nil } + end + for i, nb in ipairs(state.neighbors or {}) do + if nbWater and nbWater[i] then + waterDraws[#waterDraws + 1] = { nbWater[i], atlasFor(nb.map), + Mat4.translate(nb.ox, 0, nb.oy) } + end + end + -- the cast goes into the reflection copy only -- see drawWater for why it + -- cannot be composited yet and why it is drawn through the same function + -- the real pass below uses + if #waterDraws > 0 then + VoxelScene.drawWater(waterDraws, function() + drawCast(state, posed, atlasFor) + end) + end + + -- Sprite sheets from here to the figure pass: their texture coordinates -- mean nothing to the tileset-shaped glass mask, so the glass is off or -- the panes' atlas positions stripe the cast with lamplight at night @@ -689,33 +861,7 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor) -- drawEntity resolves the lean-over-the-wall-in-front case, and a -- character genuinely behind a building is far deeper and loses the -- test, so buildings and trees really occlude. - Voxel3D.seams(false) - for _, p in ipairs(posed) do - drawEntity(p.sprite, p.px, p.py, p.facing, p.phase, p.flip, p.gh, - p.colors, p.lift) - end - -- back on for everything textured from the atlas again -- figures, grass - -- and flowers all sample it, where the mask's coordinates are honest - Voxel3D.glass(true) - -- Authored figures, alongside the characters and with the same lean and - -- the same camera-ward pull -- they ARE characters as far as the artwork - -- is concerned, just ones the tileset draws instead of a sprite sheet. - -- Drawn after the walkers so a player standing in front of the couch - -- wins the overlap, which is the order the flat game draws them in. - local figPull = billboardPull() - eachFigure(state.map, 0, 0, function(mesh, model, caster) - Voxel3D.draw(mesh, atlasFor(state.map), model, figPull, - ShadowMap.snug(caster)) - end) - for _, nb in ipairs(state.neighbors or {}) do - eachFigure(nb.map, nb.ox, nb.oy, function(mesh, model, caster) - Voxel3D.draw(mesh, atlasFor(nb.map), model, figPull, - ShadowMap.snug(caster)) - end) - end - -- and the seams are back on for the terrain art that follows: grass and - -- flowers are the world's own drawing, not people - Voxel3D.seams(true) + drawCast(state, posed, atlasFor) -- tall grass last, pulled camera-ward exactly as far as the characters -- were (same per-vertex shader bias, so grass never drifts either): -- relative depth between a walker and the tuft row south of their feet diff --git a/lib/Water.lua b/lib/Water.lua new file mode 100644 index 0000000..9b7956c --- /dev/null +++ b/lib/Water.lua @@ -0,0 +1,1303 @@ +-- Voxel world mode: water, and what it reflects. +-- +-- Every other surface in this mode is opaque and is drawn once, inside the +-- terrain mesh, by the scene shader. Water is neither: it is a MIRROR, and +-- a mirror cannot be drawn until the thing it reflects already exists. So +-- the water surface is lifted out of the terrain mesh at build time +-- (ChunkMesher's water sink) and drawn as its own pass, after the world and +-- before the characters, by the shader below. +-- +-- WHAT IT REFLECTS, in the order the shader resolves them: +-- +-- the sky the reflected direction is put through the SAME matrix the +-- frame is drawn with, as a point at infinity, and the canvas +-- row that lands on is looked up on Sky's own band ramp -- +-- the identical texture, dither and display-mode transform +-- the painted sky uses. So the sky in the lake is the sky +-- over it: blue at noon, gold at dusk, navy under the moon, +-- and it meets the painted sky at the waterline with no seam. +-- +-- the sun, hung by ANGLE rather than by screen position, because a +-- the moon reflected body is usually off the top of the frame and a +-- projected point is meaningless out there. The angular +-- radius is Sky.discRadius converted through the camera's own +-- field of view, so the disc on the water is exactly as big +-- as the disc in the sky -- craters, dithered rim, the +-- sunset's loom and all. This is also the specular: a low sun +-- lays a broken gold path across the water on its own, out of +-- the reflection rather than out of a highlight term. +-- +-- the world SCREEN SPACE. The reflected ray is walked forward in world +-- space, each step projected through the same matrix, looking +-- for where it passes behind what the depth buffer holds -- +-- then binary-refined onto the contact and read out of a copy +-- of the frame as it stood before the water went down. Shore +-- trees, buildings, ledges and cliffs land in the water +-- because they are on screen; where the ray leaves the frame +-- or finds nothing, the sky above answers instead, which is +-- what makes the far half of a lake sky and the near half +-- scenery without a seam between them. +-- +-- the cast the walkers, the NPCs, the authored figures and a staged +-- battle's two Pokemon. Awkward, and settled by drawing them +-- twice: Gen 1 draws people OVER the world and water is +-- world, so a surfing player has to composite after the +-- water, and a reflection can only hold what came before it. +-- So they are painted into the reflection copy alone +-- (Voxel3D.beginWater), in the picture the water reflects and +-- not yet in the picture it is drawn into. +-- +-- WHAT IT CANNOT REFLECT is what no screen-space reflection can: anything +-- that is not in the frame. A tree just off the top edge is not in the water +-- below it, and a ray that runs off the side of the screen fades into the +-- sky rather than ending on a line. +-- +-- THE SURFACE ITSELF is not flat. It is a heightfield of one-world-pixel +-- columns, each standing a whole number of pixels tall and rising and +-- falling as waves, walked by the view ray in the pixel shader -- so the +-- bars occlude each other and show their sides without a single extra +-- vertex. See WAVE_HEIGHT and relief(). +-- +-- THE PASS ITSELF, and why it is shaped this way. The scene canvas carries a +-- READABLE depth canvas (Voxel3D), and a texture cannot be sampled while it +-- is bound as a render target -- so for the length of this pass the depth +-- buffer is DETACHED and the shader does the depth test itself, comparing +-- its own fragment depth against the texture it just stopped writing to. +-- That is the same test the hardware would have run, so a tree in front of a +-- pond still hides it; what it costs is depth WRITES, which water has no use +-- for anyway (it is flat, it never overlaps itself, and everything drawn +-- after it stands on top of it by construction). +-- +-- Falls back all the way down. No readable depth canvas, a driver that will +-- not compile this, or the row set to OFF and the water mesh is simply drawn +-- by the ordinary scene shader -- flat animated water, exactly what the mode +-- drew before any of this existed. + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local ModSetting = V.require("ModSetting") +local Sky = V.require("Sky") +local DayNight = V.require("DayNight") +local ShadowMap = V.require("ShadowMap") +local Mat4 = V.require("Mat4") + +local Water = {} + +-- ------- the row +-- +-- Three rungs rather than a toggle, because the two halves of this cost +-- very different things. SKY is a handful of instructions per water pixel +-- and no extra buffers read; FULL adds the screen-space march, which is the +-- part that samples a depth texture twenty-odd times. A phone that wants the +-- sunset on the lake but not the ray march has somewhere to stand. +Water.KEY = "water" +Water.LABEL = "WATER" + +Water.setting = ModSetting.new(Water.KEY, Water.LABEL, + { "full", "sky", "off" }, + { "FULL", "SKY", "OFF" }) + +function Water.level() + local v = Water.setting:get() + if v == "off" then return 0 end + if v == "sky" then return 1 end + return 2 +end + +-- Whether the reflective pass should run at all (either rung above OFF). +function Water.enabled() + return Water.level() > 0 +end + +-- ------- the look, in constants +-- +-- FRESNEL. Water reflects almost nothing looked straight down at and almost +-- everything looked along, which is Schlick's curve -- and taken literally +-- it hands the top rung a mirror and the other four nothing at all. This +-- mode's rungs are named for the camera's tilt off VERTICAL, so 15 is a +-- near-overhead camera meeting the water at 15 degrees off its normal: +-- honest Schlick gives that about 2%, and even a generous floor of 0.14 was +-- invisible. +-- +-- So the floor is lifted a long way above water's true 0.04 and the exponent +-- softened from 5 to 2: the SHAPE is still the honest one -- a low camera +-- still gets much more of it than a high one -- but the bottom of the curve +-- is a pond rather than a painted tile. +Water.FRESNEL_FLOOR = 0.34 +Water.FRESNEL_CEIL = 0.92 +Water.FRESNEL_POWER = 2.0 + +-- THE HORIZON LEAN, which is the other half of why the steeper rungs showed +-- nothing -- and the bigger half. +-- +-- A reflection off flat water points as far ABOVE the horizon as the eye is +-- above the water. At the top rung that is 15 degrees: the reflected ray +-- grazes the sky's pale end, sweeps the sun's own path and travels far +-- enough across the screen for the march to find the shoreline. At the 15 +-- rung it is 75 degrees -- straight up. Up there the sky's bands are at +-- their DARKEST (deep blue over blue water, which is no picture at all), the +-- sun and moon sit at about 6 degrees of squashed elevation and are nowhere +-- near it, and the screen-space ray leaves the top of the frame in two +-- steps. All three of those are correct, and together they are a lake with +-- nothing in it. +-- +-- So the reflected direction LEANS toward the way this camera is looking, by +-- however far the camera is from having a horizon in frame. That is a +-- deliberate stylisation and it is worth being exact about what it costs and +-- what it does not: +-- +-- at the rung where the horizon IS in frame the lean is ZERO, so the one +-- place the join can actually be seen -- the waterline, where the lake +-- meets the painted sky -- is still the exact reflection it was. +-- +-- at the rungs where the horizon is above the top edge there is no join to +-- break, and what the lean buys is the whole of the effect: the pale bands, +-- the sunset, the moon's path, and a screen-space ray that travels ACROSS +-- the diorama instead of straight out of it. +-- +-- It leans toward an ELEVATION rather than by a weight, and that matters. +-- Mixing the ray a fixed fraction of the way toward horizontal sounds like +-- the same thing and is not: the ray it starts from is different at every +-- rung, so a fixed fraction lands them all somewhere different, and the +-- middle rungs came out worst of all -- further from the sun than the +-- steepest one. Aimed at an elevation, every rung below the top one puts its +-- reflection where the TOP rung puts its own, which is the one place the +-- effect is known to work. +-- +-- Measured off Voxel3D.descent -- the sine of how far below horizontal the +-- view runs -- so it answers for the battle's placed camera too, which has +-- no rung to be asked about. +Water.LEAN_FROM = 0.30 -- descent where the lean starts: the top rung's +Water.LEAN_FULL = 0.55 -- and where it is complete +-- the elevation it aims at: the one the top rung's own reflection sits at, +-- stated as that same descent so the two cannot drift apart +Water.LEAN_ELEV = math.asin(Water.LEAN_FROM) + +function Water.lean(descent) + local span = Water.LEAN_FULL - Water.LEAN_FROM + local t = ((descent or 0) - Water.LEAN_FROM) / span + if t <= 0 then return 0 end + return t < 1 and t or 1 +end + +-- ------- the waves +-- +-- Not a normal map. The surface is a HEIGHTFIELD of one-world-pixel columns +-- -- the same unit every other voxel in this mode is built from, and exactly +-- one texel of the water tile (a tile is 8 texels across 8 world pixels) -- +-- and every column stands at a whole number of pixels. So the water is a +-- field of little square bars rising and falling on their own, which is what +-- water made of pixels should look like from a camera that can see it in 3D. +-- +-- It is drawn without any extra geometry. The mesh is still one flat quad +-- per tile; the columns are found by walking the view ray down through the +-- slab in the pixel shader (relief mapping) and taking the first one it +-- meets. That is what makes them read as SOLID rather than as shading: a +-- tall bar hides the shorter ones behind it, you see the SIDE of the ones +-- facing you, and the whole field parallaxes against the plane as the camera +-- moves. The side faces wear the mesh's own direction shading +-- (Voxel3D.FACE_SHADE, sent in rather than restated) so a wave crest is lit +-- like every other voxel in the world. +-- +-- HEIGHT is in world pixels: the tallest a column may stand above the plane +-- the quad is drawn on, and so both the amplitude and the number of rungs a +-- crest can climb through (five gives six). +-- +-- It is well past the 2px recess TileShape sinks water into, which is a +-- deliberate look rather than an oversight: the crests are RELIEF, drawn +-- inside the water quad's own screen footprint, so a bar that reaches above +-- the shoreline cannot actually spill over the bank -- it is clipped at the +-- water's edge like everything else this pass draws. What it buys is a +-- surface with real swell in it instead of a two-rung terrace. +Water.WAVE_HEIGHT = 5 + +-- ------- the trains +-- +-- Each is { fx, fz, speed, weight }. The vector is the train's DIRECTION and +-- its frequency in one -- the crest runs across it, and two pi over its +-- length is the wavelength in world pixels -- and `speed` is what walks it. +-- +-- The first one dominates, and that weighting is the whole difference +-- between water and soup: a wave has a direction, and its crest is a line +-- running across it for as far as the surface goes. Three trains of equal +-- weight cancel and reinforce in patches instead, and the field comes out as +-- round islands of raised pixels with no travel to them. +-- +-- Long, too: the dominant wavelength is about forty world pixels, five +-- tiles, so a crest is a run of hundreds of columns at one height with a +-- step down either side. Pitched anywhere near a pixel they stop being waves +-- and become static -- every column its own island. +-- +-- Read into the shader source rather than sent as uniforms, so the rate +-- below can be derived from the same numbers the field is built out of. +Water.WAVE_TRAINS = { + { 0.150, 0.062, 1.60, 0.60 }, + { 0.058, 0.132, -1.05, 0.29 }, + { -0.041, 0.033, 0.55, 0.11 }, +} + +-- ------- and what keeps them from reading as one pattern +-- +-- Three fixed trains are still an exactly periodic field: every forty-odd +-- pixels of sea wears the same crest at the same height, and a lake's worth +-- of that reads as wallpaper. Real swell varies two ways a sum of sines +-- cannot: waves arrive in SETS -- a few tall ones, then a lull -- and a +-- crest line curves as it runs rather than ruling itself across the whole +-- surface. Both are put back with one long-wavelength field each, riding +-- the DOMINANT train only; the two lesser trains stay plain, because they +-- are texture rather than structure and three modulators is soup again. +-- +-- Both wear the trains' own shape, { fx, fz, speed, x }: a direction whose +-- length is the spatial frequency, a phase rate, and what the field does. +-- Their wavelengths sit four to five times the carrier's, far enough apart +-- that neither reads as a wave itself -- the swell as slow weather over the +-- crests, the bend as the crests' own drift. +-- +-- THE SWELL scales the dominant train's amplitude; `x` is the DEPTH of the +-- deepest lull, as the fraction of the train it takes away. It runs roughly +-- along the carrier's own direction and slower than it, which is a wave +-- group's honest habit (deep-water groups travel at about half the phase +-- speed) -- so sets of crests swell up, march a while, and hand over to a +-- calm patch that is itself moving. +Water.WAVE_SWELL = { 0.0325, 0.0134, 0.55, 0.35 } + +-- THE BEND adds a slow wobble to the dominant train's phase; `x` is the +-- wobble's reach in RADIANS of carrier phase. 1.1 radians against a carrier +-- of about forty pixels bows a crest some seven pixels off its line over +-- the bend's own hundred-and-seventy-five -- a visible curve, not a +-- scribble -- and it runs ACROSS the carrier, which is the direction a +-- crest line actually wanders. What it costs is exactness in waveRate's +-- derivation: the carrier's local frequency now breathes around the number +-- the rate is derived from, so the one-pixel step is the average step +-- rather than every step's. The step CLOCK is untouched; only how far a +-- bowed stretch of crest moves on one tick varies, and by under a pixel. +Water.WAVE_BEND = { -0.0138, 0.0333, 0.35, 1.10 } + +-- ------- and the beat they move on +-- +-- The surface does not slide, it advances in STEPS, off the engine's own +-- frame counter -- the move that makes this read as art rather than as a +-- simulation someone forgot to stylise. A surface built out of whole pixels +-- that crawls between them smoothly gives away that the quantisation is +-- only skin deep. +-- +-- 12 a second, a shade under the 15 hand-drawn pixel art is usually +-- animated at: the crests were hurrying, and a big wave is slower than a +-- sprite's walk cycle. Still a clean divisor of the engine's 60, so every +-- step spans the same whole number of frames. +Water.WAVE_FPS = 12 + +-- How far the dominant train advances each of those steps, in WORLD PIXELS. +-- One is the honest choice for a stepped surface: the whole field shifts by +-- exactly one pixel per frame, so nothing ever lands half-way between two. +-- The rate below is derived from it rather than tuned beside it, so changing +-- a wavelength moves the speed with it instead of quietly desynchronising. +Water.WAVE_PIXELS_PER_STEP = 1 + +-- Radians of wave phase per second. A train travels `speed / frequency` +-- world pixels per radian of phase, so the phase that moves the dominant one +-- a pixel is its frequency over its speed -- times the step rate. +function Water.waveRate() + local t = Water.WAVE_TRAINS[1] + local freq = math.sqrt(t[1] * t[1] + t[2] * t[2]) + local speed = math.abs(t[3]) + if not (freq > 0 and speed > 0) then return 0 end + return Water.WAVE_PIXELS_PER_STEP * (freq / speed) * Water.WAVE_FPS +end +-- Relief samples down through the slab. With the stride pinned at one world +-- pixel (see WAVE_STRIDE) this is also how FAR the march can see: sixteen +-- samples, sixteen pixels of parallax, which covers the slab at every rung +-- but the very lowest and leaves the rest to fade out honestly. +-- +-- The pass early-outs entirely (see relief) whenever the camera is steep +-- enough that the whole slab projects to under a pixel across, which is most +-- of the ladder -- so the cost of this only lands where it buys something. +Water.WAVE_STEPS = 16 + +-- The furthest one relief sample may travel ACROSS the surface, in world +-- pixels -- which is what bounds how far the march runs in total. +-- +-- The march's reach is the slab's depth over the ray's descent, so it grows +-- without limit as the camera flattens: at the top rung, fragments near the +-- horizon look along the water at a few degrees and the reach runs to +-- hundreds of world pixels. Spread over a fixed number of samples that steps +-- clean over whole crests, and the surface comes apart into streaks running +-- away from the eye. Capping the span is what keeps a sample worth taking; +-- what it costs is parallax on the far water, where the columns are under a +-- pixel across and there was nothing left to see anyway. +-- +-- This is the FLOOR on it. The stride the march actually takes is a SCREEN +-- pixel's worth of surface, which is the only rate that makes sense: +-- +-- up close, a screen pixel is a fraction of a world pixel, so the stride +-- sits on this floor of one world pixel and the march visits every column +-- on its path. It has to: a column is one world pixel wide, a longer +-- stride steps over columns, and which ones it misses changes from +-- fragment to fragment -- neighbouring pixels landing on different columns +-- at different heights wearing different faces. That is peppery noise. +-- +-- far away, a screen pixel already spans several world pixels, so a stride +-- that matches it skips columns the screen could not have resolved anyway. +-- Holding it at one world pixel out there does not buy detail, it just +-- runs out of samples -- and a march that runs out stops part-way down the +-- slab and reports the surface as flat, which is why the lowest rung lost +-- its waves entirely across the whole middle distance. +Water.WAVE_STRIDE = 1 + +-- How far the wave field's own gradient tilts the REFLECTION. A multiplier +-- on the SMOOTH surface's slope, not on the stepped one -- see waveNormal +-- for why that distinction is the whole difference between a moon on the +-- water and confetti. The field's gradient peaks around 0.06 per world +-- pixel, so this lands the steepest faces about twelve degrees off vertical: +-- enough to sweep a low sun or moon into a broken glitter path down the +-- lake, and not so much that the sky's own bands come apart. +Water.WAVE_SLOPE = 3.5 +-- and how far the horizon lean is allowed to open that up, since it squashes +-- the same tilt on its way past (see LEAN_FROM) +Water.WAVE_SLOPE_LEAN = 1.5 + +-- THE MARCH. Steps are in world pixels and lengthen as they go: near the +-- surface the reflection needs precision (a shoreline is a few pixels), far +-- from it reach matters more than accuracy, and a geometric ramp gets both +-- out of one loop. RAY_STEPS is compiled in -- GLSL wants a constant bound. +Water.RAY_STEPS = 24 +Water.RAY_REFINE = 5 -- halvings once a crossing is found +Water.RAY_STEP = 3.0 -- world pixels in the first step +-- and the ratio each step after it. 3 x (1.18^24 - 1) / 0.18 is about 930 +-- world pixels of reach -- three view-heights, past which a reflection is +-- faded out anyway (see the tail fade in march) and the sky is the honest +-- answer: distant water reflects haze, which is what the bands already are. +Water.RAY_GROW = 1.18 +-- How far behind the depth buffer a crossing may land and still count, as a +-- multiple of the depth the step itself covered. A ray that dives far past +-- what it crossed went BEHIND a thin thing rather than hitting it -- the +-- classic screen-space smear, where a tree between the camera and the pond +-- paints itself across the water -- and this is the test that drops it. +Water.RAY_THICK = 1.6 +Water.EDGE_FADE = 0.14 -- reflection eased off over this much of the frame + +-- ------- the shader +-- +-- The scene shader's own vertex path, plus the world position the geometry +-- was actually DRAWN at -- after the world curve, because that is the space +-- the depth buffer holds and therefore the space the march has to walk in. +-- (The curve only ever moves Y, so a fragment's world XZ is the same on both +-- sides of it and the ripple can be measured off this one too.) +local SHADER_SRC = [[ +varying float vShade; +varying vec3 vSun; +// 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; +uniform mat4 model; +uniform mat4 sunVP; +uniform vec3 curve; // xy = the focus in world XZ, z = k; 0 = off +attribute float VertexShade; + +vec4 position(mat4 transform_projection, vec4 vertex_position) { + vShade = VertexShade; + vec4 w = model * vertex_position; + vSun = (sunVP * w).xyz; + if (curve.z > 0.0) { + vec2 cd = w.xz - curve.xy; + w.y -= dot(cd, cd) * curve.z; + } + vBent = w.xyz; + return vp * w; +} +#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 +uniform float cell; // one diorama pixel, in canvas pixels +uniform float pxAngle; // radians of view one screen pixel subtends + +// the sun's own pass, exactly as the scene shader reads it +uniform Image sunMap; +uniform float sunDark; +uniform float sunBias; +uniform vec2 sunTexel; +uniform vec3 dayTint; + +// 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 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 +uniform float lean; // and how far the reflection tilts toward it +uniform float leanElev; // the elevation it aims at, in radians +uniform float waveHeight; // the tallest column, in whole world pixels +uniform float waveSlope; // how far a column's neighbours tilt its normal +uniform float waveSlopeLean; // and how far the horizon lean may open that up +uniform float waveT; +uniform vec4 faceShade; // the mesh's own direction shading: E, W, S, N +uniform vec2 atlasSize; // the tileset atlas, in texels +uniform float fresnelFloor; +uniform float fresnelCeil; +uniform float fresnelPower; +uniform float rayStep; +uniform float rayGrow; +uniform float rayThick; +uniform float edgeFade; + +// the sky, as Sky paints it +uniform Image skyRamp; +uniform float skyCount; +uniform float skyEdge; // the sky's bottom, in canvas pixels +uniform float skyStart; // where the checker begins inside a band +uniform float skyOn; // 0 indoors: there is no sky to reflect + +// and what hangs in it +uniform vec3 bodyDir; +uniform float bodyOn; +uniform float bodyMoon; +uniform float bodyAng; // the disc's angular radius, in radians +uniform vec3 bodyCore; +uniform vec3 bodyMain; +uniform vec3 bodyDark; +uniform float glowAmt; +uniform float glowReach; // in radians, like bodyAng +uniform vec3 glowColor; + +#ifdef VOXEL_GRID +uniform float gridDark; +uniform float gridWidth; +#endif + +// ------- the sun's pass (the scene shader's, verbatim) + +// One shadow tap: 1 where the sun reaches, 0 where something blocks it -- +// EXCEPT that water declines one kind of blocker. +// +// The sun pass marks the cast in the blue channel (ShadowMap.sprites), and +// water ignores those. A character standing at a lake's edge laid a hard +// cut-out of its own sprite across the surface, and on something that is +// already showing the sky, the shoreline and the trees behind it, a +// silhouette of somebody reads as a sticker on the water rather than as a +// shadow in it. Everything the WORLD casts -- trees, buildings, cliffs, +// ledges -- still shades it, which is the half that was worth having. +float sunLit(vec2 uv, float z) { + vec4 c = Texel(sunMap, uv); + return max(step(z, c.r + c.g * (1.0 / 255.0)), c.b); +} + +float sunlight(vec3 p) { + if (sunDark <= 0.0) return 1.0; + if (p.x < 0.0 || p.x > 1.0 || p.y < 0.0 || p.y > 1.0 || p.z > 1.0) { + return 1.0; + } + vec2 e = min(p.xy, 1.0 - p.xy); + float edge = smoothstep(0.0, 0.06, min(e.x, e.y)); + if (edge <= 0.0) return 1.0; + float z = p.z - sunBias; + float lit = sunLit(p.xy + sunTexel * vec2(-0.5, -0.5), z) + + sunLit(p.xy + sunTexel * vec2( 0.5, -0.5), z) + + sunLit(p.xy + sunTexel * vec2(-0.5, 0.5), z) + + sunLit(p.xy + sunTexel * vec2( 0.5, 0.5), z); + return 1.0 - sunDark * edge * (1.0 - lit * 0.25); +} + +#ifdef VOXEL_GRID +// The wireframe, ruled on the COLUMNS rather than on the flat sheet they +// stand on. +// +// The scene shader reads a mesh's own model space, and for water that is the +// base plane -- so it would draw a grid across a flat sheet and ignore the +// bars entirely, which is the one thing that would give away that they are +// bars. What has to be outlined is what is actually SEEN: the column the ray +// landed on, at the height it landed at, so every voxel of water reads as +// its own block with its own edges. +// +// `p` is that hit; `base` is the smooth plane under it, and the derivative +// comes from THERE. `p` jumps a whole column between neighbouring fragments, +// so fwidth() of it reports a step rather than a scale and every column edge +// would blow out into a band. The plane underneath is smooth, and is the +// same scale in x and z that the columns are built on. +// +// `axis` is the direction the face does not vary along -- the top's own y, +// or a side's x or z. Its distance to the nearest plane is a constant zero, +// and taken at face value it floods the whole face solid; pushed out of +// reach it simply drops out, exactly as the scene shader's own seam handles +// the axis a face's normal points along. +float columnSeam(vec3 p, vec3 base, float axis) { + vec3 w = fwidth(base); + float wide = max(w.x, w.z); + // the plane has no vertical extent of its own to measure, so y borrows + // the horizontal scale -- it sets a line's THICKNESS and nothing else + w.y = wide; + vec3 d = abs(fract(p + 0.5) - 0.5); + vec3 px = d / max(w, vec3(1e-6)); + if (axis < 0.5) { px.y += 1e6; } + else if (axis < 1.5) { px.x += 1e6; } + else { px.z += 1e6; } + float near = min(min(px.x, px.y), px.z); + // Fade out where a column is too small on screen to hold a line at all, + // or the far water turns into a flat wash of seams rather than a grid. + // + // It holds on further than the scene shader's own does. That one is ruling + // seams across whole 16px walls and roofs; these are one world pixel + // apart, so the fade starts biting while the water is still perfectly + // readable -- and at the lowest rung, where the middle distance is most of + // the frame, it took the grid off nearly all of it. Full lines by a pixel + // and a half of screen space, gone under three quarters of one. + float span = 1.0 / max(wide, 1e-6); + float fade = clamp((span - 0.75) * 1.35, 0.0, 1.0); + return fade * clamp(gridWidth * 0.5 + 0.5 - near, 0.0, 1.0); +} +#endif + +// ------- the sky, by direction + +vec3 bandAt(float i) { + return Texel(skyRamp, + vec2((clamp(i, 0.0, skyCount - 1.0) + 0.5) / skyCount, 0.5)).rgb; +} + +// Where a DIRECTION lands on the sky's own gradient, as a band coordinate +// in [0, count]: 0 is straight overhead, count the horizon. +// +// Measured by putting the direction through the very matrix the frame is +// drawn with, as a point at infinity -- which is how Voxel3D finds both the +// vanishing line and the sun's place on the canvas. So the reflected sky and +// the painted sky are answering the same question with the same arithmetic, +// and they agree at the waterline for free at any pitch, fov or zoom. +// +// A direction whose w comes out negative is BEHIND the camera plane, which +// for an upward reflection means near-vertical: the top band, overhead. +float skyPos(vec3 d) { + vec4 c = vp * vec4(d, 0.0); + if (c.w <= 1e-6) return 0.0; + float py = (c.y / c.w * 0.5 + 0.5) * screen.y; + float row = floor(py / cell) * cell; + return clamp(row / max(skyEdge, 1.0), 0.0, 1.0) * skyCount; +} + +// `parity` is the diorama checkerboard this fragment sits on -- the same +// one Sky's own dither is cut from, so the reflected gradient breaks up in +// the same 8-bit way rather than being the one smooth thing in the frame. +vec3 skyAt(vec3 d, float parity) { + float pos = skyPos(d); + float base = min(floor(pos), skyCount - 1.0); + vec3 c = bandAt(base); + if (base < skyCount - 1.0 && (pos - base) > skyStart && parity < 0.5) { + c = bandAt(base + 1.0); + } + return c; +} + +float crater(vec2 p, vec2 c, float r) { + vec2 dd = p - c; + return step(dot(dd, dd), r * r); +} + +// The sun or moon, and the twilight warmth around it, laid over the bands. +// +// By ANGLE, not by screen position: the reflected direction usually +// projects off the top of the frame entirely, where screen distances stop +// meaning anything. bodyAng is Sky.discRadius run back through the camera's +// field of view, so this disc is the same size as the painted one. +vec3 bodyAt(vec3 d, vec3 c, float parity) { + if (bodyOn <= 0.0) return c; + float ang = acos(clamp(dot(d, bodyDir), -1.0, 1.0)); + if (glowAmt > 0.0) { + float g = glowAmt * pow(clamp(1.0 - ang / glowReach, 0.0, 1.0), 2.0); + float lvl = floor(g * 4.0); + if (g * 4.0 - lvl > 0.5 && parity < 0.5) { lvl += 1.0; } + c = mix(c, glowColor, min(lvl / 3.0, 1.0) * 0.65); + } + if (ang > bodyAng) return c; + float t = ang / bodyAng; + // the dithered rim, exactly as the painted disc keeps one parity of its + // outer ring of cells + if (t > 0.86 && parity < 0.5) return c; + vec3 disc = (t <= 0.5) ? bodyCore : bodyMain; + if (bodyMoon > 0.5) { + // disc-local coordinates: a frame built off world up, so the craters + // sit on the moon the same way round every night + vec3 t1 = normalize(cross(vec3(0.0, 1.0, 0.0), bodyDir)); + vec2 dc = vec2(dot(d, t1), dot(d, cross(bodyDir, t1))) / bodyAng; + float k = 0.0; +//@CRATERS + if (k > 0.0) { disc = bodyDark; } + } + return disc; +} + +// ------- the screen-space march + +// A point as (uv, depth, valid), through the very matrix the frame was drawn +// with. The uv and the depth are the same numbers the hardware wrote -- the +// clip-space Y flip is already baked into `vp`, and a canvas texture's v runs +// the same way its pixel rows do, so one 0.5x+0.5 answers for both. +// +// This is why the march walks in the world as DRAWN rather than as authored: +// the depth buffer holds the curved world, so a straight line in that space +// is the ray, and a straight line in the flat one would bend through it. +vec4 project(vec3 p) { + vec4 c = vp * vec4(p, 1.0); + if (c.w <= 1e-6) return vec4(0.0, 0.0, 0.0, 0.0); + return vec4(c.xy / c.w * 0.5 + 0.5, c.z / c.w * 0.5 + 0.5, 1.0); +} + +// Walk the reflected ray until it passes behind the depth buffer. Returns +// the colour found in .rgb and how much of it to believe in .a -- 0 for a +// ray that left the frame, ran out of steps, or crossed something it went +// straight through rather than landed on. +vec4 march(vec3 origin, vec3 dir) { + vec4 miss = vec4(0.0, 0.0, 0.0, 0.0); + vec3 a = origin; + vec4 pa = project(a); + if (pa.w < 0.5) return miss; + float len = rayStep; + for (int i = 0; i < RAY_STEPS; i++) { + vec3 b = a + dir * len; + vec4 pb = project(b); + if (pb.w < 0.5) return miss; + if (pb.x < 0.0 || pb.x > 1.0 || pb.y < 0.0 || pb.y > 1.0) return miss; + float scene = Texel(depthTex, pb.xy).r; + if (pb.z > scene) { + // how much depth this one step covered: the yardstick for whether + // the crossing is a surface or a thin thing the ray shot past + float span = max(abs(pb.z - pa.z), 1e-7); + if (pb.z - scene > span * rayThick) return miss; + // binary-refine onto the contact + vec3 lo = a; + vec3 hi = b; + for (int k = 0; k < RAY_REFINE; k++) { + vec3 m = (lo + hi) * 0.5; + vec4 pm = project(m); + if (pm.z > Texel(depthTex, pm.xy).r) { hi = m; } else { lo = m; } + } + vec4 hit = project(hi); + if (hit.w < 0.5) return miss; + // Ease out at the frame's rim, where the reflection is about to run + // off the only evidence there is -- and with distance travelled, so a + // long ray hands back to the sky instead of ending on a hard edge. + // + // The distance term is doing two jobs. It hides the march's own tail, + // where the steps are longest and a grazing crossing is least likely + // to be a real surface -- and it is also true: distant water reflects + // haze rather than detail, and the haze is what the bands underneath + // already are. The small floor keeps a genuine far hit as a trace + // rather than deleting it. + vec2 e = min(hit.xy, 1.0 - hit.xy); + float edge = smoothstep(0.0, edgeFade, min(e.x, e.y)); + float far = 1.0 - clamp(float(i) / float(RAY_STEPS), 0.0, 1.0); + return vec4(Texel(reflectTex, hit.xy).rgb, edge * (0.15 + 0.85 * far)); + } + a = b; + pa = pb; + len *= rayGrow; + } + return miss; +} + +// ------- the surface, as a field of pixel-tall columns + +// How high the column at world pixel `q` stands, in WHOLE world pixels. +// +// Whole, because that is what makes them BARS: a column is a voxel like +// every other voxel in this mode, one unit on a side, and a surface that +// stepped in fractions would just be a smooth wave with extra arithmetic. +// Three crossing wave trains, so the field has no readable repeat inside a +// lake's worth of pixels. +// +// The SMOOTH surface underneath, 0 to 1 -- the thing the columns are a +// quantisation of. Summed from Water.WAVE_TRAINS, which is where the trains +// and the reasoning behind their weights live; pasted in rather than sent, +// so the speed derived from those same numbers cannot drift from the field +// they describe. +float waveRaw(vec2 q) { + float h = 0.0; +//@TRAINS + return h * 0.5 + 0.5; +} + +// and the voxel surface: that field, in whole world pixels. +float waveAt(vec2 q) { + if (waveHeight <= 0.0) return 0.0; + return floor(waveRaw(q) * waveHeight + 0.5); +} + +// The tilt this column reflects with -- taken from the SMOOTH field, not +// from the stepped one, and this is the difference between a moon on the +// water and confetti. +// +// Floored heights are integers, so their differences are integers too: a +// column's neighbours are level with it or a whole pixel off, and nothing in +// between. Build the normal out of THOSE and the reflected ray can only ever +// point in about five directions -- straight up, or rotated by twice the +// arctangent of one step, or of two. A flat sky does not mind; the sun and +// the moon are discs barely two degrees across, and a ray that jumps in +// eighteen-degree increments simply steps over them. The lake goes dark and +// the odd column that happens to land dead on flares -- which is exactly +// what "the moon doesn't reflect right" looks like. +// +// The columns are an approximation of a real surface, and light reflects off +// the surface being approximated. So the SHAPE stays quantised -- it is what +// you see, and it is the whole point -- while the normal is read off the +// smooth field the shape is made from. Still one answer per column, because +// `q` is an integer: pixel-quantised in space, continuous in value, which +// puts the glitter path back without softening a single edge. +// +// Forward differences over one pixel: three samples, and the answer only has +// to say which way this piece of the surface leans. +vec3 waveNormal(vec2 q, float tilt) { + float h = waveRaw(q); + float e = waveRaw(q + vec2(1.0, 0.0)) - h; + float s = waveRaw(q + vec2(0.0, 1.0)) - h; + return normalize(vec3(-e * tilt, 1.0, -s * tilt)); +} + + +// Walk the view ray down through the wave slab and return the column it +// actually meets -- RELIEF MAPPING, and the whole reason the bars read as +// solid rather than as a pattern painted on a flat sheet. +// +// The mesh is still one flat quad per tile, so what gets rasterised is the +// point where the ray crosses the BASE plane. The visible surface is +// somewhere above that, and the two differ by more the lower the camera +// sits. So the ray is walked BACKWARD to the top of the slab and then +// stepped down: the first column whose top it falls below is what the eye is +// looking at, and everything shorter behind that column is hidden by it for +// free, because the march simply never reaches it. +// +// A step that lands below a column's top having just ARRIVED in that column +// is looking at its side; one that was already there and fell through is +// looking at its top. That is the whole of the face test, and it is what +// gives a crest a lit face and a shaded one. +// +// `axis` names which way the face it found points -- 0 top, 1 east/west, 2 +// north/south -- because the wireframe needs to know the one direction the +// face does not vary along (see columnSeam). +void relief(vec3 base, vec3 dir, out vec3 hit, out vec2 col, out float face, + out float axis) { + col = floor(base.xz); + hit = base; + face = 1.0; + axis = 0.0; + float dy = -dir.y; + // a ray running level along the surface has no slab to walk through, and + // dividing by its descent would send the start point to infinity + if (waveHeight <= 0.0 || dy < 0.02) return; + float across = length(dir.xz); + float reach = waveHeight / dy; + // How far across the surface the whole slab displaces the answer. Under + // half a pixel it cannot pick a different column than the one already + // under the fragment, so the march would spend its samples arriving where + // it started -- which is exactly the case at the steep rungs, where the + // camera looks nearly straight down the columns and there is no side of a + // bar to see anyway. + float span = reach * across; + if (span < 0.5) { + hit.y = base.y + waveAt(col); + return; + } + // and the other end: `reach` grows as one over the descent, so a grazing + // ray asks for hundreds of world pixels of march from a fixed number of + // samples. + // + // What one sample is worth is a SCREEN pixel of surface, so that is the + // stride (see WAVE_STRIDE). A screen pixel covers this much of the water: + // the distance to the eye times the angle one pixel subtends, opened out + // by the obliquity -- a surface seen edge-on runs away far faster per + // pixel than one seen face-on. Floored at a world pixel, because up close + // a finer stride than the columns themselves buys nothing and skipping + // them costs everything. + float dist = length(base - eye); + float stride = max(WAVE_STRIDE, dist * pxAngle / dy); + float maxSpan = float(WAVE_STEPS) * stride; + if (span > maxSpan) { reach = maxSpan / max(across, 1e-4); } + vec3 top = base - dir * reach; + vec2 wasCol = floor(top.xz); + for (int i = 1; i <= WAVE_STEPS; i++) { + vec3 p = mix(top, base, float(i) / float(WAVE_STEPS)); + vec2 q = floor(p.xz); + float y = base.y + waveAt(q); + if (p.y <= y) { + col = q; + hit = vec3(p.x, y, p.z); + vec2 d = q - wasCol; + if (abs(d.x) + abs(d.y) < 0.5) { + face = 1.0; // fell through the top + axis = 0.0; + } else if (abs(d.x) > abs(d.y)) { + face = (d.x > 0.0) ? faceShade.y : faceShade.x; // west : east + axis = 1.0; + } else { + face = (d.y > 0.0) ? faceShade.w : faceShade.z; // north : south + axis = 2.0; + } + return; + } + wasCol = q; + } +} + +// The water's own art, read at the column the ray landed on rather than at +// the fragment's own place on the flat quad -- otherwise the bars parallax +// away and the pixels they are made of stay behind on the plane. +// +// Read off the COLUMN, not off the fragment. +// +// One world pixel is one atlas texel exactly, and the mesher lays a tile's +// eight texels across its eight world pixels -- so the column at world +// (cx, cz) wears texel (cx mod 8, cz mod 8) and nothing else. That makes the +// lookup exact, and far more importantly STABLE: the art a column shows +// depends only on where that column stands in the world, so it cannot swim +// as the camera moves and two fragments that landed on the same column +// cannot disagree about it. +// +// Offsetting the fragment's own uv by the parallax instead makes the art +// depend on how far the march happened to travel -- and wherever the march +// skipped a column, neighbouring fragments picked texels several apart. That +// is what peppered the surface with noise, and why it cleared up in patches: +// the patches are where the march was not skipping. +// +// The tile origin is the FRAGMENT's, so the lookup can never leave the tile +// this quad was built to sample -- the same bleed the mesher's INSET stops. +vec2 waveUV(vec2 tc, vec2 col) { + vec2 texel = 1.0 / atlasSize; + vec2 tile = 8.0 * texel; + vec2 org = floor(tc / tile) * tile; + return org + (mod(col, 8.0) + 0.5) * texel; +} + +// The float parameters are pinned to mediump BECAUSE the stage default is +// not: LOVE's own header forward-declares effect() under its default, and +// at least one mobile compiler (Samsung's Xclipse, in so many words) holds +// that a definition whose parameter precisions differ from its prototype's +// is a second function of the same name, and refuses the pair. The params +// can afford it -- the colour is a colour, and tc/sc arrived through +// LOVE's mediump plumbing whatever this signature says -- and the maths +// below runs on the stage default the moment the values touch a local. +vec4 effect(mediump vec4 color, Image tex, mediump vec2 tc, mediump vec2 sc) { + // THE DEPTH TEST, done here because the buffer that would have done it is + // detached for the length of this pass so it can be READ (see the header). + // Same comparison, same buffer, same result: a building in front of a pond + // still hides it. + // + // Normalised by LOVE's own screen size, not by the `screen` uniform: `sc` + // arrives in canvas PIXELS, and on a highdpi surface (Android's density + // is routinely 2.625) a canvas holds that many pixels per canvas UNIT, + // which is what `screen` counts. Divided by units, uv runs to 2.6 and + // clamps, and the test reads edge texels for two thirds of the frame -- + // discarding water in blocks and letting the haze backdrop through, which + // on a phone looked like lakes with pieces missing. love_ScreenSize.xy is + // the bound canvas's own pixel size, the same units sc is measured in, on + // every display. (`screen` stays in units: skyPos reads it against cell + // and skyEdge, which are unit-measured with it.) + vec2 uv = sc / love_ScreenSize.xy; + if (gl_FragCoord.z > Texel(depthTex, uv).r) discard; + + // THE COLUMN THIS FRAGMENT IS LOOKING AT. Every water pixel is a bar of + // its own standing a whole number of pixels tall, and the ray decides + // which one it meets -- so what follows is answered per COLUMN and not per + // screen pixel: one colour to a bar, at the resolution the water art is + // drawn at, with no smooth shading anywhere across it. (The depth test + // above is the one thing that stays per fragment: that is the hardware's + // own question and it is asked in screen space.) + vec3 view = normalize(vBent - eye); + vec3 hit; + vec2 col; + float face; + float axis; + relief(vBent, view, hit, col, face, axis); + // and the bar's centre, so a column is sampled and reflected from one + // place rather than from wherever inside it the fragment happened to land + vec3 surf = vec3(col.x + 0.5, hit.y, col.y + 0.5); + + vec4 p = Texel(tex, waveUV(tc, col)); + if (p.a < 0.5) discard; + // `face` is the column's own side shading, which is what makes a crest + // read as a solid thing with a lit flank rather than as a bright patch + vec3 base = p.rgb * vShade * face * sunlight(vSun) * dayTint; + + // the reflection follows the WAVES' own shape -- the tilt this column + // takes from the neighbours it stands beside -- rather than an invented + // wobble, so the sky and the sun break along the bars instead of across + // them. Opened up by the lean, which is about to squash it (see below). + vec3 n = waveNormal(col, waveSlope * (1.0 + lean * waveSlopeLean)); + vec3 r = reflect(view, n); + // the same reflection off a LEVEL surface, which is what the lean below + // moves: the difference between the two is this column's own contribution + vec3 rFlat = reflect(view, vec3(0.0, 1.0, 0.0)); + // The horizon lean (see LEAN_FROM in Water.lua): zero at the rung whose + // horizon is in frame, so the waterline join is untouched; taking the ray + // down to the elevation THAT rung reflects at as the camera tips over, + // where there is no join to break and a straight-up reflection has nothing + // in it. Applied to the sky, the body AND the march, because the same + // seventy-five-degree ray that misses the sun also leaves the frame. + // + // What is leaned is the LEVEL reflection, with this column's own deflection + // put back on top afterwards. Leaning the perturbed ray instead sets its + // elevation outright, which overwrites the very variation the waves are + // there to provide: at full lean every column on the lake reflects the + // same elevation, the sky comes out one flat band and the moon -- a disc + // two degrees wide that the ray now never sweeps past -- vanishes + // completely. Which is exactly what it did. + // + // The ray keeps its own BEARING and is only tipped in elevation, so a + // reflection still points where the water is pointing it -- and a ray so + // near vertical that it has no bearing left borrows the camera's. + if (lean > 0.0) { + float fl = length(rFlat.xz); + vec3 bearing = (fl > 1e-3) ? vec3(rFlat.x / fl, 0.0, rFlat.z / fl) + : lookFlat; + float e = mix(asin(clamp(rFlat.y, -1.0, 1.0)), leanElev, lean); + r = normalize(bearing * cos(e) + vec3(0.0, sin(e), 0.0) + (r - rFlat)); + } + + // The checkerboard the sky's bands and the sunset's glow are dithered on, + // cut from the WATER's own columns rather than from the screen. Same + // reasoning the window glint follows in the scene shader: a pattern + // anchored to the screen has the world sliding through it at zoom speed + // whenever the camera pans, which strobes. Anchored to the surface, + // panning moves nothing and only the waves do. + float parity = mod(col.x + col.y, 2.0); + vec3 refl = base; + if (skyOn > 0.5) { + refl = bodyAt(r, skyAt(r, parity), parity); + } + if (rays > 0.5) { + vec4 hit = march(surf, r); + refl = mix(refl, hit.rgb, hit.a); + } + + // Schlick, floored and softened (see FRESNEL_* in Water.lua): the angle + // still decides, a grazing camera still gets a mirror, and a steep one + // still gets a pond rather than a flat sticker. + float ct = clamp(dot(-view, n), 0.0, 1.0); + float f = fresnelFloor + + (fresnelCeil - fresnelFloor) * pow(1.0 - ct, fresnelPower); + vec3 rgb = mix(base, refl, clamp(f, 0.0, 1.0)); + +#ifdef VOXEL_GRID + rgb *= 1.0 - gridDark * columnSeam(hit, vBent, axis); +#endif + return vec4(rgb, 1.0) * color; +} +#endif +]] + +-- The crater list is Sky's (Sky.MOON_CRATERS), pasted in as source rather +-- than sent as a uniform array: GLSL ES has no array constructors worth +-- relying on, and the driver bug that cost the sky its bands is exactly +-- what a uniform array of vectors buys. Built from the one list, so the +-- moon on the water can never grow craters the moon in the sky has not. +local function craterSource() + local out = {} + for _, c in ipairs(Sky.MOON_CRATERS) do + out[#out + 1] = (" k += crater(dc, vec2(%.4f, %.4f), %.4f);") + :format(c[1], c[2], Sky.CRATER_FRAC) + end + return table.concat(out, "\n") +end + +Water._craterSource = craterSource -- named for the suite + +-- and the wave trains, pasted in for the same reason: the rate is derived +-- from this table (Water.waveRate), so the field the shader sums has to be +-- the one that table describes rather than a copy of it kept in step by hand. +-- +-- The dominant train carries the swell and the bend (see WAVE_SWELL): its +-- phase wobbles by the bend field and its amplitude breathes with the swell +-- envelope, both off the same tables the constants above document. One +-- statement per train either way, which is what the suite counts. +local function trainSource() + local out = {} + for i, t in ipairs(Water.WAVE_TRAINS) do + if i == 1 then + local s = Water.WAVE_SWELL + local b = Water.WAVE_BEND + out[#out + 1] = ( + " h += sin(dot(q, vec2(%.4f, %.4f)) + waveT * %.4f\n" + .. " + %.4f * sin(dot(q, vec2(%.4f, %.4f)) + waveT * %.4f))\n" + .. " * %.4f * (1.0 - %.4f * (0.5 + 0.5 *\n" + .. " sin(dot(q, vec2(%.4f, %.4f)) + waveT * %.4f)));") + :format(t[1], t[2], t[3], + b[4], b[1], b[2], b[3], + t[4], s[4], s[1], s[2], s[3]) + else + out[#out + 1] = (" h += sin(dot(q, vec2(%.4f, %.4f)) + waveT * %.4f)" + .. " * %.4f;"):format(t[1], t[2], t[3], t[4]) + end + end + return table.concat(out, "\n") +end + +Water._trainSource = trainSource -- named for the suite + +local function source(grid) + local src = SHADER_SRC:gsub("//@CRATERS", (craterSource():gsub("%%", "%%%%"))) + src = src:gsub("//@TRAINS", (trainSource():gsub("%%", "%%%%"))) + local head = ("#define RAY_STEPS %d\n#define RAY_REFINE %d\n" + .. "#define WAVE_STEPS %d\n#define WAVE_STRIDE %.1f\n") + :format(Water.RAY_STEPS, Water.RAY_REFINE, Water.WAVE_STEPS, + Water.WAVE_STRIDE) + if grid then head = head .. "#define VOXEL_GRID 1\n" end + return head .. src +end + +Water._source = source -- named for the suite + +-- Two compilations, exactly as Voxel3D keeps two of the scene shader: the +-- wireframe variant needs derivatives, the one thing a driver can refuse, +-- and a refusal must cost the seams on the water and nothing else. +-- nil = untried, false = unavailable. +local shaders = { [false] = nil, [true] = nil } + +function Water.shader(grid) + grid = grid and true or false + if shaders[grid] == nil then + if not (love.graphics and love.graphics.newShader) then + shaders[grid] = false + else + local ok, sh = pcall(love.graphics.newShader, source(grid)) + if not ok and V and V.mod and V.mod.log then + -- once, where it can be read: the fallback is flat water, which is + -- easy to look at and impossible to diagnose without this line + V.mod.log:warn("water shader did not compile: %s -- lakes draw flat", + tostring(sh)) + end + shaders[grid] = (ok and sh) or false + end + end + return shaders[grid] or nil +end + +-- ------- the pass + +local active = nil -- the shader this pass bound, or nil + +-- The ripple phase. Driven by the ENGINE's tile-animation clock, the same +-- 60Hz counter the water tiles rotate on, so the ripple and the art it +-- ripples move off one number rather than drifting against each other. +local function waveTime() + -- lazily, and through the mod namespace: TerrainAtlas reaches into the + -- engine's renderer at load time, and nothing about a settings row should + -- depend on that having happened yet + local ok, frame = pcall(function() + return V.require("TerrainAtlas")._animFrame() + end) + if not (ok and type(frame) == "number") then return 0 end + -- floored to the wave beat (see WAVE_FPS). The engine's counter runs at + -- 60, so this is the frame that step began on. + local period = 60 / math.max(1, Water.WAVE_FPS) + return (math.floor(frame / period) * period / 60) * Water.waveRate() +end + +Water._waveTime = waveTime + +-- Begin the reflective pass. +-- +-- `ctx` is everything the pass cannot work out for itself, all of it already +-- computed by whoever set the camera up this frame: +-- +-- reflect the frame so far, as a texture (Voxel3D.beginWater) +-- depth its depth, likewise +-- vp, eye, curve, screen, cell the camera, as beginScene sent it +-- skyEdge where the sky's bottom is, or nil indoors / with no bands +-- grid whether the voxel wireframe is compiled into this frame +-- +-- Returns false when the pass cannot run, in which case the caller draws the +-- water mesh through the ordinary scene shader instead. +function Water.begin(ctx) + if not (ctx and ctx.reflect and ctx.depth) then return false end + local level = Water.level() + if level <= 0 then return false end + local sh = ctx.grid and Water.shader(true) or nil + if not sh then sh = Water.shader(false) end + if not sh then return false end + + love.graphics.setShader(sh) + love.graphics.setColor(1, 1, 1, 1) + local function send(name, ...) + pcall(sh.send, sh, name, ...) + end + + send("vp", "row", ctx.vp) + send("eye", ctx.eye) + send("curve", ctx.curve) + send("screen", { ctx.screen[1], ctx.screen[2] }) + send("cell", math.max(1, ctx.cell or 1)) + -- how much of the view one screen pixel is worth: what sets the relief + -- march's stride, so a sample is always about a pixel of surface + send("pxAngle", (ctx.fov or 1) / math.max(1, ctx.screen[2])) + send("reflectTex", ctx.reflect) + send("depthTex", ctx.depth) + + -- the sun's pass, sent the same way and for the same reason the scene + -- shader sends it: the sampler is declared either way, and leaving one + -- unbound is a driver-dependent crash rather than a fallback + local map = ShadowMap.active() + send("sunVP", "row", map and ShadowMap.uvVP or Mat4.identity()) + local tex = ShadowMap.texture() + if tex then send("sunMap", tex) end + local Voxel3D = V.require("Voxel3D") + send("sunDark", map and Voxel3D.SHADOW_ALPHA or 0) + send("sunBias", ShadowMap.bias) + local texel = 1 / ShadowMap.res + send("sunTexel", { texel, texel }) + send("dayTint", Voxel3D.tint or { 1, 1, 1 }) + + send("rays", level >= 2 and 1 or 0) + -- the horizon lean, and the direction it leans toward (see Water.lean) + send("lookFlat", ctx.lookFlat or { 0, 0, -1 }) + send("lean", Water.lean(ctx.descent)) + send("leanElev", Water.LEAN_ELEV) + send("waveHeight", Water.WAVE_HEIGHT) + send("waveSlope", Water.WAVE_SLOPE) + send("waveSlopeLean", Water.WAVE_SLOPE_LEAN) + send("waveT", waveTime()) + -- the columns' side faces wear the MESH's own direction shading, sent in + -- rather than restated, so a wave crest is lit like every other voxel + local fs = Voxel3D.FACE_SHADE + send("faceShade", { fs[1], fs[2], fs[5], fs[6] }) -- east, west, south, north + send("fresnelFloor", Water.FRESNEL_FLOOR) + send("fresnelCeil", Water.FRESNEL_CEIL) + send("fresnelPower", Water.FRESNEL_POWER) + send("rayStep", Water.RAY_STEP) + send("rayGrow", Water.RAY_GROW) + send("rayThick", Water.RAY_THICK) + send("edgeFade", Water.EDGE_FADE) + if ctx.grid then + local VoxelGrid = V.require("VoxelGrid") + send("gridDark", VoxelGrid.DARK) + send("gridWidth", VoxelGrid.WIDTH) + end + + Water.sendSky(sh, ctx) + active = sh + return true +end + +-- The sky half of the uniforms: the band ramp, and whatever hangs in it. +-- +-- Split out because it is the part with a "there is none" answer -- indoors, +-- and on any frame whose ramp could not be built -- and that answer has to +-- leave every sampler bound anyway. skyOn 0 reflects the water's own colour +-- back at itself, which is what a pond in a cave does. +function Water.sendSky(sh, ctx) + local function send(name, ...) + pcall(sh.send, sh, name, ...) + end + local ramp, count = Sky.ramp() + local edge = ctx.skyEdge + if not (ramp and count and edge and edge > 0) then + -- the sampler still has to hold something; the ramp is the only image + -- this shader has for the job, so bind the frame copy and switch it off + send("skyRamp", ctx.reflect) + send("skyCount", 1) + send("skyEdge", 1) + send("skyStart", 2) + send("skyOn", 0) + send("bodyOn", 0) + send("glowAmt", 0) + return + end + send("skyRamp", ramp) + send("skyCount", count) + send("skyEdge", edge) + send("skyStart", Sky.DITHER and Sky.DITHER_START or 2) + send("skyOn", 1) + + local body = DayNight.body() + if not body then + send("bodyOn", 0) + send("glowAmt", 0) + return + end + local amt, glowColor = DayNight.glow() + local h = ctx.screen[2] + local cell = math.max(1, ctx.cell or 1) + -- Sky sizes the disc in canvas pixels; out here the reflected body is + -- usually off the top of the frame, where a pixel is not a unit any more + -- -- so it is converted to the ANGLE it subtends through this camera's + -- own field of view, which is the same number wherever it is looked at. + local perRadian = h / math.max(1e-4, ctx.fov or 1) + local rpx = Sky.discRadius(h, cell, + { moon = body.moon, glowAmt = amt }) + local shades = Sky.discShades(body.moon) + local function shade(i) + local c = shades[i] or shades[#shades] or { 255, 255, 255 } + return { c[1] / 255, c[2] / 255, c[3] / 255 } + end + local twilight = (amt or 0) > 0.25 and not body.moon + send("bodyOn", 1) + send("bodyMoon", body.moon and 1 or 0) + send("bodyDir", { body.dx, body.dy, body.dz }) + send("bodyAng", rpx / perRadian) + send("bodyCore", shade(1)) + send("bodyMain", shade(twilight and 3 or 2)) + send("bodyDark", shade(3)) + send("glowAmt", (not body.moon) and (amt or 0) or 0) + send("glowColor", glowColor + and { glowColor[1] / 255, glowColor[2] / 255, glowColor[3] / 255 } + or { 1, 0.88, 0.66 }) + send("glowReach", (ctx.screen[1] * Sky.GLOW_REACH) / perRadian) +end + +-- Draw one water mesh with `model` applied. Mirrors Voxel3D.draw, minus the +-- camera-ward pull (a flat sheet has nothing to lean over) and the separate +-- sun transform (water is terrain: the sun saw the same matrix). +function Water.draw(mesh, texture, model) + if not (active and mesh) then return end + if texture then mesh:setTexture(texture) end + pcall(active.send, active, "model", "row", model or Mat4.identity()) + -- Per draw rather than per pass, and read off the TEXTURE rather than + -- assumed: it is what converts a world pixel of the columns' parallax into + -- the texel of art standing on it, and two maps in one frame can be drawn + -- from atlases of different sizes. + if texture and texture.getDimensions then + local ok, w, h = pcall(texture.getDimensions, texture) + if ok and w and h then + pcall(active.send, active, "atlasSize", { w, h }) + end + end + love.graphics.draw(mesh) +end + +function Water.finish() + active = nil +end + +-- Drop the compiled shaders (window resize, hot reload): they are GPU +-- objects on a context that may not exist any more. +function Water.invalidate() + for k, sh in pairs(shaders) do + if sh and sh.release then pcall(sh.release, sh) end + shaders[k] = nil + end + active = nil +end + +return Water diff --git a/main.lua b/main.lua index ccef967..95ca933 100644 --- a/main.lua +++ b/main.lua @@ -81,6 +81,7 @@ local OverworldBattle = V.require("OverworldBattle") local BattleExit = V.require("BattleExit") local DayNight = V.require("DayNight") local DayTint = V.require("DayTint") +local Water = V.require("Water") -- Forward declaration: the voxel pipeline's update hook (registered below) -- calls this, and it is defined further down with the settings it drives. @@ -282,6 +283,10 @@ applyFull = function(level) -- the horizon flat. The curve bends the world away from a walking player, -- which fights a fixed diorama framing WorldCurve.setting:setIndex(1, Game) + -- and the water reflecting everything it can: FULL is the diorama at its + -- most photographed, and a lake with the sky and the shoreline in it is + -- most of what makes the model read as being outdoors + Water.setting:setIndex(1, Game) -- and the view fitted to the window opts.zoom = 0 Zoom.applyOptions(opts) @@ -332,6 +337,11 @@ local SETTINGS = { { VoxelGrid.setting, "One-pixel wireframe along every voxel edge." }, { WorldCurve.setting, "Bend the world down over the horizon, Animal Crossing style." }, + { Water.setting, + "Reflections on water. FULL adds screen-space reflections of the " + .. "shoreline, the trees and the buildings behind it; SKY is the sky, " + .. "the sun and the moon alone, which is most of the look for a " + .. "fraction of the cost." }, -- `full` marks a row FULL does not take away. FULL owns the diorama's own -- knobs; what a battle is drawn over, and how it is framed, are not that. { OverworldBattle.setting, @@ -366,6 +376,7 @@ mod.options:define(schema) -- 6 T-SHIFT cycle the blur ladder (was 9) -- 7 V-CURVE cycle the horizon bend (new) -- 8 3D-BTL toggle overworld battles (new) +-- 9 WATER cycle the water reflections (new; 9 was T-SHIFT's old key) -- -- Only 6 arrives by the documented route. Game:keypressed answers the -- engine's own display keys FIRST and returns -- 2 COLORS, 3 TILT, 4 ZOOM, @@ -398,6 +409,7 @@ local HOTKEYS = { ["5"] = VoxelGrid.setting, ["7"] = WorldCurve.setting, ["8"] = OverworldBattle.setting, + ["9"] = Water.setting, } do @@ -447,19 +459,19 @@ do return end elseif Pipelines.canToggle("voxel", top, self.overworld) then - -- All three answer to the voxel pass's own free-roam gate -- + -- All four answer to the voxel pass's own free-roam gate -- -- borrowed from the registry rather than restated, so a press -- mid-warp or mid-cutscene is refused for the wireframe exactly when - -- it would be for the mode itself. Two of them parameterise that - -- pass; the third (3D-BTL) decides what a battle is drawn over, and + -- it would be for the mode itself. Three of them parameterise that + -- pass; the fourth (3D-BTL) decides what a battle is drawn over, and -- wants the same gate for a different reason: the answer is read -- when the fight starts, so flipping it from inside one would be a -- switch that appeared to do nothing. claim:cycle(self) -- 8 is one of the two ways staged battles get switched on, and they - -- pin BATTLE LAYOUT to OG (see the rows hook). The other two keys + -- pin BATTLE LAYOUT to OG (see the rows hook). The other keys -- parameterise the pass and leave the layout alone; the guard answers - -- for all three, so nothing here has to know which key it was. + -- for all of them, so nothing here has to know which key it was. if stagedBattles() then OverworldBattle.forceOG(self) end return end @@ -848,7 +860,7 @@ mod.hooks:wrap("world.tod", function(next, tod, ctx) return DayNight.tod() end) -mod.exports.version = "1.3.1" +mod.exports.version = "1.4.0" -- 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 ef2c15c..157448b 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "DRAMATIC_SHAPE", "name": "Dramatic Shape Voxel Mod", - "version": "1.3.1", + "version": "1.4.0", "api": 2, "entry": "main.lua", "profile": "content", @@ -15,5 +15,6 @@ "engine_internals" ], "affects_link": false, - "description": "A full 3D diorama overworld: extruded terrain, depth-buffered occlusion, voxel characters and a tilt-shift miniature pass -- and battles fought on the map itself, shot over the shoulder at the nearest clear ground with a slow parallax drift and a depth-of-field pass. Registers two render pipelines and claims hotkeys 3, 5, 6, 7 and 8 -- 3 and 5 displace the engine's TILT and GBC FX keys, both still reachable on the OPTIONS menu. Presentational only: it changes what a battle is drawn over, never where anybody stands." + "description": "A full 3D diorama overworld: extruded terrain, depth-buffered occlusion, voxel characters and a tilt-shift miniature pass -- and battles fought on the map itself, shot over the shoulder at the nearest clear ground with a slow parallax drift and a depth-of-field pass. Water reflects the sky, the sun, the moon and -- through a screen-space ray march -- the shoreline standing behind it. Registers two render pipelines and claims hotkeys 3, 5, 6, 7, 8 and 9 -- 3 and 5 displace the engine's TILT and GBC FX keys, both still reachable on the OPTIONS menu. Presentational only: it changes what a battle is drawn over, never where anybody stands.", + "github": "DramaticShape/DramaticShapeVoxelMod" } diff --git a/mod.card b/mod.card index d7f9644..af77b03 100644 --- a/mod.card +++ b/mod.card @@ -21,6 +21,7 @@ return { "VOXEL options row and hotkey 3 (OFF / 15 / 35 / 50 / 75 degrees)", "T-SHIFT options row and hotkey 6 (OFF / 1 / 2 / 3), the miniature blur", "V-GRID on hotkey 5 and V-CURVE on hotkey 7", + "WATER on hotkey 9 (FULL / SKY / OFF, FULL by default): the water surface becomes a field of pixel-tall voxel columns rising and falling as waves, reflecting the sky, the sun, the moon and the cast standing beside it -- and, on FULL, the shoreline, trees and buildings behind it, by a screen-space ray march", "3D-BTL on hotkey 8 (ON / OFF, on by default), battles fought on the world map", "BACK SPRITES options row (OFF / ON, off by default), which keeps your own Pokemon on the battle menu in its classic slot while the foe stands out on the map", "a day/night clock that reaches the flat 2D overworld as well as the diorama -- outdoor maps only, and only when the hour is not midday", @@ -30,6 +31,9 @@ return { }, known = { "needs shader and depth-canvas support; without them the rows still cycle but the world stays 2D and battles draw plainly", + "water reflections additionally need a READABLE depth canvas; a driver without one draws the flat animated water this mode always drew", + "WATER on FULL ray-marches the depth buffer per water pixel, so a map that is mostly sea costs real fill rate on a weak GPU -- SKY is the same look minus the ray march, and OFF is the flat water", + "a screen-space reflection can only reflect what is in the frame: a tree just off the top edge is not in the water below it, and a ray that runs off the side fades into the sky rather than ending on a line", "a map with no 3x6 clearing falls back to a 1x4 one, and a map with neither draws the plain battle screen", "the arena is where the CAMERA goes -- nobody is moved, so a fight staged across the map is a shot of that ground, not a trip to it", "the battle backdrop renders at the GB's 160x144 to match the pics composited over it, so it is chunkier than the free-roam pass", diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index 95b969d..6bdfb73 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -298,7 +298,7 @@ local order = {} for i, row in ipairs(grouped) do order[row.id] = i end T.check(order["pipeline:tiltshift"] < order["DRAMATIC_SHAPE:grid"], "the mode's settings follow its pipeline rows") -T.eq(order["DRAMATIC_SHAPE:battles"] - order["pipeline:tiltshift"], 3, +T.eq(order["DRAMATIC_SHAPE:battles"] - order["pipeline:tiltshift"], 4, "and sit in one unbroken block, not scattered to the end of the list") T.check(order["void_fill"] > order["DRAMATIC_SHAPE:battles"], "with the engine's own later rows still after them") @@ -379,9 +379,15 @@ end Pipelines.setLevel("voxel", 2) local hookedRows = Runtime.call("ui.options.rows", function(_, r) return r end, { data = Data }, { { id = "text_speed" } }) -T.eq(#hookedRows, 6, "the options hook added a row per setting") -local grid, curve, battles = hookedRows[2], hookedRows[3], hookedRows[4] -local backRow, daytime = hookedRows[5], hookedRows[6] +T.eq(#hookedRows, 7, "the options hook added a row per setting") +local grid, curve, water = hookedRows[2], hookedRows[3], hookedRows[4] +local battles, backRow, daytime = hookedRows[5], hookedRows[6], hookedRows[7] +T.eq(water.label, "WATER", "the water row carries its label") +T.eq(water.value(), "FULL", + "and defaults to FULL -- reflections are the point of having the row") +water.step({ save = { options = {} }, mods = { modOptions = {} } }, 1) +T.eq(water.value(), "SKY", + "stepping down drops the screen-space march and keeps the sky, sun and moon") T.eq(daytime.label, "DAYTIME", "the day/night row carries its label") T.eq(daytime.value(), "SYNC", "and defaults to SYNC -- no value set follows the clock on the wall") @@ -1482,6 +1488,440 @@ T.eq(Sky.paint(320, 0, skyGrad, 40, 7), false, "and a frame with no height paints nothing at all") end +-- ------- reflections on water +-- +-- Water is the one surface in this mode that cannot be drawn with the rest +-- of the world: it is a mirror, and a mirror needs what it reflects to +-- already be down. So it is lifted out of the terrain mesh at BUILD time and +-- drawn as its own pass. That lift is the load-bearing part -- get it wrong +-- and a lake is either a hole in the world or is drawn twice -- and it is +-- pure geometry, so it is driven here against a hand-drawn map. +do +local Water = run.loader.exports.DRAMATIC_SHAPE.lib.require("Water") +local Sky = run.loader.exports.DRAMATIC_SHAPE.lib.require("Sky") +local ChunkMesher = run.loader.exports.DRAMATIC_SHAPE.lib.require("ChunkMesher") +local Structures = run.loader.exports.DRAMATIC_SHAPE.lib.require("Structures") +local Shapes = run.loader.exports.DRAMATIC_SHAPE.lib.require("TileShape") +local TileShapeHeights = Shapes.heights() + +-- ------- the ladder +-- +-- Three rungs, not a toggle: the sky half of this costs a handful of +-- instructions and the screen-space half costs a ray march, so a machine +-- that wants the sunset on the lake but not the march has somewhere to sit. +T.eq(Water.setting.values[1], "full", + "FULL is the default -- reflections are the point of having the row") +Water.setting:sync("full") -- the row test above stepped it +T.eq(Water.level(), 2, "and it reads back as the full pass") +T.eq(Water.enabled(), true, "which is on") +Water.setting:sync("sky") +T.eq(Water.level(), 1, "SKY keeps the pass but drops the screen-space march") +T.eq(Water.enabled(), true, "and is still a reflection") +Water.setting:sync("off") +T.eq(Water.level(), 0, "OFF is no pass at all") +T.eq(Water.enabled(), false, + "which is what puts the water back in the ordinary scene shader") +Water.setting:sync("full") + +-- ------- the waves are geometry, not shading -- and they step at 15fps +-- +-- The surface is a heightfield of one-world-pixel columns, each standing a +-- WHOLE number of pixels tall -- a voxel like every other voxel in this +-- mode -- and it advances in STEPS rather than sliding: 15 a second, the +-- cadence hand-drawn pixel art is animated at. A surface built out of whole +-- pixels that crawls smoothly between them gives away that the quantisation +-- is only skin deep. +do +local TerrainAtlas = run.loader.exports.DRAMATIC_SHAPE.lib.require("TerrainAtlas") +local realClock = TerrainAtlas._animFrame +local frame = 0 +TerrainAtlas._animFrame = function() return frame end +local function at(f) + frame = f + return Water._waveTime() +end + +local period = 60 / Water.WAVE_FPS +T.eq(period, 5, "12 steps a second is one every five engine frames") +T.eq(math.floor(period), period, + "and the beat divides the engine's 60 exactly, so every step spans the " + .. "same whole number of frames") +-- inside one step nothing moves; crossing one, it does +T.eq(at(0), at(period - 1), + "every frame inside one wave step gets the same phase -- the surface " + .. "steps rather than crawling between its own pixels") +T.neq(at(0), at(period), "and the step boundary is where it moves") + +local steps = {} +for f = 0, 59 do steps[at(f)] = true end +local n = 0 +for _ in pairs(steps) do n = n + 1 end +T.eq(n, Water.WAVE_FPS, "which is WAVE_FPS distinct positions in a second") +TerrainAtlas._animFrame = realClock + +-- and the step is worth taking: one world pixel of the dominant train per +-- step, DERIVED from that train rather than tuned beside it, so a change of +-- wavelength moves the speed with it. A step the surface cannot resolve is +-- a smooth crawl wearing a quantised clock. +local t = Water.WAVE_TRAINS[1] +local freq = math.sqrt(t[1] * t[1] + t[2] * t[2]) +local travel = (Water.waveRate() / Water.WAVE_FPS) * math.abs(t[3]) / freq +T.check(math.abs(travel - Water.WAVE_PIXELS_PER_STEP) < 1e-9, + "each step advances the dominant crest by exactly WAVE_PIXELS_PER_STEP " + .. "world pixels, so nothing ever lands half-way between two") + +-- the trains reach the shader as source, off the same table the rate above +-- is derived from -- one list, so the two cannot drift +local trains = Water._trainSource() +T.eq(select(2, trains:gsub("h %+= sin", "")), #Water.WAVE_TRAINS, + "every train in the table is summed by the shader") +T.check(trains:find(("%.4f"):format(t[1]), 1, true) ~= nil, + "at the frequency the table states") + +-- the variation that keeps three periodic trains from reading as wallpaper: +-- the dominant train's amplitude breathes with the swell and its crests bow +-- with the bend, both pasted from their own tables like the trains are +T.check(trains:find(("%.4f"):format(Water.WAVE_SWELL[1]), 1, true) ~= nil + and trains:find(("%.4f"):format(Water.WAVE_BEND[1]), 1, true) ~= nil, + "the swell and the bend reach the shader off the tables that document " + .. "them, not off copies kept in step by hand") +T.check(Water.WAVE_SWELL[4] > 0 and Water.WAVE_SWELL[4] < 1, + "the swell's deepest lull thins the dominant train without deleting or " + .. "inverting it -- a sea with sets in it, not a sea that turns off") +for _, mod in ipairs({ Water.WAVE_SWELL, Water.WAVE_BEND }) do + local mf = math.sqrt(mod[1] * mod[1] + mod[2] * mod[2]) + T.check(mf * 3.5 < freq, + "a modulator's wavelength sits several times the carrier's, far enough " + .. "apart that it reads as weather over the waves rather than as a " + .. "fourth wave -- which would be the soup the weights exist to avoid") +end + +T.check(Water.WAVE_HEIGHT > -TileShapeHeights.water, + "the crests stand taller than the recess TileShape sinks water into -- " + .. "they are RELIEF inside the quad's own footprint, so a bar that reaches " + .. "above the bank is clipped at the water's edge rather than spilling") +end + +-- ------- the moon on the water is the moon in the sky +-- +-- The reflected disc is drawn by a shader and the painted one by rectangles, +-- so nothing but shared DATA can keep them the same moon. The crater list is +-- pasted into the shader source from Sky's own table, which is the seam that +-- makes "they cannot drift" true rather than merely intended. +local craters = Water._craterSource() +local craterLines = select(2, craters:gsub("crater%(", "")) +T.eq(craterLines, #Sky.MOON_CRATERS, + "the shader gets one crater per crater the painted moon has") +for _, c in ipairs(Sky.MOON_CRATERS) do + T.check(craters:find(("%.4f"):format(c[1]), 1, true) ~= nil, + "and each one at the offset the painted moon puts it at") +end +T.check(craters:find(("%.4f"):format(Sky.CRATER_FRAC), 1, true) ~= nil, + "at the same fraction of the disc's radius") + +-- and the disc is the same SIZE, which is the other half of being the same +-- moon: one function answers for the painted radius and for the angle the +-- reflection subtends it at +local px, cells = Sky.discRadius(288, 7, { moon = true }) +T.eq(cells, Sky.DISC_MIN, + "a small frame floors the disc at its minimum radius in cells") +T.eq(px, Sky.DISC_MIN * 7, "reported in canvas pixels on that cell grid") +T.eq(select(2, Sky.discRadius(288, 7, { glowAmt = 0.9 })), Sky.DISC_MIN + 1, + "and the low sun looms, exactly as the painted one does") +T.eq(select(2, Sky.discRadius(288, 7, { glowAmt = 0.9, moon = true })), + Sky.DISC_MIN, "which is a SUNSET exaggeration -- the moon never looms") + +-- the same band ramp, too: one texture, so the sky on the lake cannot be a +-- different palette from the sky over it +local rampImg, rampCount = Sky.ramp() +T.check(rampImg == nil or rampCount == #Sky.bands(), + "the reflection reads the sky off the very ramp the sky is painted from") + +-- ------- the horizon lean: the reflection has to have something IN it at +-- every rung, not just the one whose horizon is in frame +-- +-- The rungs are named for the camera's tilt off VERTICAL, so at 15 the eye +-- meets the water nearly head-on and the mirror ray points 75 degrees UP -- +-- where the sky's bands are darkest, the sun and moon (squashed to about 6 +-- degrees) are nowhere near, and a screen-space ray leaves the frame in two +-- steps. All three are correct and together they are an empty lake. The lean +-- tips the reflection toward the way the camera looks by however far that +-- camera is from having a horizon in frame. +do +local Voxel3D = run.loader.exports.DRAMATIC_SHAPE.lib.require("Voxel3D") +local VoxelState = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelState") +local wasAngle, wasCam = VoxelState.angle, Voxel3D.camera +Voxel3D.camera = nil + +local lean = {} +for _, deg in ipairs({ 15, 35, 50, 75 }) do + VoxelState.angle = math.rad(deg) + Voxel3D.viewProjection(256, 256, 320, 288) + lean[deg] = { Water.lean(Voxel3D.descent), Voxel3D.descent } + -- the orbit looks NORTH, so the flattened view direction is -Z and level + T.check(math.abs(Voxel3D.lookFlat[3] + 1) < 1e-6, + ("the %d rung looks north along the ground plane"):format(deg)) + T.eq(Voxel3D.lookFlat[2], 0, + "flattened onto it, so the lean can never tip a reflection underground") +end + +-- descent is the SINE of how far below horizontal the view runs, and the +-- rungs are the camera's tilt off vertical -- so the two are complements +for _, deg in ipairs({ 15, 35, 50, 75 }) do + T.check(math.abs(lean[deg][2] - math.cos(math.rad(deg))) < 1e-6, + ("the %d rung descends by cos(%d)"):format(deg, deg)) +end + +T.eq(lean[75][1], 0, + "at the rung whose horizon is in frame there is NO lean -- the one place " + .. "the join can be seen (the waterline, where the lake meets the painted " + .. "sky) is still the exact reflection it always was") +T.check(lean[50][1] > 0, "and it comes in as the camera tips over") +T.check(lean[35][1] >= lean[50][1] and lean[15][1] >= lean[35][1], + "growing with every rung further from the horizon") +T.eq(lean[15][1], 1, + "and complete well before the steepest rung, so every rung under the top " + .. "one aims its reflection where the top one's already lands") + +-- a camera looking dead level has nothing to lean +T.eq(Water.lean(0), 0, "a level camera leans not at all") +T.eq(Water.lean(1), 1, "and one looking straight down leans all the way") +T.eq(Water.lean(Water.LEAN_FROM), 0, + "the ramp starts exactly where the top rung sits, so that rung is the one " + .. "the lean never touches") +T.check(math.abs(math.sin(Water.LEAN_ELEV) - Water.LEAN_FROM) < 1e-12, + "and the elevation it aims at IS that rung's own, stated as the same " + .. "number rather than beside it") + +VoxelState.angle, Voxel3D.camera = wasAngle, wasCam +end + +-- ------- people do not shadow water +-- +-- The sun pass is ONE map, so a surface cannot ask what threw a shadow +-- unless the map says -- and it does, in the blue channel, which was zero +-- anyway. Water is the only surface that asks: a character standing at a +-- lake's edge laid a hard cut-out of its own sprite across a surface already +-- showing the sky and the shoreline, which reads as a sticker rather than as +-- a shadow. Everything the world casts still shades it. +do +local ShadowMap = run.loader.exports.DRAMATIC_SHAPE.lib.require("ShadowMap") +T.check(type(ShadowMap.sprites) == "function", + "the sun pass can be told it is drawing the cast rather than the world") +-- inert outside a pass, like every other toggle on it -- a caller that +-- brackets a draw it never made must not send to a shader that is not bound +T.check(pcall(ShadowMap.sprites, true) and pcall(ShadowMap.sprites, false), + "and saying so outside one is harmless") + +local shadowSrc = ShadowMap._source and ShadowMap._source() or nil +if shadowSrc then + T.check(shadowSrc:find("fract(d), sprite", 1, true) ~= nil, + "the marker rides the channel the depth pack left free, so it costs " + .. "nothing: the map is still two channels of depth") +end +end + +-- ------- the compiled variants +local plain = Water._source(false) +local gridded = Water._source(true) +T.check(plain:find("#define WAVE_STEPS " .. Water.WAVE_STEPS, 1, true) ~= nil, + "the relief march's step count is compiled in too") +-- the whole surface is answered per COLUMN: the ray picks one, and the art, +-- the shading, the reflection and the dither all read that one rather than +-- the fragment's own place on the flat quad. A smoothly-shaded reflection +-- over hard-edged 8-bit water is two pictures stacked. +T.check(plain:find("floor(waveRaw(q) * waveHeight + 0.5)", 1, true) ~= nil, + "column heights are floored to WHOLE world pixels -- a fractional step is " + .. "a smooth wave with extra arithmetic, not a bar") +-- and the normal is read off the SMOOTH field underneath, which is the +-- difference between a moon on the water and confetti: integer heights give +-- integer differences, so a normal built from them can only point in about +-- five directions and a two-degree disc falls between them +T.check(plain:find("float h = waveRaw(q);", 1, true) ~= nil, + "but the reflection's normal comes off the smooth surface the columns are " + .. "a quantisation of, so the ray sweeps instead of jumping") +T.check(plain:find("waveNormal(vec2 q, float tilt)", 1, true) ~= nil + and plain:find("waveNormal(col,", 1, true) ~= nil, + "still one answer per column, so the surface stays pixel-quantised in " + .. "space while the value it reflects with is continuous") +T.check(plain:find("relief(vBent, view, hit, col, face, axis)", 1, true) ~= nil, + "and the visible column is found by walking the view ray through the " + .. "slab, which is what makes a tall bar hide the short ones behind it") +-- the march's reach grows as one over the ray's descent, so a grazing camera +-- asks for hundreds of world pixels of it from a fixed number of samples -- +-- which stepped over whole crests and smeared the surface into streaks +-- a sample is worth a SCREEN pixel of surface, so that is the stride: held +-- at a world pixel up close (finer buys nothing and skipping costs the +-- pepper) and opened out with distance (holding it there just runs the march +-- out of samples part-way down the slab, which flattened the lowest rung's +-- whole middle distance) +T.check(plain:find("#define WAVE_STRIDE", 1, true) ~= nil + and plain:find("max(WAVE_STRIDE, dist * pxAngle / dy)", 1, true) ~= nil, + "the relief stride is a screen pixel's worth of surface, floored at a " + .. "world pixel") +T.check(Water.WAVE_STRIDE <= 1, + "and that floor is at most ONE world pixel, because a column is one world " + .. "pixel wide -- a longer one steps over columns, and which ones it " + .. "misses changes fragment to fragment, which is the peppery noise") +-- and the art is read off the COLUMN rather than by offsetting the +-- fragment's own uv by however far the march happened to travel: one world +-- pixel is one texel, so a column's texel follows from where it stands and +-- two fragments landing on the same column cannot disagree about it +T.check(plain:find("org + (mod(col, 8.0) + 0.5) * texel", 1, true) ~= nil, + "a column's art follows from its own world position, so it cannot swim " + .. "with the camera or speckle between neighbouring fragments") +T.check(plain:find("waveUV(tc, col)", 1, true) ~= nil, + "and the column is what is handed to it") + +-- the wireframe is ruled on the COLUMNS, not on the flat sheet they stand on +T.check(gridded:find("columnSeam(hit, vBent, axis)", 1, true) ~= nil, + "with V-GRID on, the seams outline the column the ray landed on -- every " + .. "voxel of water its own block -- rather than ruling a grid across the " + .. "flat quad underneath and ignoring the bars entirely") +T.check(gridded:find("vec3 w = fwidth(base);", 1, true) ~= nil, + "measured off the smooth plane, because the hit jumps a whole column " + .. "between neighbouring fragments and its own derivative is a step") +T.check(plain:find("march(surf, r)", 1, true) ~= nil, + "the reflection marches from that column, not from the raw fragment") +T.check(plain:find("mod(col.x + col.y, 2.0)", 1, true) ~= nil, + "and the dither's checkerboard is cut from the columns too, so a camera " + .. "pan slides the world through nothing") +T.check(plain:find("#define RAY_STEPS " .. Water.RAY_STEPS, 1, true) ~= nil, + "the march's step count is compiled in -- GLSL wants a constant bound") +T.check(plain:find("VOXEL_GRID", 1, true) ~= nil, + "the wireframe is guarded in the source") +T.check(plain:find("#define VOXEL_GRID", 1, true) == nil, + "and off in the plain variant") +T.check(gridded:find("#define VOXEL_GRID", 1, true) ~= nil, + "so a frame with the seams on gets its own compilation, like the scene " + .. "shader -- a driver that refuses derivatives loses the seams and not " + .. "the water") +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") +T.check(plain:find( + "effect(mediump vec4 color, Image tex, mediump vec2 tc, mediump vec2 sc)", + 1, true) ~= nil, + "effect()'s own floats stay pinned to LOVE's prototype precision -- the " + .. "Xclipse compiler reads a definition that drifted from the forward " + .. "declaration as an illegal overload and refuses the whole shader") +T.check(plain:find("sc / love_ScreenSize.xy", 1, true) ~= nil, + "the depth test normalises the pixel coord by the canvas's own pixel " + .. "size -- `screen` counts canvas UNITS, and on a highdpi phone the two " + .. "differ by the density, which clamped the lookup and cut the water " + .. "into blocks") + +-- ------- the lift itself +-- +-- A pond in a field: four water cells recessed below flat ground. The +-- shipped maps are the real thing but a picture states the invariant +-- exactly, and this one needs no atlas, no GPU and no fixture. +local WATER_TILE, GRASS_TILE = 20, 3 +local pond = { + { GRASS_TILE, GRASS_TILE, GRASS_TILE, GRASS_TILE }, + { GRASS_TILE, WATER_TILE, WATER_TILE, GRASS_TILE }, + { GRASS_TILE, WATER_TILE, WATER_TILE, GRASS_TILE }, + { GRASS_TILE, GRASS_TILE, GRASS_TILE, GRASS_TILE }, +} +local pondMap = { + id = "DS_TEST_POND", + tileset = { id = "DS_TEST_SET", image = "gfx/tilesets/ds_test.png", + tilesPerRow = 16, imageWidth = 128, imageHeight = 48, + blocks = {}, grassTile = -1 }, + def = { width = 1, height = 1, tileset = "DS_TEST_SET" }, + walkable = { [GRASS_TILE] = true }, + waterTiles = { [WATER_TILE] = true }, + doorTiles = {}, + tileAt = function(_, tx, ty) + return pond[(ty % 4) + 1][(tx % 4) + 1] + end, + cellTile = function(self, cx, cy) return self:tileAt(cx * 2, cy * 2 + 1) end, + isWaterCell = function(self, cx, cy) + return self:cellTile(cx, cy) == WATER_TILE + end, + isWalkableCell = function(self, cx, cy) + return self:cellTile(cx, cy) == GRASS_TILE + end, + inBounds = function(_, cx, cy) + return cx >= 0 and cy >= 0 and cx < 2 and cy < 2 + end, +} + +-- body-only, so the border ring is out of it and the count is the picture +local _, _, whole = ChunkMesher.geometry(pondMap, true, nil) +Structures.invalidate(pondMap.id) +local landVerts, _, land, waterVerts, _, wet = + ChunkMesher.geometry(pondMap, true, nil, true) + +T.check(wet > 0, "the pond's surface comes out as water quads") +T.eq(land + wet, whole, + "and the split is a MOVE, not a copy: every quad the one-sink build " + .. "emitted is in exactly one of the two") +T.eq(#waterVerts, wet * 4, "the water sink holds whole quads") + +-- every water vertex sits on the recessed plane, which is what says the +-- surface and only the surface was lifted -- the shoreline faces that drop +-- from the ground down to it belong to the GROUND that exposes them, and +-- must stay in the terrain mesh or a lake is ringed by a slit into the sky +local heights = Shapes.heights() +for _, v in ipairs(waterVerts) do + T.check(v[2] == heights.water, + "a water vertex stands on the water plane, not on a shoreline face") +end +local shore = 0 +for _, v in ipairs(landVerts) do + if v[2] < 0 then shore = shore + 1 end +end +T.check(shore > 0, + "and the shoreline bands below ground level stayed with the terrain") + +-- a map with no water at all splits into everything and nothing, rather +-- than into an empty terrain mesh +Structures.invalidate(pondMap.id) +local dry = {} +for y = 1, 4 do + dry[y] = {} + for x = 1, 4 do dry[y][x] = GRASS_TILE end +end +pond = dry +local _, _, dryLand, _, _, dryWet = ChunkMesher.geometry(pondMap, true, nil, + true) +T.check(dryLand > 0, "a map with no water still meshes its ground") +T.eq(dryWet, 0, "and hands back no water surface at all") + +-- ------- and the pairing +-- +-- The terrain mesh and the water lifted out of it are ONE answer: they came +-- from the same build, so a caller must never end up holding a full mesh +-- beside a body build's water (the ring's ponds twice, the body's as holes). +-- pair() is the only way to ask, which is what makes that unpairable. +local mesh, wetMesh = ChunkMesher.pair({ id = "DS_NOT_A_MAP" }, false) +T.eq(mesh, nil, "an unbuilt map pairs to nothing") +T.eq(wetMesh, nil, "on both halves, so a caller cannot half-draw one") + +Structures.invalidate(pondMap.id) +ChunkMesher.invalidate(pondMap.id) +Shapes.invalidate() +end + Voxel.angle = 0 -- ------- overworld battles: where the fight is staged diff --git a/tests/mon_shots.lua b/tests/mon_shots.lua new file mode 100644 index 0000000..73cfefb --- /dev/null +++ b/tests/mon_shots.lua @@ -0,0 +1,71 @@ +-- Driver: one overworld-battle screenshot per species, the mon fighting +-- ITSELF -- its back pic on the player's mark and its front pic on the +-- enemy's, so a single frame shows both sprites the 3D mode draws for it. +-- +-- The point is a visual sweep for pic glitches (holes the paper-fill missed, +-- a silhouette cut wrong, a pin that leaves the mon floating), so every shot +-- is staged identically: same map, same cells, same beat -- the battle menu, +-- both HUD panels up. Whatever differs between two shots is the mon. +-- +-- SHOT_DIR=.scratchpad/mon_shots \ +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/mon_shots.lua love . +-- +-- Files land as NNN_species.png in dex order. +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or ".scratchpad/mon_shots" + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + + -- every real species the merged data carries, walked in dex order + local species = {} + for id, def in pairs(game.data.pokemon) do + if type(id) == "string" and type(def) == "table" + and def.dex and def.dex >= 1 and def.dex <= 151 then + species[#species + 1] = { id = id, dex = def.dex } + end + end + table.sort(species, function(a, b) return a.dex < b.dex end) + U.log(("%d species"):format(#species)) + + game.save.player.name = "RED" + + for _, s in ipairs(species) do + -- level 50 both sides: high enough that nothing about the staging is + -- species-specific, and a wild battle never awards exp off a menu shot + game.save.party = { Pokemon.new(game.data, s.id, 50) } + + U.teleport(game, "ROUTE_1", 5, 8, "down") + -- let the neighbourhood's meshes land so the first battle frame is the + -- real arena rather than the flat fallback + U.wait(60) + + local battle = BattleState.newWild(game, s.id, 50) + battle.onFinish = function() end + game.overworld:pushBattle(battle) + + -- the wipe, then tap through "Wild X appeared!" and the send-out until + -- the battle MENU is actually up -- a fixed tap count lands on whatever + -- beat the intro happened to be on, which is how a shot ends up with the + -- trainer still standing where the mon should be + U.wait(70) + for _ = 1, 200 do + if battle.phase == "menu" then break end + U.tap(game, "a") + U.wait(6) + end + if battle.phase ~= "menu" then + U.log(("STUCK before menu: %s (phase %s)"):format(s.id, tostring(battle.phase))) + end + -- let the send-out slide/ball beat finish so the mon is standing still + U.wait(40) + U.shot(game, ("%s/%03d_%s.png"):format(DIR, s.dex, s.id:lower())) + + while game.stack:top() and game.stack:top() ~= game.overworld do + game.stack:pop() + end + U.wait(10) + end + + U.log("done -- " .. DIR) +end diff --git a/tests/pic_dump.lua b/tests/pic_dump.lua new file mode 100644 index 0000000..c6a9714 --- /dev/null +++ b/tests/pic_dump.lua @@ -0,0 +1,63 @@ +-- Driver: dump the exact pic textures a live 3D battle draws, per stage -- +-- the sprite as loaded (raw) and what picImage hands the billboard after the +-- palette bake and BattlePics' paper fill (final). Diagnostic for pics that +-- render with holes: whichever stage the transparency first appears in is +-- the stage that made it. +-- +-- SHOT_DIR=.scratchpad/pic_dump \ +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/pic_dump.lua love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or ".scratchpad/pic_dump" + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + + local function save(img, path) + if not img then U.log("NIL image for " .. path) return end + local w, h = img:getDimensions() + local g = love.graphics + local prev = g.getCanvas() + local canvas = g.newCanvas(w, h, { dpiscale = 1 }) + g.setCanvas(canvas) + g.clear(0, 0, 0, 0) + g.setBlendMode("replace", "premultiplied") + g.setColor(1, 1, 1, 1) + g.draw(img, 0, 0) + g.setCanvas(prev) + g.setBlendMode("alpha") + local f = assert(io.open(path, "wb")) + f:write(canvas:newImageData():encode("png"):getString()) + f:close() + end + + local SPECIES = os.getenv("PIC_SPECIES") + local list = {} + if SPECIES then + for id in SPECIES:gmatch("[^,%s]+") do list[#list + 1] = id:upper() end + else + list = { "PIKACHU", "SEEL", "BULBASAUR", "MEWTWO" } + end + + for _, id in ipairs(list) do + game.save.party = { Pokemon.new(game.data, id, 50) } + U.teleport(game, "ROUTE_1", 5, 8, "down") + U.wait(30) + local battle = BattleState.newWild(game, id, 50) + battle.onFinish = function() end + game.overworld:pushBattle(battle) + U.wait(80) + local lo = id:lower() + save(battle.enemy.sprite, ("%s/%s_front_raw.png"):format(DIR, lo)) + save(battle:picImage(battle.enemy.sprite), ("%s/%s_front_final.png"):format(DIR, lo)) + save(battle.player.sprite, ("%s/%s_back_raw.png"):format(DIR, lo)) + save(battle:picImage(battle.player.sprite), ("%s/%s_back_final.png"):format(DIR, lo)) + U.log("dumped " .. id) + while game.stack:top() and game.stack:top() ~= game.overworld do + game.stack:pop() + end + U.wait(5) + end + + U.log("done -- " .. DIR) + love.event.quit() +end diff --git a/tests/water_reflect_probe.lua b/tests/water_reflect_probe.lua new file mode 100644 index 0000000..4dcf36a --- /dev/null +++ b/tests/water_reflect_probe.lua @@ -0,0 +1,128 @@ +-- Driver: WHY is the water not reflecting anything? +-- +-- The reflective pass has several links and every one of them fails quietly +-- back to flat water, which looks exactly like the row being off. This walks +-- the chain in the LIVE game and prints where it stops. +-- +-- POKEPORT_DRIVER=mods/DRAMATIC_SHAPE/tests/water_reflect_probe.lua lovec . +-- +-- knobs (env): +-- REFL_MAP map id (default PALLET_TOWN) +-- REFL_SPOT "x,y[,facing]" (default 5,6,down) +-- REFL_LEVEL voxel rung (default 5, the 75-degree camera, +-- which is where a reflection is +-- most of what you can see) +-- REFL_TIME daytime pin (day/dusk/...) (default: leave as-is) +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pipelines = require("src.render.Pipelines") + + local mapId = os.getenv("REFL_MAP") or "PALLET_TOWN" + local level = math.floor(tonumber(os.getenv("REFL_LEVEL")) or 5) + local sx, sy, facing = (os.getenv("REFL_SPOT") or "5,6,down") + :match("^%s*(%d+)%s*,%s*(%d+)%s*,?%s*(%a*)") + facing = (facing ~= "" and facing) or "down" + + local function say(...) print("[water-ssr] " .. string.format(...)) end + + U.teleport(game, mapId, tonumber(sx), tonumber(sy), facing) + U.wait(20) + Pipelines.setLevel("voxel", level) + U.wait(40) -- outlast the camera tween and the build + + local V = game.mods.exports["DRAMATIC_SHAPE"] + V = V and V.lib + if not V then return say("mod exports unreachable -- is it enabled?") end + + local Water = V.require("Water") + local Voxel3D = V.require("Voxel3D") + local ChunkMesher = V.require("ChunkMesher") + local Sky = V.require("Sky") + local DayNight = V.require("DayNight") + + if os.getenv("REFL_TIME") then + DayNight.setting:sync(os.getenv("REFL_TIME")) + U.wait(5) + end + + local ow = game.overworld + local map = ow and ow.map + if not map then return say("no live map") end + + -- 1. is the row on at all? + say("row=%s level=%d", tostring(Water.setting:get()), Water.level()) + if not Water.enabled() then + return say("STOP: WATER is OFF -- press 9, or set the row") + end + + -- 2. is there any water on this map to reflect in? + local terrain, water = ChunkMesher.pair(map, false) + if not terrain then terrain, water = ChunkMesher.pair(map, true) end + say("terrain mesh=%s water mesh=%s", tostring(terrain ~= nil), + tostring(water ~= nil)) + if not terrain then + return say("STOP: no terrain mesh yet -- the build is still cooking") + end + if not water then + say("STOP: this map has no water surface. That is not a fault unless") + say(" you can see a lake: check the tileset's water tiles reach") + say(" TileShape (run voxel_survey.lua for the shape breakdown).") + return + end + + -- 3. did the driver give us a depth texture to read? This is the one + -- 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 (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 + end + + -- 4. did the shader build? Both variants -- the wireframe one needs + -- derivatives, which a driver may refuse on its own. + say("shader plain=%s grid=%s", + tostring(Water.shader(false) ~= nil), tostring(Water.shader(true) ~= nil)) + if not Water.shader(false) then + return say("STOP: the water shader did not compile -- the mod log has " + .. "the driver's own message") + end + + -- 5. is there a sky to reflect, and something hanging in it? + local ramp, count = Sky.ramp() + say("sky ramp=%s bands=%s edge=%s", tostring(ramp ~= nil), tostring(count), + tostring(Voxel3D.skyEdge)) + local body = DayNight.body() + if body then + local amt = DayNight.glow() + local w, h = Voxel3D.size() + local rpx = Sky.discRadius(h, Voxel3D.cell or 1, + { moon = body.moon, glowAmt = amt }) + local ang = rpx / ((h or 1) / math.max(1e-4, Voxel3D.fovY or 1)) + say("body=%s dir=(%.2f, %.2f, %.2f) disc=%.1fpx (%.2f deg)", + body.moon and "moon" or "sun", body.dx, body.dy, body.dz, rpx, + math.deg(ang)) + else + say("body: none in the sky right now (set REFL_TIME=day or =night)") + end + if not ramp then + say("NOTE: no band ramp -- indoors, or the ramp could not be built. The") + say(" sky half of the reflection is off; the ray march still runs.") + end + + -- 6. how much reflection this camera is actually asking for. Both numbers + -- fall out of the rung, and between them they explain every "it only + -- works at 75" report: Fresnel decides how much shows, and the lean + -- decides whether what shows has anything in it. + local f = Water.FRESNEL_FLOOR + (Water.FRESNEL_CEIL - Water.FRESNEL_FLOOR) + * (1 - Voxel3D.descent) ^ Water.FRESNEL_POWER + say("camera: descent %.3f -> fresnel about %.2f, horizon lean %.2f", + Voxel3D.descent, f, Water.lean(Voxel3D.descent)) + say("waves: %d px columns, phase %.2f", Water.WAVE_HEIGHT, Water._waveTime()) + + say("OK: every link is live. The effect is strongest at REFL_LEVEL=5 (the") + say(" 75-degree rung, where Fresnel is highest and the reflection is") + say(" exact) and with a low sun (REFL_TIME=dusk).") +end