mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 11:50:50 +02:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e08f09c27 | |||
| c9d7e26858 | |||
| eb231d221e | |||
| 980383bb92 | |||
| 22b58e27a4 | |||
| 98f7419b72 | |||
| 92fef2a37e | |||
| 8f38aeb36e | |||
| 9a9441899a | |||
| 7f76caa5f6 | |||
| be2f0464c5 | |||
| 8728783b22 | |||
| 731ecd9677 | |||
| 851f36d46f | |||
| 775757b2d6 | |||
| 20f1807edd | |||
| 3eb62a5e00 |
@@ -0,0 +1,200 @@
|
||||
name: Release
|
||||
|
||||
# Packs the mod into an installable .zip and publishes it as a GitHub Release,
|
||||
# once per push to main.
|
||||
#
|
||||
# Archive layout: every mod file at the archive root, manifest.json included.
|
||||
# That is one of the two shapes the game accepts on MODS > Import mod .zip
|
||||
# (src/mods/LauncherMods.lua locateRoot: manifest at the root, or inside a
|
||||
# single top-level folder). Nothing else is added, so the archive stays
|
||||
# installable by hand too.
|
||||
#
|
||||
# Versioning, first rule that applies wins:
|
||||
# 1. the "version" input of a manual run,
|
||||
# 2. "[release X.Y.Z]" anywhere in the commit message,
|
||||
# 3. manifest.json's own version, when it is ahead of every existing tag,
|
||||
# so bumping the manifest is the normal way to cut a release,
|
||||
# 4. otherwise the newest vX.Y.Z tag with its patch incremented
|
||||
# (0.2.99 rolls over to 0.3.0).
|
||||
# Whichever wins is written into the manifest.json inside the archive, so a
|
||||
# shipped mod never reports a different version than the release it came from.
|
||||
#
|
||||
# Generated by: python3 tools/modkit.py add-release-workflow <mod-id>
|
||||
# MOD_ID below is stamped to this mod's id when the file is copied.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths-ignore:
|
||||
- '.github/**'
|
||||
- '**.md'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Exact version to release (e.g. 0.3.0). Leave blank to auto-resolve."
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: release
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Determine version
|
||||
id: ver
|
||||
env:
|
||||
DISPATCH_VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 - <<'PY' >> "$GITHUB_OUTPUT"
|
||||
import json, os, re, subprocess, sys
|
||||
|
||||
SEMVER = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")
|
||||
|
||||
def sh(*args):
|
||||
return subprocess.run(args, capture_output=True, text=True).stdout.strip()
|
||||
|
||||
def parse(text):
|
||||
m = SEMVER.match(text)
|
||||
return tuple(int(p) for p in m.groups()) if m else None
|
||||
|
||||
def die(msg):
|
||||
print(f"::error::{msg}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
with open("manifest.json", encoding="utf-8") as fh:
|
||||
manifest_version = str(json.load(fh).get("version", ""))
|
||||
|
||||
released = sorted(
|
||||
v for v in (parse(tag[1:]) for tag in sh("git", "tag", "-l", "v*").splitlines()) if v
|
||||
)
|
||||
latest = released[-1] if released else None
|
||||
|
||||
override = os.environ.get("DISPATCH_VERSION", "").strip()
|
||||
if not override:
|
||||
found = re.search(r"\[release\s+(\d+\.\d+\.\d+)\]", sh("git", "log", "-1", "--pretty=%B"))
|
||||
override = found.group(1) if found else ""
|
||||
|
||||
manifest_ver = parse(manifest_version)
|
||||
if override:
|
||||
version = parse(override) or die(f"invalid version override {override!r} (expected X.Y.Z)")
|
||||
source = "the override"
|
||||
elif manifest_ver and (latest is None or manifest_ver > latest):
|
||||
version = manifest_ver
|
||||
source = "manifest.json"
|
||||
elif latest:
|
||||
major, minor, patch = latest
|
||||
patch += 1
|
||||
if patch > 99:
|
||||
minor, patch = minor + 1, 0
|
||||
version = (major, minor, patch)
|
||||
source = "a patch bump on v%d.%d.%d" % latest
|
||||
else:
|
||||
die(f"manifest.json version {manifest_version!r} is not X.Y.Z "
|
||||
"and there is no vX.Y.Z tag to count from")
|
||||
|
||||
text = "%d.%d.%d" % version
|
||||
print(f"Releasing {text}, from {source}.", file=sys.stderr)
|
||||
print(f"version={text}")
|
||||
print(f"tag=v{text}")
|
||||
PY
|
||||
|
||||
- name: Refuse to clobber an existing release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ steps.ver.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
|
||||
echo "::error::Tag $TAG already exists. Pick a different version."
|
||||
exit 1
|
||||
fi
|
||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||
echo "::error::Release $TAG already exists. Pick a different version."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build the mod .zip
|
||||
env:
|
||||
VERSION: ${{ steps.ver.outputs.version }}
|
||||
MOD_ID: "DRAMATIC_SHAPE"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
staging="$RUNNER_TEMP/pkg"
|
||||
out="$GITHUB_WORKSPACE/dist"
|
||||
rm -rf "$staging" "$out"
|
||||
mkdir -p "$staging" "$out"
|
||||
|
||||
git archive HEAD | tar -x -C "$staging"
|
||||
|
||||
rm -rf "$staging/.github" "$staging/.gitattributes" \
|
||||
"$staging/.gitignore" "$staging/.luarc.json"
|
||||
|
||||
python3 - "$staging/manifest.json" "$VERSION" <<'PY'
|
||||
import json, sys
|
||||
path, version = sys.argv[1], sys.argv[2]
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
manifest = json.load(fh)
|
||||
manifest["version"] = version
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
json.dump(manifest, fh, indent=2, ensure_ascii=False)
|
||||
fh.write("\n")
|
||||
PY
|
||||
|
||||
zip_path="$out/${MOD_ID}-${VERSION}.zip"
|
||||
(cd "$staging" && zip -qr "$zip_path" .)
|
||||
unzip -l "$zip_path"
|
||||
|
||||
unzip -p "$zip_path" manifest.json > "$RUNNER_TEMP/packed-manifest.json"
|
||||
python3 - "$RUNNER_TEMP/packed-manifest.json" "$VERSION" <<'PY'
|
||||
import json, sys
|
||||
path, expected = sys.argv[1], sys.argv[2]
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
version = json.load(fh)["version"]
|
||||
if version != expected:
|
||||
raise SystemExit(f"::error::packed manifest says {version}, expected {expected}")
|
||||
print(f"manifest.json is at the archive root and reports {version}")
|
||||
PY
|
||||
|
||||
(cd "$out" && sha256sum "${MOD_ID}"-*.zip > sha256sums.txt)
|
||||
cat "$out/sha256sums.txt"
|
||||
|
||||
- name: Publish GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ steps.ver.outputs.version }}
|
||||
TAG: ${{ steps.ver.outputs.tag }}
|
||||
MOD_ID: "DRAMATIC_SHAPE"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
prev="$(git tag -l 'v*' --sort=-v:refname | grep -v "^${TAG}$" | head -1 || true)"
|
||||
range="${prev:+${prev}..}$GITHUB_SHA"
|
||||
changes="$(git log --no-merges --pretty='- %s' "$range" | head -50 || true)"
|
||||
|
||||
notes=$'Download the .zip and install it from the game: MODS > Import mod .zip.'
|
||||
if [ -n "$changes" ]; then
|
||||
notes+=$'\n\n## Changes\n\n'"$changes"
|
||||
fi
|
||||
printf 'Release notes:\n%s\n' "$notes"
|
||||
|
||||
gh release create "$TAG" \
|
||||
--target "$GITHUB_SHA" \
|
||||
--title "$VERSION" \
|
||||
--notes "$notes" \
|
||||
"dist/${MOD_ID}-${VERSION}.zip" \
|
||||
"dist/sha256sums.txt"
|
||||
|
||||
echo "Published release $TAG"
|
||||
+307
@@ -1,5 +1,312 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Changed
|
||||
|
||||
- **Ledges are cliffs now.** A ledge used to be taken at face value: a 6px
|
||||
speed bump extruded out of a flat world. But the drawing is the game
|
||||
telling you about terrain -- the side you hop FROM is higher ground --
|
||||
so the world now has real elevation: everything above a hop-down edge
|
||||
stands one ledge-height (6px) up, the lip sits flush with the plateau it
|
||||
rims, and its south face is the cliff drop wearing the same cropped lip
|
||||
art it always wore. Stack ledges and the tiers stack; Pallet Town is the
|
||||
sea-level datum at 0, and the terrain tops out 22px up.
|
||||
|
||||
The subtlety is that ledges do not enclose anything -- every one can be
|
||||
walked around through a gap, so a plateau flood fill would leak through
|
||||
it, meet itself across its own ledge line and conclude an area sits a
|
||||
tier above itself. So lib/Elevation.lua closes the lines before it
|
||||
fills anything. Ledge cells group into runs, and each run is extended
|
||||
along its own axis from either end -- across the gap the player detours
|
||||
through -- until it reaches something that closes it: unwalkable
|
||||
ground, another ledge, or the edge of the world. A run that finds
|
||||
nothing within reach is not a cliff anybody walks around and stays
|
||||
open. The cells that extension crosses become STEPS, and they are the
|
||||
one piece of sloped ground in the world: their tiles grade into 4px and
|
||||
2px treads, so walking a sealed gap climbs three crisp 2px risers where
|
||||
the cliff line crosses it.
|
||||
|
||||
With every line closed, an ordinary flood over the walkable ground cuts
|
||||
the world into areas -- and now they really are the areas enclosed by
|
||||
ledges. Those areas are then levelled against each other in whole
|
||||
tiers, by least squares over the area graph rather than by propagation:
|
||||
each lip votes "the area behind me is one tier over the area in front",
|
||||
areas meeting across ordinary trees or water cast a small
|
||||
same-ground vote, and a stray lip is outvoted by the run it disagrees
|
||||
with instead of tipping half a route. Because each area is a single
|
||||
variable the result is exactly FLAT -- these are plateaus, not a
|
||||
smoothed field. The solve is global and anchored on Pallet's own
|
||||
ground, so two connected maps never disagree about a seam; it runs once
|
||||
inside the build budget, in well under a second for all 43k cells of
|
||||
Kanto, and cuts them into 199 areas rising six tiers from Pallet at 0 to
|
||||
the Route 22/23 highlands at 42px.
|
||||
|
||||
Roughly three quarters of the ledges with standable ground on both
|
||||
sides come out as an exact one-tier drop. The rest -- and the ones
|
||||
whose high side is impassable mountain rock, where there is no plateau
|
||||
to stand on at all -- take the level of the ground around them, which
|
||||
reads as terrain rather than as a mistake, but they are the cases still
|
||||
worth an eye in-game.
|
||||
|
||||
Everything that stands on the ground rides it: characters and NPCs
|
||||
(including ghosts on neighbour maps), grass tufts, flowers, props,
|
||||
buildings (each on one flattened pad -- no terraced floorboards), tree
|
||||
hulls, battle arenas and their camera rig, cast shadows (the sun
|
||||
frustum grows by the tallest base), and the free-roam camera's focus,
|
||||
which eases after the player's ground height instead of pinning to the
|
||||
old flat plane. Cliff skirts fall out of the mesher's own
|
||||
neighbour-difference rule, banded in cell-local height so every crop
|
||||
the flat world drew is byte-identical there. Interiors and any map not
|
||||
connected to Pallet keep the classic flat reading, ledge bumps and all.
|
||||
|
||||
### 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
|
||||
|
||||
- **A staged battle on a phone stood some Pokémon three times the size of the
|
||||
square they were on.** A Pidgey towered over the arena while the mon beside
|
||||
it was the right size, which reads as a bug in one species and is not one.
|
||||
|
||||
Putting the paper back inside a battle pic (BattlePics, 1.3.0) needs the
|
||||
pic's pixels, and a LOVE Image does not hand them back -- so the pic is drawn
|
||||
into a canvas of its own size and the canvas is read. `newCanvas` takes the
|
||||
SURFACE's dpi scale when it is not told otherwise, `conf.lua` turns highdpi
|
||||
on for Android and iOS, and Android's display density is routinely 2.75. So
|
||||
`newCanvas(56, 56)` allocated a 154x154 texture there, the pic was magnified
|
||||
into it, and the readback came back at the magnified size. The rebuilt pic
|
||||
was 2.75x the artwork, the engine's pics layer drew it 1:1 because it trusts
|
||||
`getWidth()`, and the mon stood on its tile nearly three times too big.
|
||||
|
||||
Only a pic with an enclosed hole in it is rebuilt at all -- the rest are
|
||||
handed straight back untouched -- which is why it hit some species and not
|
||||
others, and why it never showed on desktop, where the dpi scale is already 1.
|
||||
The readback now asks for one texel per pic pixel, the way the engine's own
|
||||
`PixelCanvas` does for the same reason. The animated-tile atlas readback took
|
||||
the same fix: on a phone it would have come back magnified too, and every
|
||||
tile coordinate in it counts in eights from the top-left.
|
||||
|
||||
## 1.3.0
|
||||
|
||||
### Added
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -98,6 +98,10 @@ return {
|
||||
ground = 0,
|
||||
water = -2,
|
||||
void = 0,
|
||||
-- doubles as the terrain TIER: on the connected overworld the ground
|
||||
-- above a hop-down edge stands this many pixels up (lib/Elevation.lua)
|
||||
-- and the lip's box sinks by the same amount to sit flush as its rim,
|
||||
-- so retuning it retunes the cliffs with the faces that clothe them
|
||||
ledge = 6,
|
||||
fence = 10,
|
||||
sign = 12,
|
||||
|
||||
+8
-2
@@ -242,13 +242,19 @@ end
|
||||
-- Whether both mons would be in plain view from the battle camera.
|
||||
function BattleArena.clearance(map, arena)
|
||||
local BattleCam = V.require("BattleCam")
|
||||
local ok, rig = pcall(BattleCam.rig, arena, 0)
|
||||
-- rig and rays at the arena's OWN floor height: on solved terrain a
|
||||
-- fight on a plateau stands (and is judged) that many pixels up, or
|
||||
-- the raised ground itself would read as an obstacle over every mon
|
||||
local gy = heightAt(map, arena.player[1], arena.player[2])
|
||||
local ok, rig = pcall(BattleCam.rig, arena, gy)
|
||||
if not (ok and rig and rig.eye) then return true end
|
||||
local eye = rig.eye
|
||||
local H = BattleArena.MON_H
|
||||
for _, mark in ipairs({ arena.player, arena.enemy }) do
|
||||
for _, hy in ipairs({ 1, H * 0.5, H }) do
|
||||
if not lineClear(map, eye, mark[1], hy, mark[2]) then return false end
|
||||
if not lineClear(map, eye, mark[1], gy + hy, mark[2]) then
|
||||
return false
|
||||
end
|
||||
end
|
||||
end
|
||||
return true
|
||||
|
||||
+16
-1
@@ -88,6 +88,21 @@ local CUT = 0.5
|
||||
-- its data back, so it is drawn into a canvas of its own size and the canvas
|
||||
-- is read -- which is also what makes this work for every path that produces
|
||||
-- a pic, without knowing which one produced this one.
|
||||
--
|
||||
-- The canvas is forced to dpiscale = 1, and that is the whole difference
|
||||
-- between a pic and a MONSTER. love.graphics.newCanvas defaults its dpiscale
|
||||
-- to the surface's, conf.lua turns highdpi on for Android and iOS, and
|
||||
-- Android's density is routinely 2.75 -- so newCanvas(56, 56) hands back a
|
||||
-- 154x154 texture there, the pic is drawn into it magnified to fill it, and
|
||||
-- newImageData reads the magnified copy back at its own PIXEL size. The image
|
||||
-- built from that is 2.75x the artwork, drawPicsLayer draws it at 1:1 because
|
||||
-- it trusts getWidth(), and the mon stands on the map nearly three times the
|
||||
-- size of the square it is supposed to cover. Desktop never saw it: dpiscale
|
||||
-- is already 1 there. Nor did every species, because only a pic with an
|
||||
-- enclosed hole in it comes back through here at all (see `changed` below) --
|
||||
-- so a Pidgey came out giant and the mon beside it did not, which is what
|
||||
-- makes this read as a sprite bug rather than a scale one. See the engine's
|
||||
-- own src/render/PixelCanvas.lua, which exists for exactly this reason.
|
||||
local function readBack(img)
|
||||
local w, h = img:getDimensions()
|
||||
if w <= 0 or h <= 0 then return nil end
|
||||
@@ -96,7 +111,7 @@ local function readBack(img)
|
||||
local prevR, prevG, prevB, prevA = love.graphics.getColor()
|
||||
local data = nil
|
||||
local ok = pcall(function()
|
||||
local canvas = love.graphics.newCanvas(w, h)
|
||||
local canvas = love.graphics.newCanvas(w, h, { dpiscale = 1 })
|
||||
love.graphics.setCanvas(canvas)
|
||||
love.graphics.clear(0, 0, 0, 0)
|
||||
love.graphics.setBlendMode("replace", "premultiplied")
|
||||
|
||||
+64
-6
@@ -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
|
||||
|
||||
+11
-4
@@ -701,12 +701,19 @@ function Buildings.stamp(S, map, quads, tx, ty, bw, bh)
|
||||
vote(tx + bw, ty + r)
|
||||
end
|
||||
|
||||
-- One building, ONE base: the model is rigid, so it stands at the
|
||||
-- elevation under its door row and the pad beneath is flattened to
|
||||
-- match -- a house near a ramp must not have terraced floorboards.
|
||||
local my = S.base
|
||||
and S.base[keyOf(tx + math.floor(bw / 2), ty + bh - 1)] or 0
|
||||
|
||||
for r = 0, bh - 1 do
|
||||
for c = 0, bw - 1 do
|
||||
local k = keyOf(tx + c, ty + r)
|
||||
S.shapeAt[k] = shape
|
||||
S.skip[k] = true
|
||||
S.ground[k] = best or false
|
||||
if S.base then S.base[k] = my ~= 0 and my or nil end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -714,10 +721,10 @@ function Buildings.stamp(S, map, quads, tx, ty, bw, bh)
|
||||
local out = S.objectQuads
|
||||
for _, q in ipairs(quads) do
|
||||
out[#out + 1] = {
|
||||
{ q[1][1] + mx, q[1][2], q[1][3] + mz },
|
||||
{ q[2][1] + mx, q[2][2], q[2][3] + mz },
|
||||
{ q[3][1] + mx, q[3][2], q[3][3] + mz },
|
||||
{ q[4][1] + mx, q[4][2], q[4][3] + mz },
|
||||
{ q[1][1] + mx, q[1][2] + my, q[1][3] + mz },
|
||||
{ q[2][1] + mx, q[2][2] + my, q[2][3] + mz },
|
||||
{ q[3][1] + mx, q[3][2] + my, q[3][3] + mz },
|
||||
{ q[4][1] + mx, q[4][2] + my, q[4][3] + mz },
|
||||
uv = q.uv, shade = q.shade,
|
||||
-- placements only ever scan the BODY, so a building is always this
|
||||
-- map's own structure: the mesher's edge keep-rules must not eat
|
||||
|
||||
+149
-49
@@ -221,21 +221,40 @@ 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
|
||||
local atlasW = tileset.imageWidth or (perRow * 8)
|
||||
local atlasH = tileset.imageHeight or 48
|
||||
|
||||
-- The terrain's base elevation under a tile (0 on a map without a
|
||||
-- solved field -- every interior), and the ABSOLUTE height of what
|
||||
-- stands there: base + the shape's own extrusion. Side faces are
|
||||
-- derived from neighbour height differences, so once every height is
|
||||
-- measured from the same datum the cliff skirt under a raised cell
|
||||
-- falls out of the same band loop that has always clothed walls.
|
||||
local baseAt = S.base and function(k) return S.base[k] or 0 end
|
||||
or function() return 0 end
|
||||
|
||||
local function heightAt(tx, ty)
|
||||
local k = keyOf(tx, ty)
|
||||
if S.skip[k] then return 0 end
|
||||
if S.skip[k] then return baseAt(k) end
|
||||
local run = S.runs[k]
|
||||
if run then return run.h end
|
||||
if run then return baseAt(k) + run.h end
|
||||
local s = S.shapeAt[k]
|
||||
return s and s.h or 0
|
||||
return baseAt(k) + (s and s.h or 0)
|
||||
end
|
||||
|
||||
-- one atlas-rect UV, optionally cropped to art rows [vTop, vBot] of 8
|
||||
@@ -334,13 +353,17 @@ local function runGeometry(map, bodyOnly, masks, sink)
|
||||
-- blocks half the sky, so the closer a voxel sits to it the less ambient
|
||||
-- light reaches it -- which is what plants a prop on the floor instead
|
||||
-- of leaving it looking pasted over the top.
|
||||
-- `floor` is the terrain base the prop stands on: contact darkening
|
||||
-- measures height above the prop's OWN ground, not above the world
|
||||
-- datum, or every plant on a plateau would lose its feet.
|
||||
local aoProp = { 0, 0, 0, 0 }
|
||||
local function groundShades(c, shade)
|
||||
local function groundShades(c, shade, floor)
|
||||
if type(shade) == "table" then return shade end
|
||||
floor = floor or 0
|
||||
local y1, y2, y3, y4 = c[1][2], c[2][2], c[3][2], c[4][2]
|
||||
if math.min(y1, y2, y3, y4) >= AO_RISE then return shade end
|
||||
if math.min(y1, y2, y3, y4) - floor >= AO_RISE then return shade end
|
||||
for i = 1, 4 do
|
||||
local t = c[i][2] / AO_RISE
|
||||
local t = (c[i][2] - floor) / AO_RISE
|
||||
aoProp[i] = shade * (t >= 1 and 1 or (1 - AO_GROUND * (1 - t)))
|
||||
end
|
||||
return aoProp
|
||||
@@ -358,12 +381,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),
|
||||
@@ -444,28 +469,32 @@ local function runGeometry(map, bodyOnly, masks, sink)
|
||||
-- prebuilt prism quads (appended below) carry the art
|
||||
local g = S.ground[k]
|
||||
if g then
|
||||
topQuad(tx * 8, ty * 8, 0, g, 1)
|
||||
-- the claimed tile is still ground at height 0, and water next
|
||||
local b = baseAt(k)
|
||||
topQuad(tx * 8, ty * 8, b, g, 1)
|
||||
-- the claimed tile is still ground at its base, and water next
|
||||
-- door still recesses below it: without the same below-ground
|
||||
-- side bands ordinary ground emits, the two-pixel shoreline
|
||||
-- face is a slit into the sky behind the mesh -- which is
|
||||
-- exactly what a building plot or a sign standing at the
|
||||
-- waterline showed. Same bands, cut from the synthesized
|
||||
-- ground's own art
|
||||
-- ground's own art. Bands run in CELL-LOCAL height (world
|
||||
-- minus base), so the crop is the one the flat world always
|
||||
-- drew, translated up with the terrain.
|
||||
for _, side in ipairs(SIDES) do
|
||||
local nh = heightAt(tx + side[1], ty + side[2])
|
||||
if nh < 0 then
|
||||
if nh < b then
|
||||
local d = side[3]
|
||||
local lat = LATERAL[d]
|
||||
local hl = lat and heightAt(tx + lat[1], ty + lat[2]) or 0
|
||||
local hr = lat and heightAt(tx + lat[3], ty + lat[4]) or 0
|
||||
for band = math.floor(nh / 8), -1 do
|
||||
local y0 = math.max(nh, band * 8)
|
||||
local y1 = math.min(0, band * 8 + 8)
|
||||
if y1 > y0 then
|
||||
sideQuad(d, tx * 8, ty * 8, y0, y1, g,
|
||||
(band * 8 + 8) - y1, (band * 8 + 8) - y0,
|
||||
sideShades(hl, hr, y0, y1, y0 <= nh,
|
||||
local nl = nh - b
|
||||
for band = math.floor(nl / 8), -1 do
|
||||
local ly0 = math.max(nl, band * 8)
|
||||
local ly1 = math.min(0, band * 8 + 8)
|
||||
if ly1 > ly0 then
|
||||
sideQuad(d, tx * 8, ty * 8, b + ly0, b + ly1, g,
|
||||
(band * 8 + 8) - ly1, (band * 8 + 8) - ly0,
|
||||
sideShades(hl, hr, b + ly0, b + ly1, ly0 <= nl,
|
||||
Voxel3D.FACE_SHADE[d]))
|
||||
end
|
||||
end
|
||||
@@ -474,7 +503,11 @@ local function runGeometry(map, bodyOnly, masks, sink)
|
||||
end
|
||||
elseif s then
|
||||
local run = S.runs[k]
|
||||
local h = run and run.h or s.h
|
||||
local b = baseAt(k)
|
||||
-- h is ABSOLUTE (base + extrusion), matching heightAt; hLocal is
|
||||
-- the extrusion alone, which is the space the art bands live in
|
||||
local hLocal = run and run.h or s.h
|
||||
local h = b + hLocal
|
||||
local x0, z0 = tx * 8, ty * 8
|
||||
|
||||
-- top face. A roofed volume gets a GABLE segment: the roof rises
|
||||
@@ -490,9 +523,10 @@ local function runGeometry(map, bodyOnly, masks, sink)
|
||||
-- everything else its own art.
|
||||
if run and run.rise > 0 then
|
||||
local mid = run.extent / 2
|
||||
local runTop = b + run.h -- the facade top, absolute
|
||||
local function gableH(d) -- d = rows north of the south eave
|
||||
local t = d <= mid and d / mid or (run.extent - d) / (run.extent - mid)
|
||||
return run.h + run.rise * math.max(0, math.min(1, t))
|
||||
return runTop + run.rise * math.max(0, math.min(1, t))
|
||||
end
|
||||
local d0 = run.front - ty -- rows from the south edge
|
||||
local hS = gableH(d0)
|
||||
@@ -503,13 +537,13 @@ local function runGeometry(map, bodyOnly, masks, sink)
|
||||
math.floor((1 - rel) * run.roofRows))
|
||||
local roofTile = map:tileAt(tx, run.north + idx)
|
||||
local swY, seY, neY, nwY = hS, hS, hN, hN
|
||||
if heightAt(tx - 1, ty) < run.h then -- west flank: hip
|
||||
swY = math.max(run.h, hS - 8)
|
||||
nwY = math.max(run.h, hN - 8)
|
||||
if heightAt(tx - 1, ty) < runTop then -- west flank: hip
|
||||
swY = math.max(runTop, hS - 8)
|
||||
nwY = math.max(runTop, hN - 8)
|
||||
end
|
||||
if heightAt(tx + 1, ty) < run.h then -- east flank: hip
|
||||
seY = math.max(run.h, hS - 8)
|
||||
neY = math.max(run.h, hN - 8)
|
||||
if heightAt(tx + 1, ty) < runTop then -- east flank: hip
|
||||
seY = math.max(runTop, hS - 8)
|
||||
neY = math.max(runTop, hN - 8)
|
||||
end
|
||||
local u0, u1, v0, v1 = uvRect(roofTile, 0, 8)
|
||||
push({ { x0, swY, z0 + 8 }, { x0 + 8, seY, z0 + 8 },
|
||||
@@ -546,7 +580,7 @@ local function runGeometry(map, bodyOnly, masks, sink)
|
||||
break
|
||||
end
|
||||
end
|
||||
local row = math.min(ty, front - math.floor(h / 8))
|
||||
local row = math.min(ty, front - math.floor(hLocal / 8))
|
||||
if row < north then
|
||||
-- the whole run folded onto the face: top with the drawn
|
||||
-- row just above it when that row is furniture too (a
|
||||
@@ -558,13 +592,27 @@ 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
|
||||
-- heights [8k, 8k+8) and shows one full tile of art; a partial
|
||||
-- band crops the art rows to match, so nothing ever stretches.
|
||||
-- Bands count in CELL-LOCAL height (world minus this cell's base):
|
||||
-- the fold starts at the cell's own feet wherever the terrain
|
||||
-- raised them, and the crop a 6px lip face has always worn stays
|
||||
-- byte-identical on a flat map. Negative bands are the SKIRT a
|
||||
-- raised cell shows a lower neighbour -- terrain that had no face
|
||||
-- at all before elevation -- and they wear the cell's own art
|
||||
-- from its top row down, the same convention the recessed-water
|
||||
-- shoreline bands established below zero.
|
||||
for _, side in ipairs(SIDES) do
|
||||
local nh = heightAt(tx + side[1], ty + side[2])
|
||||
if nh < h then
|
||||
@@ -575,9 +623,11 @@ local function runGeometry(map, bodyOnly, masks, sink)
|
||||
local lat = LATERAL[d]
|
||||
local hl = lat and heightAt(tx + lat[1], ty + lat[2]) or 0
|
||||
local hr = lat and heightAt(tx + lat[3], ty + lat[4]) or 0
|
||||
for band = math.floor(nh / 8), math.ceil(h / 8) - 1 do
|
||||
local y0 = math.max(nh, band * 8)
|
||||
local y1 = math.min(h, band * 8 + 8)
|
||||
local nl = nh - b
|
||||
for band = math.floor(nl / 8), math.ceil(hLocal / 8) - 1 do
|
||||
local ly0 = math.max(nl, band * 8)
|
||||
local ly1 = math.min(hLocal, band * 8 + 8)
|
||||
local y0, y1 = b + ly0, b + ly1
|
||||
if y1 > y0 then
|
||||
local src, shade = tile, Voxel3D.FACE_SHADE[d]
|
||||
if run then
|
||||
@@ -621,7 +671,7 @@ local function runGeometry(map, bodyOnly, masks, sink)
|
||||
end
|
||||
end
|
||||
sideQuad(d, x0, z0, y0, y1, src,
|
||||
(band * 8 + 8) - y1, (band * 8 + 8) - y0,
|
||||
(band * 8 + 8) - ly1, (band * 8 + 8) - ly0,
|
||||
sideShades(hl, hr, y0, y1, y0 <= nh, shade))
|
||||
end
|
||||
end
|
||||
@@ -698,7 +748,11 @@ local function runGeometry(map, bodyOnly, masks, sink)
|
||||
-- the neighbour will ever draw that geometry
|
||||
if q.own or outwardOnEdge(q, x0, z0, x1, z1)
|
||||
or keepQuad(x0, z0, x1, z1) then
|
||||
push({ q[1], q[2], q[3], q[4] }, quadUV(q), groundShades(q, q.shade))
|
||||
local fl = S.base
|
||||
and baseAt(keyOf(math.floor(q[1][1] / 8),
|
||||
math.floor(q[1][3] / 8))) or 0
|
||||
push({ q[1], q[2], q[3], q[4] }, quadUV(q),
|
||||
groundShades(q, q.shade, fl))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -742,7 +796,7 @@ local function runGeometry(map, bodyOnly, masks, sink)
|
||||
for i = 1, 4 do
|
||||
local c, s2 = q[i], sc[i]
|
||||
s2[1] = c[1] + mx
|
||||
s2[2] = c[2]
|
||||
s2[2] = c[2] + (st.my or 0)
|
||||
s2[3] = c[3] + mz
|
||||
end
|
||||
local ok = keepAll
|
||||
@@ -754,7 +808,7 @@ local function runGeometry(map, bodyOnly, masks, sink)
|
||||
ok = keepQuad(x0, z0, x1, z1)
|
||||
end
|
||||
if ok then
|
||||
push(sc, quadUV(q), groundShades(sc, q.shade))
|
||||
push(sc, quadUV(q), groundShades(sc, q.shade, st.my))
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -764,18 +818,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 +928,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 +1003,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 +1115,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 +1143,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
|
||||
|
||||
+36
-4
@@ -29,6 +29,7 @@ local V = ...
|
||||
|
||||
local Mat4 = V.require("Mat4")
|
||||
local Voxel = V.require("VoxelState")
|
||||
local Elevation = V.require("Elevation")
|
||||
|
||||
local ShadowMap = {}
|
||||
|
||||
@@ -130,17 +131,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
|
||||
@@ -289,7 +296,10 @@ local function fit(cx, cy, vw, vh)
|
||||
local f = sunDir()
|
||||
local view = Mat4.lookAt({ 0, 0, 0 }, f, { 0, 0, -1 })
|
||||
|
||||
local reach = ShadowMap.HEIGHT
|
||||
-- the tallest thing that can cast: the fixed geometry ceiling plus the
|
||||
-- terrain base it may be standing on (0 wherever no elevation solved)
|
||||
local top = ShadowMap.HEIGHT + Elevation.maxBase()
|
||||
local reach = top
|
||||
* math.max(math.abs(ShadowMap.KX), math.abs(ShadowMap.KZ)) + 24
|
||||
local north = groundReach(vh)
|
||||
-- the view widens with distance, so the far ground spans more than the
|
||||
@@ -297,7 +307,7 @@ local function fit(cx, cy, vw, vh)
|
||||
-- frustum's true spread and costs a good deal less resolution
|
||||
local spread = north * 0.5
|
||||
local xs = { cx - vw / 2 - spread, cx + vw / 2 + spread + reach }
|
||||
local ys = { -32, ShadowMap.HEIGHT } -- -32 covers recessed water
|
||||
local ys = { -32, top } -- -32 covers recessed water
|
||||
local zs = { cy - north, cy + vh / 2 + reach }
|
||||
|
||||
local l, r, b, t, zn, zf
|
||||
@@ -440,6 +450,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 +461,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()
|
||||
|
||||
+63
-11
@@ -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)
|
||||
|
||||
+76
-23
@@ -49,6 +49,7 @@ local Map = require("src.world.Map")
|
||||
local Buildings = V.require("Buildings")
|
||||
local TileShape = V.require("TileShape")
|
||||
local Budget = V.require("BuildBudget")
|
||||
local Elevation = V.require("Elevation")
|
||||
|
||||
local Structures = {}
|
||||
|
||||
@@ -208,6 +209,38 @@ function Structures.forMap(map)
|
||||
end
|
||||
end
|
||||
|
||||
-- ---- the terrain's base elevation under every tile ----
|
||||
--
|
||||
-- Elevation solves the ledge-bounded terrain once, globally, at CELL
|
||||
-- granularity (see lib/Elevation.lua); here it lands per TILE so the
|
||||
-- mesher and every quad emitter below read one table. Two wrinkles:
|
||||
--
|
||||
-- * a ledge tile's box KEEPS its authored height but sinks by it --
|
||||
-- base + h then puts the lip's top flush with the high plateau it
|
||||
-- is the rim of, and its exposed south face is exactly the tier
|
||||
-- drop wearing the same cropped lip art it always wore;
|
||||
-- * ring tiles read through baseAtTile's edge clamp, so the border
|
||||
-- apron continues the body's elevation instead of cliffing to 0.
|
||||
--
|
||||
-- Maps without a field (every interior) get no table at all, and every
|
||||
-- consumer's `S.base and ...` guard keeps the classic flat path.
|
||||
local base = nil
|
||||
if Elevation.fieldFor(map.id) then
|
||||
base = {}
|
||||
for ty = y0, y1 do
|
||||
for tx = x0, x1 do
|
||||
Budget.tick()
|
||||
local k = keyOf(tx, ty)
|
||||
if tileAt[k] then
|
||||
local b = Elevation.baseAtTile(map, tx, ty)
|
||||
local s = shapeAt[k]
|
||||
if s and s.class == "ledge" then b = b - (s.h or 0) end
|
||||
if b ~= 0 then base[k] = b end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ---- buildings: whole sprites voxelized band by band ----
|
||||
--
|
||||
-- Before anything else looks at this grid. A profiled building is a
|
||||
@@ -222,7 +255,7 @@ function Structures.forMap(map)
|
||||
-- still overdraws a walker's feet even though characters stamp over
|
||||
-- terrain.)
|
||||
S = { shapeAt = shapeAt, tileAt = tileAt, outdoor = Map.isOutdoor(def),
|
||||
hideBareRing = hullRingOnly or nil,
|
||||
hideBareRing = hullRingOnly or nil, base = base,
|
||||
runs = {}, skip = {}, ground = {}, doorFold = {}, objectQuads = {},
|
||||
grassQuads = {}, flowerQuads = {}, roundStamps = {}, figures = {} }
|
||||
Buildings.build(S, map, pixels(tileset), perRow)
|
||||
@@ -1002,7 +1035,7 @@ function Structures.buildCylinders(S, map, x0, x1, y0, y1, groundTiles)
|
||||
ground = tpl.bg or false
|
||||
S.roundStamps[#S.roundStamps + 1] =
|
||||
{ quads = tpl.quads, mx = cx * 16 + 16, mz = cy * 16 + 16,
|
||||
r = 16 }
|
||||
r = 16, my = S.base and S.base[keyOf(cx * 2, cy * 2)] or 0 }
|
||||
end
|
||||
for dy = 0, 3 do
|
||||
for dx = 0, 3 do
|
||||
@@ -1035,7 +1068,8 @@ function Structures.buildCylinders(S, map, x0, x1, y0, y1, groundTiles)
|
||||
end
|
||||
ground = tpl.bg or false
|
||||
S.roundStamps[#S.roundStamps + 1] =
|
||||
{ quads = tpl.quads, mx = cx * 16 + 8, mz = cy * 16 + 8 }
|
||||
{ quads = tpl.quads, mx = cx * 16 + 8, mz = cy * 16 + 8,
|
||||
my = S.base and S.base[keyOf(cx * 2, cy * 2)] or 0 }
|
||||
end
|
||||
-- headless (no pixels): no hull, but still claim the tiles so
|
||||
-- the volume path never boxes a pinned cell. Ground is the
|
||||
@@ -1135,6 +1169,8 @@ function Structures.buildRelief(S, map, region, data, perRow, h)
|
||||
|
||||
local quads = S.objectQuads
|
||||
local wx0, wz0 = region.minX * 8, region.minY * 8
|
||||
-- a relief lies ON the terrain, so the whole slab rides the region's base
|
||||
local by = S.base and S.base[keyOf(region.minX, region.minY)] or 0
|
||||
for py = 0, bh - 1 do
|
||||
for px = 0, bw - 1 do
|
||||
if on(px, py) then
|
||||
@@ -1142,26 +1178,27 @@ function Structures.buildRelief(S, map, region, data, perRow, h)
|
||||
local u = (srcU[i] + 0.5) / atlasW
|
||||
local v = (srcV[i] + 0.5) / atlasH
|
||||
local x, z = wx0 + px, wz0 + py
|
||||
local y0, y1 = by, by + h
|
||||
local function quad(c1, c2, c3, c4, shade)
|
||||
quads[#quads + 1] = { c1, c2, c3, c4, u = u, v = v, shade = shade }
|
||||
end
|
||||
quad({ x, h, z }, { x + 1, h, z }, { x + 1, h, z + 1 },
|
||||
{ x, h, z + 1 }, RELIEF_SHADE.top)
|
||||
quad({ x, y1, z }, { x + 1, y1, z }, { x + 1, y1, z + 1 },
|
||||
{ x, y1, z + 1 }, RELIEF_SHADE.top)
|
||||
if not on(px, py + 1) then
|
||||
quad({ x, 0, z + 1 }, { x + 1, 0, z + 1 }, { x + 1, h, z + 1 },
|
||||
{ x, h, z + 1 }, RELIEF_SHADE.south)
|
||||
quad({ x, y0, z + 1 }, { x + 1, y0, z + 1 }, { x + 1, y1, z + 1 },
|
||||
{ x, y1, z + 1 }, RELIEF_SHADE.south)
|
||||
end
|
||||
if not on(px, py - 1) then
|
||||
quad({ x + 1, 0, z }, { x, 0, z }, { x, h, z },
|
||||
{ x + 1, h, z }, RELIEF_SHADE.north)
|
||||
quad({ x + 1, y0, z }, { x, y0, z }, { x, y1, z },
|
||||
{ x + 1, y1, z }, RELIEF_SHADE.north)
|
||||
end
|
||||
if not on(px - 1, py) then
|
||||
quad({ x, 0, z }, { x, 0, z + 1 }, { x, h, z + 1 },
|
||||
{ x, h, z }, RELIEF_SHADE.side)
|
||||
quad({ x, y0, z }, { x, y0, z + 1 }, { x, y1, z + 1 },
|
||||
{ x, y1, z }, RELIEF_SHADE.side)
|
||||
end
|
||||
if not on(px + 1, py) then
|
||||
quad({ x + 1, 0, z + 1 }, { x + 1, 0, z }, { x + 1, h, z },
|
||||
{ x + 1, h, z + 1 }, RELIEF_SHADE.side)
|
||||
quad({ x + 1, y0, z + 1 }, { x + 1, y0, z }, { x + 1, y1, z },
|
||||
{ x + 1, y1, z + 1 }, RELIEF_SHADE.side)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1208,10 +1245,11 @@ local function bookcaseRank(S, map, tx, northTy, frontTy, capTile)
|
||||
return ns ~= nil and ns.art == "bookcase"
|
||||
end
|
||||
|
||||
local by = S.base and S.base[keyOf(tx, frontTy)] or 0
|
||||
for band = 0, bands - 1 do
|
||||
local tile = band < size and map:tileAt(tx, frontTy - band) or capTile
|
||||
local u0, u1, v0, v1 = uvRect(tile)
|
||||
local y0, y1 = band * 8, band * 8 + 8
|
||||
local y0, y1 = by + band * 8, by + band * 8 + 8
|
||||
quads[#quads + 1] = { { x0, y0, z1 }, { x1, y0, z1 },
|
||||
{ x1, y1, z1 }, { x0, y1, z1 },
|
||||
uv = { { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } },
|
||||
@@ -1330,6 +1368,7 @@ local function stairCell(S, map, data, cx, cy, s)
|
||||
local atlasW = map.tileset.imageWidth or 128
|
||||
local atlasH = map.tileset.imageHeight or 48
|
||||
local quads = S.objectQuads
|
||||
local q0 = #quads
|
||||
local down = s.class == "stair_down_e" or s.class == "stair_down_w"
|
||||
local east = s.class == "stair_e" or s.class == "stair_down_e"
|
||||
local mx, mz = cx * 16, cy * 16
|
||||
@@ -1466,6 +1505,16 @@ local function stairCell(S, map, data, cx, cy, s)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- the whole flight was built from y=0; a raised base lifts it after
|
||||
-- the fact so the geometry above stays in the cell's own space
|
||||
local by = S.base and S.base[keyOf(cx * 2, cy * 2)] or 0
|
||||
if by ~= 0 then
|
||||
for i = q0 + 1, #quads do
|
||||
local q = quads[i]
|
||||
for c = 1, 4 do q[c][2] = q[c][2] + by end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function Structures.buildStairs(S, map, x0, x1, y0, y1)
|
||||
@@ -2028,6 +2077,8 @@ function Structures.buildObject(S, map, region, cluster,
|
||||
baseY, support = bs.h, bs
|
||||
end
|
||||
end
|
||||
-- and whatever it stands on, it stands on it at the terrain's base
|
||||
baseY = baseY + (S.base and S.base[keyOf(cluster.minX, cluster.maxY)] or 0)
|
||||
local atlasW = map.tileset.imageWidth or 128
|
||||
local atlasH = map.tileset.imageHeight or 48
|
||||
local quads = S.objectQuads
|
||||
@@ -2291,7 +2342,7 @@ local function buildFigure(S, map, fig, tx, ty, perRow)
|
||||
quads = quads,
|
||||
wx = tx * 8 + minX,
|
||||
wz = ty * 8 + math.floor(lowY / 8) * 8 + 4,
|
||||
y = baseY,
|
||||
y = baseY + (S.base and S.base[keyOf(tx, ty + fig.h - 1)] or 0),
|
||||
}
|
||||
|
||||
-- What each covered tile wears now that he is off it. Only the ART
|
||||
@@ -2436,12 +2487,13 @@ function Structures.buildGrass(S, map, x0, x1, y0, y1, data)
|
||||
templates[tileId] = tpl
|
||||
end
|
||||
local wx, wz = tx * 8, ty * 8
|
||||
local wy = S.base and S.base[k] or 0
|
||||
for _, q in ipairs(tpl) do
|
||||
quads[#quads + 1] = {
|
||||
{ q[1][1] + wx, q[1][2], q[1][3] + wz },
|
||||
{ q[2][1] + wx, q[2][2], q[2][3] + wz },
|
||||
{ q[3][1] + wx, q[3][2], q[3][3] + wz },
|
||||
{ q[4][1] + wx, q[4][2], q[4][3] + wz },
|
||||
{ q[1][1] + wx, q[1][2] + wy, q[1][3] + wz },
|
||||
{ q[2][1] + wx, q[2][2] + wy, q[2][3] + wz },
|
||||
{ q[3][1] + wx, q[3][2] + wy, q[3][3] + wz },
|
||||
{ q[4][1] + wx, q[4][2] + wy, q[4][3] + wz },
|
||||
uv = q.uv, shade = q.shade,
|
||||
}
|
||||
end
|
||||
@@ -2635,12 +2687,13 @@ function Structures.buildFlowers(S, map, tw, th, x0, x1, y0, y1, data)
|
||||
templates[tileId] = tpl
|
||||
end
|
||||
local wx, wz = tx * 8, ty * 8
|
||||
local wy = S.base and S.base[k] or 0
|
||||
for _, q in ipairs(tpl) do
|
||||
quads[#quads + 1] = {
|
||||
{ q[1][1] + wx, q[1][2], q[1][3] + wz },
|
||||
{ q[2][1] + wx, q[2][2], q[2][3] + wz },
|
||||
{ q[3][1] + wx, q[3][2], q[3][3] + wz },
|
||||
{ q[4][1] + wx, q[4][2], q[4][3] + wz },
|
||||
{ q[1][1] + wx, q[1][2] + wy, q[1][3] + wz },
|
||||
{ q[2][1] + wx, q[2][2] + wy, q[2][3] + wz },
|
||||
{ q[3][1] + wx, q[3][2] + wy, q[3][3] + wz },
|
||||
{ q[4][1] + wx, q[4][2] + wy, q[4][3] + wz },
|
||||
uv = q.uv, shade = q.shade,
|
||||
}
|
||||
end
|
||||
|
||||
@@ -270,7 +270,12 @@ local function readback(image)
|
||||
local prev = love.graphics.getCanvas()
|
||||
local ok, data = pcall(function()
|
||||
local w, h = image:getDimensions()
|
||||
local canvas = love.graphics.newCanvas(w, h)
|
||||
-- dpiscale = 1, or this is not a copy. On a highdpi surface (Android,
|
||||
-- iOS -- see conf.lua) newCanvas takes the surface's scale by default,
|
||||
-- so the atlas would be drawn into a texture 2.75x its size and read
|
||||
-- back magnified -- and every tile coordinate below, which counts in
|
||||
-- eights from the top-left, would land somewhere between two tiles.
|
||||
local canvas = love.graphics.newCanvas(w, h, { dpiscale = 1 })
|
||||
love.graphics.setCanvas(canvas)
|
||||
love.graphics.clear(0, 0, 0, 0)
|
||||
-- straight copy: no blending against the cleared target, no tint from
|
||||
|
||||
+245
-16
@@ -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,19 @@ 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) }
|
||||
-- The height the orbit looks AT -- the smoothed ground under the
|
||||
-- player's feet (VoxelScene tracks it), so climbing a terrace does not
|
||||
-- slide the walker up the screen at a pitched camera. Zero on flat
|
||||
-- terrain, which is the framing this rig always had.
|
||||
local fy = Voxel3D.focusY or 0
|
||||
local focus = { cx, fy, cy }
|
||||
local eye = { cx, fy + 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 +643,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 +673,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 +693,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 +840,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 +1153,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
|
||||
|
||||
+214
-43
@@ -21,12 +21,18 @@ 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 Elevation = V.require("Elevation")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Map = require("src.world.Map")
|
||||
|
||||
local VoxelScene = {}
|
||||
|
||||
-- the camera's smoothed focus height (see render); nil until first framed
|
||||
local focusHeight = nil
|
||||
|
||||
-- What the active display mode actually paints with.
|
||||
--
|
||||
-- paletteFor hands back a map's RAW SGB zone palette, and that is not what
|
||||
@@ -164,24 +170,33 @@ local YAW = {
|
||||
-- top of it rather than sunk into it. Uses the same bottom-left collision
|
||||
-- tile the engine walks on (Map:cellTile).
|
||||
local function groundAt(map, cellX, cellY)
|
||||
-- The terrain's base under the cell -- 0 wherever no elevation field
|
||||
-- exists (every interior), so the flat world keeps its old answers.
|
||||
local b = Elevation.baseAt(map, cellX, cellY)
|
||||
-- Off the map, cellTile border-extends into the map's borderBlock --
|
||||
-- which on maps ringed with trees is a RAISED tile. The only entity
|
||||
-- ever standing off-map is the player mid seam-step (placed one cell
|
||||
-- before the connection entry), and the ground actually rendered
|
||||
-- there is the departed neighbour's flat walkway: height 0. Without
|
||||
-- this, crossing into such a map hoisted the walker tree-high for
|
||||
-- exactly one step -- the "hops like a ledge" seam bug.
|
||||
if not map:inBounds(cellX, cellY) then return 0 end
|
||||
-- there is the departed neighbour's flat walkway: the border apron's
|
||||
-- own base (baseAt clamps to the nearest body cell, which is how the
|
||||
-- apron is meshed). Without this, crossing into such a map hoisted
|
||||
-- the walker tree-high for exactly one step -- the "hops like a
|
||||
-- ledge" seam bug.
|
||||
if not map:inBounds(cellX, cellY) then return b end
|
||||
local shapes = TileShape.forMap(map)
|
||||
local s = shapes[map:cellTile(cellX, cellY)]
|
||||
if not s then return 0 end
|
||||
if not s then return b end
|
||||
-- a recessed class (water) still supports whatever stands on it; only
|
||||
-- raised ground lifts the model. Stairs never do: the class height is
|
||||
-- the flight's TALL end, but the player enters at floor level and the
|
||||
-- warp fires as they step in -- lifting them onto the geometry read as
|
||||
-- climbing an invisible block
|
||||
if s.art == "stair" then return 0 end
|
||||
return s.h > 0 and s.h or 0
|
||||
if s.art == "stair" then return b end
|
||||
-- on solved terrain a ledge is the high plateau's rim, its lip flush
|
||||
-- with the ground it belongs to: the solver's base IS its top. On a
|
||||
-- flat map it is still the classic 6px bump you stand on top of.
|
||||
if s.class == "ledge" and Elevation.fieldFor(map.id) then return b end
|
||||
return b + (s.h > 0 and s.h or 0)
|
||||
end
|
||||
|
||||
VoxelScene.YAW = YAW
|
||||
@@ -404,17 +419,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 +513,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 +681,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 +692,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 +713,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 +739,7 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh,
|
||||
mirror)))
|
||||
end
|
||||
end
|
||||
ShadowMap.sprites(false)
|
||||
|
||||
ShadowMap.finish(sig)
|
||||
end
|
||||
@@ -593,7 +749,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 +786,20 @@ 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)
|
||||
|
||||
-- The camera's focus height chases the ground under the player's feet,
|
||||
-- eased so a 2px terrace tread is a glide rather than a pop; a WARP-
|
||||
-- sized jump (raised route -> interior at 0) snaps instead of swooping
|
||||
-- the whole frame through the floor.
|
||||
local targetY = me and me.gh or 0
|
||||
if focusHeight == nil or math.abs(targetY - focusHeight) > 24 then
|
||||
focusHeight = targetY
|
||||
else
|
||||
focusHeight = focusHeight + (targetY - focusHeight) * 0.12
|
||||
end
|
||||
Voxel3D.focusY = focusHeight
|
||||
|
||||
if not Voxel3D.beginScene(w, h, cx, cy, vw, vh, skyFor(state.map)) then
|
||||
return nil
|
||||
@@ -658,6 +827,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 +886,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
|
||||
|
||||
+1303
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -688,6 +700,23 @@ mod.events:on("map.reloaded", function(payload)
|
||||
if mapId then ChunkMesher.invalidate(mapId) end
|
||||
end)
|
||||
|
||||
-- ------- the terrain solver needs the whole map registry
|
||||
--
|
||||
-- lib/Elevation.lua cuts the connected overworld into plateaus, which
|
||||
-- takes every map's blocks and connections at once -- not just the one
|
||||
-- being walked. The engine keeps that registry in main.lua's `Game`,
|
||||
-- which is a LOCAL there and reachable from no mod, so it arrives here
|
||||
-- instead: `mods.loaded` carries the merged dataset, and it is the only
|
||||
-- moment the whole of it is handed over. Without this the solver found
|
||||
-- no data, answered "no field" for every map, and the world stayed as
|
||||
-- flat as it ever was -- silently, which is the part that cost a while.
|
||||
mod.events:on("mods.loaded", function(payload)
|
||||
local data = payload and payload.data
|
||||
if data and data.maps then
|
||||
V.require("Elevation").install(data)
|
||||
end
|
||||
end)
|
||||
|
||||
-- ------- rows come and go, so the menu has to notice
|
||||
--
|
||||
-- OptionsMenu builds its row list ONCE, when it is opened, and then reads
|
||||
@@ -848,7 +877,7 @@ mod.hooks:wrap("world.tod", function(next, tod, ctx)
|
||||
return DayNight.tod()
|
||||
end)
|
||||
|
||||
mod.exports.version = "1.3.0"
|
||||
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
|
||||
|
||||
+3
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "DRAMATIC_SHAPE",
|
||||
"name": "Dramatic Shape Voxel Mod",
|
||||
"version": "1.3.0",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -2170,6 +2610,7 @@ local BattlePics = run.loader.exports.DRAMATIC_SHAPE.lib.require("BattlePics")
|
||||
-- Run one hand-drawn figure through the real BattlePics and hand back a
|
||||
-- reader over what came out. The pic is faked at the readback seam, which is
|
||||
-- the only thing between this and the pixels the engine would have blitted.
|
||||
local lastCanvas = nil -- what the readback asked newCanvas for
|
||||
local function fill(rows)
|
||||
local W, H = #rows[1], #rows
|
||||
local built = nil
|
||||
@@ -2189,7 +2630,8 @@ local function fill(rows)
|
||||
end
|
||||
|
||||
local realNewCanvas, realNewImage = love.graphics.newCanvas, love.graphics.newImage
|
||||
love.graphics.newCanvas = function()
|
||||
love.graphics.newCanvas = function(cw, ch, opts)
|
||||
lastCanvas = { w = cw, h = ch, opts = opts }
|
||||
return { setFilter = function() end, release = function() end,
|
||||
newImageData = fakeData }
|
||||
end
|
||||
@@ -2285,6 +2727,24 @@ local drainOut, drainPic, drain = fill({
|
||||
T.check(drainOut ~= drainPic and drain and drain(8, 4),
|
||||
"a narrow one is where the drawing ran out, and is paper")
|
||||
|
||||
-- ------- and the readback is measured in PIXELS, which is what kept the mons
|
||||
-- the size of the squares they stand on
|
||||
--
|
||||
-- love.graphics.newCanvas takes the SURFACE's dpi scale when it is not told
|
||||
-- otherwise, conf.lua turns highdpi on for Android and iOS, and Android's
|
||||
-- density is routinely 2.75. So an untold newCanvas(56, 56) allocated a
|
||||
-- 154x154 texture on a phone, the pic was magnified into it, and newImageData
|
||||
-- read the magnified copy back at its own size -- an image 2.75x the artwork,
|
||||
-- which drawPicsLayer then drew at 1:1 because it trusts getWidth(). The mon
|
||||
-- stood on the map three times the size of its tile.
|
||||
--
|
||||
-- Only for a pic with paper to put back, which is why it read as a bug in
|
||||
-- particular Pokemon (a giant Pidgey beside a normal mon) rather than as a
|
||||
-- scale that was wrong everywhere.
|
||||
T.check(lastCanvas and lastCanvas.opts and lastCanvas.opts.dpiscale == 1,
|
||||
"the readback canvas is one texel per pic pixel, on a highdpi phone too")
|
||||
T.eq(lastCanvas.w, 18, "and it is the size of the pic, in those pixels")
|
||||
|
||||
-- the answer is cached on the image, so a pic costs one readback a session
|
||||
-- rather than one a frame -- checked on the first figure, which is still in
|
||||
-- there because fill() does not clear it
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user