mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 12:50:50 +02:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c82598b24c | |||
| 47363b8d23 | |||
| 752653e243 | |||
| 6887f5d951 | |||
| a140980b1d | |||
| eb231d221e | |||
| 980383bb92 | |||
| 22b58e27a4 | |||
| 98f7419b72 | |||
| 92fef2a37e | |||
| 8f38aeb36e | |||
| 9a9441899a | |||
| 7f76caa5f6 | |||
| be2f0464c5 | |||
| 8728783b22 | |||
| 731ecd9677 | |||
| 851f36d46f | |||
| 775757b2d6 | |||
| 20f1807edd | |||
| 4da8e5dc3e | |||
| 3eb62a5e00 | |||
| 26d1d96d52 | |||
| d9a000d7ad | |||
| f12b564dcd | |||
| d1a1c69c7d | |||
| 500556c3fc | |||
| e621c28a74 | |||
| eabc8af716 |
@@ -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"
|
||||||
+629
@@ -1,5 +1,634 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.4.1
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Furniture through the building pipeline.** The band-table voxelizer
|
||||||
|
that models whole buildings from their own drawings (lib/Buildings.lua)
|
||||||
|
now reads interior furniture too, and the first four drawings are in:
|
||||||
|
|
||||||
|
- **F01, the starter-ball table in Oak's lab** -- the tabletop's 16
|
||||||
|
drawn rows lay flat over a 16px plot (1:1, the first template that
|
||||||
|
never cycles), the black/#555/black edge band folds into the slab's
|
||||||
|
own rim, and the base extrudes with its corner feet. Six voxels
|
||||||
|
tall, exactly the drawn elevation.
|
||||||
|
- **F03, the empty north table beside it** -- the same band table on a
|
||||||
|
grid two tiles narrower.
|
||||||
|
- **F02, the lab's computer desk** -- the first DESK-SET template: the
|
||||||
|
drawing segments into PARTS, each classified by the surface it
|
||||||
|
depicts. The monitor and the computer tower stand upright on the
|
||||||
|
desk wearing their own drawn tops as lids; the keyboards and the
|
||||||
|
mouse lie flat in front of them; the sheet of paper on the right
|
||||||
|
lies flat across the desk. Flat parts keep the drawing's own rule --
|
||||||
|
drawn row IS depth row, the same 1:1 the tabletop is drawn with --
|
||||||
|
so an object's height on the drawing is its position on the desk.
|
||||||
|
The Hall of Fame's recording machine is this drawing tile for tile
|
||||||
|
on the GYM atlas, and models identically for free.
|
||||||
|
- **F04, the Center PC** -- the desk-set read again: a Mac-style unit
|
||||||
|
with its screen and drive slot in relief, standing at the back of a
|
||||||
|
low white-topped desk with its keyboard lying at the front edge.
|
||||||
|
Eleven Pokemon Centers, plus the Indigo Plateau lobby, whose MART
|
||||||
|
tileset shares the atlas.
|
||||||
|
|
||||||
|
Two measurements had to stop being assumptions for furniture to fit
|
||||||
|
the pipeline: the GROUND LINE is now read off the drawing (a building
|
||||||
|
ends on the black threshold row it stands on; a table's legs stop two
|
||||||
|
rows short of theirs, and extruding against the grid floated them in
|
||||||
|
the air), and a template may name its PLOT (`depth`) when the matched
|
||||||
|
grid runs past it onto the walkable floor the legs merely stand on.
|
||||||
|
Both are identities for every existing building.
|
||||||
|
|
||||||
|
- **The Center couch has a backrest.** The couch is drawn from above --
|
||||||
|
back-and-arm strip down the west side, cushions and seams on the east
|
||||||
|
-- and rendered as one seat-high box. The new `backrest` class raises
|
||||||
|
the drawn back strip to 12px over the 8px seat, in every Center and
|
||||||
|
the Celadon Hotel. The man sitting on it keeps his seat: the figure
|
||||||
|
anchor now scans under his card for the tallest authored upright (his
|
||||||
|
cushion) instead of reading the corner tile, which is the backrest
|
||||||
|
now.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Sprites ride at the height the art actually stands.** Class heights
|
||||||
|
can now be overridden per tileset (a tileset entry's `heights`), and
|
||||||
|
DOJO's lab tables use it: they are drawn 6px tall, not the default
|
||||||
|
table's 12, so the starter balls sit exactly on the modelled tabletop
|
||||||
|
-- and the volume-built north tables drop to the same height, keeping
|
||||||
|
every table in the room level.
|
||||||
|
- The Center PC's old rendering -- a 12px table box with the unit as a
|
||||||
|
flat standee on it -- retires wherever the F04 template stamps; the
|
||||||
|
pins stay only as the degradation path when the shape profile is
|
||||||
|
absent.
|
||||||
|
|
||||||
|
## 1.4.0
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **WATER, a new row on hotkey 9: water reflects the world, the sky, the sun
|
||||||
|
and the moon.** Every lake, sea and pond in Kanto was a flat animated
|
||||||
|
texture lying in a hole in the ground. It is now a surface, and it is
|
||||||
|
reflective.
|
||||||
|
|
||||||
|
What it reflects, in the order the shader resolves them:
|
||||||
|
|
||||||
|
- **The sky.** The reflected direction goes through the very matrix the
|
||||||
|
frame is drawn with, as a point at infinity, and the canvas row that
|
||||||
|
lands on is looked up on Sky's own band ramp -- the identical texture,
|
||||||
|
the identical checkerboard dither, the identical display-mode transform.
|
||||||
|
So the sky in the lake is the sky over it, and the two meet at the
|
||||||
|
waterline with no seam at any pitch, field of view, window shape or zoom.
|
||||||
|
Blue at noon, gold at dusk, navy under the moon; GRAY gets a grey lake
|
||||||
|
and CLASSIC a green one, for nothing.
|
||||||
|
|
||||||
|
- **The sun and the moon**, hung by ANGLE rather than by screen position,
|
||||||
|
because a reflected body is usually off the top of the frame entirely
|
||||||
|
and a projected point stops meaning anything out there. The angular
|
||||||
|
radius is the painted disc's own radius run back through the camera's
|
||||||
|
field of view, so the two are the same size -- craters, dithered rim,
|
||||||
|
the sunset's loom and all, off one shared list. This is also the
|
||||||
|
specular: a low sun lays a broken gold path across the water on its own,
|
||||||
|
out of the reflection rather than out of a highlight term nailed on
|
||||||
|
beside it.
|
||||||
|
|
||||||
|
- **The world, in screen space.** The reflected ray is walked forward in
|
||||||
|
world space, each step projected through the same matrix, looking for
|
||||||
|
where it passes behind what the depth buffer holds -- then binary-refined
|
||||||
|
onto the contact and read out of a copy of the frame as it stood before
|
||||||
|
the water went down. Shore trees, buildings, ledges and cliffs land in
|
||||||
|
the water because they are on screen; where the ray leaves the frame or
|
||||||
|
finds nothing, the sky above answers instead, which is what makes the far
|
||||||
|
half of a lake sky and the near half scenery with no seam between them.
|
||||||
|
|
||||||
|
Fresnel decides how much of it shows: almost nothing looked straight down
|
||||||
|
at, almost everything looked along -- so the 15-degree rung is a pond and
|
||||||
|
the 75-degree rung is a mirror, off the same surface.
|
||||||
|
|
||||||
|
Every rung gets one, though, which took a lean. A reflection off flat water
|
||||||
|
points as far above the horizon as the eye is above the water: 15 degrees
|
||||||
|
at the top rung -- grazing the sky's pale end, sweeping the sun's own path,
|
||||||
|
travelling far enough across the screen for the march to find the shoreline
|
||||||
|
-- and 75 degrees, straight up, at the steepest. Up there the bands are at
|
||||||
|
their darkest, the sun and moon sit at about 6 degrees of squashed
|
||||||
|
elevation and are nowhere near it, and the screen-space ray leaves the top
|
||||||
|
of the frame in two steps. All three are correct, and together they are a
|
||||||
|
lake with nothing in it.
|
||||||
|
|
||||||
|
So the reflection now LEANS toward the elevation the top rung reflects at,
|
||||||
|
by however far the camera is from having a horizon in frame -- **zero** at
|
||||||
|
the rung where the horizon IS in frame, so the one place the join can be
|
||||||
|
seen, the waterline, is still the exact reflection it was. Toward an
|
||||||
|
elevation rather than by a weight, because the ray it starts from differs
|
||||||
|
at every rung and a fixed fraction lands them all somewhere different: the
|
||||||
|
middle rungs came out further from the sun than the steepest one. And it
|
||||||
|
leans the LEVEL reflection with each column's own deflection added back on
|
||||||
|
top -- leaning the perturbed ray sets its elevation outright, which at full
|
||||||
|
lean gave every column on the lake the same one, flattened the sky to a
|
||||||
|
single band and removed the moon entirely.
|
||||||
|
|
||||||
|
Three rungs rather than a toggle. FULL is the whole thing; SKY drops the
|
||||||
|
ray march and keeps the sky, sun and moon, which is most of the look for a
|
||||||
|
handful of instructions; OFF is the flat water this mode always drew. The
|
||||||
|
FULL preset sets it to FULL.
|
||||||
|
|
||||||
|
- **The water surface is a field of pixel-tall columns, and they are real.**
|
||||||
|
Not a normal map: a heightfield of one-world-pixel bars -- the same unit
|
||||||
|
every other voxel in this mode is built from, and exactly one texel of the
|
||||||
|
water tile -- each standing a WHOLE number of pixels high and rising and
|
||||||
|
falling on its own.
|
||||||
|
|
||||||
|
Three travelling wave trains, and one of them dominates: a wave has a
|
||||||
|
DIRECTION, and its crest is a line running across it for as far as the
|
||||||
|
water goes. Three trains of equal weight cancel and reinforce in patches
|
||||||
|
instead, and the surface comes out as round islands of raised pixels with
|
||||||
|
no travel to them -- blobs rather than waves. The dominant train's
|
||||||
|
wavelength is about forty world pixels, five tiles, so a crest is a long
|
||||||
|
run of columns at one height with a step down either side.
|
||||||
|
|
||||||
|
Drawn with no extra geometry at all: the mesh is still one flat quad per
|
||||||
|
tile, and the columns are found by walking the view ray down through the
|
||||||
|
slab in the pixel shader. That is what makes them read as solid -- a tall
|
||||||
|
bar hides the shorter ones behind it, you see the SIDE of the ones facing
|
||||||
|
you (wearing the mesh's own direction shading, so a crest is lit like every
|
||||||
|
other voxel in the world), and the whole field parallaxes against the plane
|
||||||
|
as the camera moves. The water's art is read at the column the ray landed
|
||||||
|
on rather than at the flat quad underneath, so the pixels travel with the
|
||||||
|
bars they are made of.
|
||||||
|
|
||||||
|
The columns are what you SEE; the normal they reflect with is read off the
|
||||||
|
smooth surface they are a quantisation of. That distinction is the whole
|
||||||
|
difference between a moon on the water and confetti: whole-pixel heights
|
||||||
|
have whole-pixel differences, so a normal built from them can only point in
|
||||||
|
about five directions, and a sun or moon barely two degrees across falls
|
||||||
|
between them. Still one normal per column, so the surface stays
|
||||||
|
pixel-quantised in space while the value it reflects with is continuous.
|
||||||
|
|
||||||
|
Crests stand up to five world pixels, well past the 2px recess water sits
|
||||||
|
in -- deliberately, because the columns are relief drawn inside the water
|
||||||
|
quad's own footprint, so a bar that reaches above the bank is clipped at
|
||||||
|
the water's edge rather than spilling over it. What it buys is a surface
|
||||||
|
with real swell in it instead of a two-rung terrace.
|
||||||
|
|
||||||
|
And it moves in STEPS, at **15 a second** -- the cadence hand-drawn pixel
|
||||||
|
art is animated at. A surface built out of whole pixels that crawls
|
||||||
|
smoothly between them gives away that the quantisation is only skin deep.
|
||||||
|
Each step advances the dominant wave by exactly one world pixel, derived
|
||||||
|
from that train's own wavelength rather than tuned beside it, so nothing
|
||||||
|
ever lands half-way between two pixels and changing a wavelength moves the
|
||||||
|
speed with it.
|
||||||
|
|
||||||
|
- **AA, a new options row: OFF / 2X / 4X.** Everything else in this game is
|
||||||
|
flat art blitted at whole pixels. This mode's world is real geometry seen
|
||||||
|
through a perspective camera, and a polygon edge that lands at an angle
|
||||||
|
across the pixel grid is the one place where a hard stair-step is not a
|
||||||
|
stylistic choice -- a roof ridge, a ledge lip, a tree's silhouette against
|
||||||
|
the sky, the leaning card of a character. At the shallow rungs, where the
|
||||||
|
diorama reads most like a photograph of a model, they crawl as the camera
|
||||||
|
drifts.
|
||||||
|
|
||||||
|
The row is SUPERSAMPLING: the whole pass renders into a canvas larger than
|
||||||
|
the window and is folded back down at the end. The ladder is samples per
|
||||||
|
display pixel, so 2X is a canvas root-two wider and taller and 4X one
|
||||||
|
exactly twice the size -- an honest 2x2 box.
|
||||||
|
|
||||||
|
Two alternatives were tried against what this pass already is, and both
|
||||||
|
lost:
|
||||||
|
|
||||||
|
- **MSAA** would have taken the water with it. The reflections read the
|
||||||
|
frame's own depth buffer as a texture, and a multisampled depth
|
||||||
|
attachment is not something a fragment shader in this dialect can sample.
|
||||||
|
The row would have quietly switched the WATER row off.
|
||||||
|
|
||||||
|
- **An edge filter** (FXAA and its relatives) works from the finished
|
||||||
|
colour alone, so it would be guessing where the edges are out of one
|
||||||
|
sample per pixel -- inventing detail it never rendered, and unable to
|
||||||
|
tell a geometry edge from the boundary between two texels of a tileset.
|
||||||
|
|
||||||
|
Rendering larger has neither problem, and nothing in the frame had to be
|
||||||
|
taught about it: every pass already measures itself in the canvas it was
|
||||||
|
handed, so the sky's dither, the water's ray march, the shadow lookups and
|
||||||
|
the camera itself come out the same picture at a higher sample rate. It
|
||||||
|
antialiases the geometry, the alpha-cut outline of a sprite card, the
|
||||||
|
wireframe and the reflections at once, because none of them know it is
|
||||||
|
happening.
|
||||||
|
|
||||||
|
And it softens the ARTWORK with them, which is worth saying plainly. A
|
||||||
|
tileset texel out here is not a screen pixel, it is a quad in a perspective
|
||||||
|
view, and its boundary crosses the pixel grid at the same arbitrary angle a
|
||||||
|
roof ridge does -- so the fold averages across it exactly as it averages
|
||||||
|
across the ridge. That is what an honest extra sample says about that
|
||||||
|
pixel, and it is also the trade the row is: the diorama comes out smoother,
|
||||||
|
not sharper. Which is why it is a row and not something that is simply on.
|
||||||
|
|
||||||
|
Two things are quoted in DISPLAY pixels rather than canvas ones and are
|
||||||
|
multiplied up to match: the voxel wireframe's line width -- left alone it
|
||||||
|
would fold down to half a line, so turning the smoothing up would appear to
|
||||||
|
fade the grid out -- and the scale the overworld's FX closures draw at.
|
||||||
|
|
||||||
|
The fold is a shader rather than a scaled draw, because the void this pass
|
||||||
|
renders into is a transparent BLACK: averaging a straight-alpha edge against
|
||||||
|
it drags the colour toward black as well as toward transparent, and the
|
||||||
|
engine's composite then multiplies by that alpha a second time. Every
|
||||||
|
silhouette against the sky would have come out ringed with a dark fringe --
|
||||||
|
the exact artefact the row exists to remove. So the taps are premultiplied
|
||||||
|
before they are averaged and divided back out after.
|
||||||
|
|
||||||
|
The staged battle gets it too, on its own canvas: the arena is folded back
|
||||||
|
to the window's pixel size before the depth-of-field pass and the HUDs go
|
||||||
|
on, so the world is smoothed and the pics, panels and text box stay the
|
||||||
|
chunky GB art they are.
|
||||||
|
|
||||||
|
OFF by default, and **FULL neither sets it nor takes the row away** -- it
|
||||||
|
is the one row that is not a knob on the look but on what the look COSTS,
|
||||||
|
and only the player knows what their machine can carry. No hotkey, for the
|
||||||
|
same reason: it is set once, not flicked while walking.
|
||||||
|
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
- **Under BACK SPRITES some of your own Pokemon were see-through -- Pikachu,
|
||||||
|
Seel, Dewgong, Chansey, Jigglypuff -- with the arena showing through the
|
||||||
|
middle of them.** Those back pics are drawn as OUTLINES: everything inside
|
||||||
|
the ink is the lightest shade, the decoder keys that shade to nothing, and
|
||||||
|
on hardware it did not matter because the field behind them was white too.
|
||||||
|
|
||||||
|
BattlePics already put that paper back by flooding the background inward and
|
||||||
|
filling whatever it could not reach, and along the bottom of a figure it told
|
||||||
|
a narrow opening (a belly the drawing ran out of, sealed) from a wide one (a
|
||||||
|
stride, left open for the world to show through). Right for a mon standing
|
||||||
|
on the map -- but the pinned back pic is not on the map, it is on the text
|
||||||
|
box with its feet on row 96, and there is white box under its lowest row
|
||||||
|
rather than arena. Every one of those mons leaks out through an opening far
|
||||||
|
too wide to read as a drain, so the flood walked straight up inside them.
|
||||||
|
|
||||||
|
A pic on the box is now told so, and its bottom edge seals: nothing reaches
|
||||||
|
it from below at any width, and the rule stops being a heuristic -- paper is
|
||||||
|
whatever the background cannot walk to from the left, the right or the top.
|
||||||
|
Twelve of the game's 151 back pics turn on this; the other 139 come back
|
||||||
|
byte-identical, and no front pic is touched at all.
|
||||||
|
|
||||||
|
**And a hole is filled with the pic's own paper rather than with white.**
|
||||||
|
Shade 0 is only white while the pic is still grays, and pics arrive here
|
||||||
|
after the bake -- a species SGB colour, a BGP fade mid-animation, PAL_BLACK
|
||||||
|
across the whole screen while the blackout text is up. A hardcoded white
|
||||||
|
belly would have been the one lit thing on a blacked-out mon. The lightest
|
||||||
|
shade still standing in the pic is that colour, and every one of the game's
|
||||||
|
battler pics keeps at least one such pixel -- an eye, a highlight down a
|
||||||
|
cheek -- so what goes back is the baked shade itself.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
- **BACK SPRITES, a new row under 3D-BTL: your own Pokémon stays on the battle menu.**
|
||||||
|
The staged shot stands both mons on the map, which is the mode's whole claim
|
||||||
|
-- and it costs the framing Gen 1 is most recognisable by: your own Pokémon,
|
||||||
|
seen from behind, sitting on top of the battle menu with its feet on the box.
|
||||||
|
|
||||||
|
With BACK SPRITES on the foe is still geometry standing on its own tile at the far
|
||||||
|
end of the arena, and the player's side goes back to being the GB's own flat
|
||||||
|
back pic in the GB's own slot: same art, same 2x, same feet on row 96. It is
|
||||||
|
the engine's own pics layer that draws it, through the `onlySide` argument
|
||||||
|
that layer already takes, so every pic effect -- the grow-out-of-the-ball,
|
||||||
|
the faint slide, the damage blink, the send-out trainer pic -- comes along
|
||||||
|
unchanged and none of it is reimplemented.
|
||||||
|
|
||||||
|
Nothing else about the shot moves. The arena, the camera and the drift are
|
||||||
|
solved exactly as they were, so the foe stands where it always stood and the
|
||||||
|
player's cell is simply empty ground in the foreground. Two things follow the
|
||||||
|
setting: the `pokemon.sprite` hook stops asking for the front pic on the
|
||||||
|
player's side (it is a back view again, and the front art would be that mon
|
||||||
|
turned round to face the player it belongs to), and the move-animation offset
|
||||||
|
drops that side's contribution, because a pic that has not moved cannot have
|
||||||
|
moved the pair's centre.
|
||||||
|
|
||||||
|
OFF by default -- what the mode advertises is the two of them out there --
|
||||||
|
and only on the OPTIONS menu while 3D-BTL is on, since with staged battles
|
||||||
|
off the engine already draws exactly this.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Battle pics were see-through, and it took a back sprite on a tiled floor
|
||||||
|
to make it obvious.** Gen 1 pics are two-bit art whose lightest shade is
|
||||||
|
white, and the decoded PNGs key that shade to alpha 0 -- which cost nothing
|
||||||
|
when the field behind them was white too. Over a route, every belly, every
|
||||||
|
eye white and every highlight is a hole with the world showing through, and
|
||||||
|
the mon reads as a stencil.
|
||||||
|
|
||||||
|
`BattlePics` exists to put that paper back and, as written, put none of it
|
||||||
|
back. It flood-filled the outside from the border and filled what the flood
|
||||||
|
could not reach, which is exact and, on this game's art, empty: a Gen 1
|
||||||
|
figure is an open drawing, and its belly walks out to the border through the
|
||||||
|
gap between its legs. Read across all 305 of the game's battle pics, that
|
||||||
|
rule finds an enclosed hole in exactly none of them.
|
||||||
|
|
||||||
|
The fix is to start the flood somewhere else: at the edges of the ARTWORK'S
|
||||||
|
OWN BOUNDING BOX, and at three of them -- left, right and top. The bottom is
|
||||||
|
closed, because it is not a side the background is behind, it is where the
|
||||||
|
drawing was CUT. A pic is bottom-aligned in its slot with all the margin at
|
||||||
|
the top, so a mon's lowest row is the last row it was given and everything
|
||||||
|
below the belly simply stops. Treat that cut as open and the background
|
||||||
|
pours up inside the figure, which is the channel of world that used to show
|
||||||
|
through a Clefairy.
|
||||||
|
|
||||||
|
That is exact rather than a heuristic: nothing is filled because of what
|
||||||
|
surrounds it, only because the background provably cannot reach it. Which is
|
||||||
|
why it needs no idea whether it is holding a front pic or a back one -- the
|
||||||
|
sky between a pair of ears reaches the top edge and stays sky, the gap
|
||||||
|
between a body and a raised tail reaches the side and stays gap, the belly
|
||||||
|
reaches neither and is paper. The silhouette is untouched, so the mon still
|
||||||
|
cuts cleanly against the world.
|
||||||
|
|
||||||
|
It replaces the border flood outright rather than sitting beside it, since
|
||||||
|
anything the border could not reach the box edges cannot reach either.
|
||||||
|
|
||||||
|
The bottom edge needs one more distinction, because two different things
|
||||||
|
meet the underside of a figure. A DRAIN is where the drawing ran out -- a
|
||||||
|
belly whose white carries on down until the artist stopped, leaking out
|
||||||
|
through the inch between a body and a leg -- and is sealed. A MOUTH is the
|
||||||
|
space between two legs, background that happens to be enclosed on three
|
||||||
|
sides, and is left open so the world shows through a trainer's stride.
|
||||||
|
|
||||||
|
Width tells them apart, and on this game's art it is not a close call.
|
||||||
|
Measured along the bottom of every battle pic, the drains run 3 and 4 pixels
|
||||||
|
(Clefairy's back, Wartortle's back, Red's back) and the mouths run 10, 12, 14
|
||||||
|
and 17 (a Rattata's underbelly, Blue's stride, Brock's, a Pikachu's back).
|
||||||
|
Nothing lands between 4 and 10, so the cut is taken at 6 with room either
|
||||||
|
side rather than tuned to one sprite. Apart from that number the rule stays
|
||||||
|
exact.
|
||||||
|
|
||||||
|
Front pics come back untouched, and not by being special-cased: they are
|
||||||
|
near-solid silhouettes with almost nothing inside them to fill, so their own
|
||||||
|
shape is what says so.
|
||||||
|
|
||||||
|
Both mons were affected -- the cards in the arena as much as anything -- so
|
||||||
|
this lands wherever a battle pic is drawn over the world, not just under
|
||||||
|
BACK SPRITES.
|
||||||
|
|
||||||
|
- **The pinned back pic was lit at noon while the world behind it was not.**
|
||||||
|
Everything standing in the arena goes through the voxel shader, and that
|
||||||
|
shader multiplies by the hour's tint, so at dusk the diorama warms and at
|
||||||
|
night it goes blue -- the two mons' cards included, because they are drawn
|
||||||
|
in the same pass as the ground they stand on. A back pic pinned to the menu
|
||||||
|
is not in that pass; it is a flat blit over the finished shot, and it stayed
|
||||||
|
bright over a midnight route.
|
||||||
|
|
||||||
|
The same tint is now applied to that one draw, by multiplying every colour
|
||||||
|
the pics layer sets on its way past -- so the alpha, the faint slide's fade
|
||||||
|
and the damage blink all compose with it instead of being overwritten. What
|
||||||
|
it does not get is the sun: the cards are shadow-mapped and a pic pinned to
|
||||||
|
the menu has no position in the scene to be shadowed at, so it carries the
|
||||||
|
hour and not the weather.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **The hour reaches the FLAT world too, not just the diorama.** DAYTIME drove
|
||||||
|
the 3D pass through the voxel shader's own tint uniform -- a uniform the 2D
|
||||||
|
tile path never runs -- so with VOXEL off, the same evening that fell on the
|
||||||
|
diorama left the flat world at permanent noon. One clock, two worlds, one of
|
||||||
|
them ignoring it. Outdoor maps now get the same multiply, painted as one
|
||||||
|
rectangle over the composited world.
|
||||||
|
|
||||||
|
The whole difficulty is WHERE, and it is worth writing down. Not on the world
|
||||||
|
canvas: in a colorized mode that canvas is grayscale art and the blit that
|
||||||
|
puts it on screen runs it through the palette shader, which classifies each
|
||||||
|
pixel into a shade BY ITS RED CHANNEL -- multiply a night blue over it first
|
||||||
|
and every pixel lands in the wrong bucket, so the world does not darken, it
|
||||||
|
changes colour. Not over the finished frame either, or the dialog boxes and
|
||||||
|
menus darken along with the world they are held up in front of, which is the
|
||||||
|
same reason the tilt-shift blur is a `worldPresent` and not a `present`.
|
||||||
|
|
||||||
|
Which leaves the instant between the world blit and the UI blit, and the
|
||||||
|
engine has no seam there -- `worldPresent` only runs when a PIPELINE produced
|
||||||
|
the world, which in flat mode is precisely what did not happen. So
|
||||||
|
`Renderer:endFrame` is wrapped and the UI canvas's own draw is watched for:
|
||||||
|
`blit` passes the canvas it is compositing as the first argument, so the
|
||||||
|
first draw of `Renderer.canvas` IS the boundary, by identity rather than by
|
||||||
|
counting. The shader and scissor that call arrives under belong to the UI
|
||||||
|
blit already in progress, so both are put aside for the rectangle and handed
|
||||||
|
straight back.
|
||||||
|
|
||||||
|
Skipped entirely when a pipeline drew the frame (it tinted itself, and twice
|
||||||
|
is wrong), indoors (a room has no sky to take its light from), and at midday
|
||||||
|
(a multiply by white) -- so a game with the clock at DAY issues not one extra
|
||||||
|
call.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **FULL no longer takes the two battle rows off the menu.** It still owns the
|
||||||
|
rows that describe the LOOK -- the wireframe, the horizon bend, the blur, the
|
||||||
|
hour -- because it is a preset for the diorama and a row that no longer
|
||||||
|
decides anything is worse than no row. 3D-BTL and BACK SPRITES are not that:
|
||||||
|
one decides what a fight is drawn OVER and the other how it is framed.
|
||||||
|
FULL still SETS both on arrival; it does not hold them, and leaving them
|
||||||
|
reachable is the difference between a preset and a lock.
|
||||||
|
|
||||||
|
This makes `stagedBattles()` honest as a side effect. It used to answer yes
|
||||||
|
under FULL as well, on the grounds that FULL owned the 3D-BTL row and
|
||||||
|
switched it on -- safe only while the row was hidden. With the row reachable
|
||||||
|
from inside FULL, that clause would have claimed staged battles for a preset
|
||||||
|
the player had just switched them off inside, pinning BATTLE LAYOUT to OG for
|
||||||
|
a fight that never gets staged. The row is the only thing that decides now,
|
||||||
|
which is what `OverworldBattle.begin` and `wantsFront` already believed.
|
||||||
|
|
||||||
|
- **TILT and GBC FX are off the OPTIONS menu entirely while this mod is
|
||||||
|
installed.** Both fight the diorama and both were already half-taken: the
|
||||||
|
mode's own key forces them off on every press, and the registry switches
|
||||||
|
TILT off whenever a world pipeline takes the pass. What was left was two
|
||||||
|
rows a player could set and watch get reverted -- TILT being the flat fake
|
||||||
|
of what this mode does for real, and GBC FX a full-screen present pass over
|
||||||
|
the top of the whole thing.
|
||||||
|
|
||||||
|
Dropped AND held at zero, which is the part that matters: hiding a live
|
||||||
|
setting is a trap, because a save written before the mod was installed can
|
||||||
|
carry TILT 3 and a row that is not there cannot turn it back off. Pinned
|
||||||
|
wherever the value could arrive from -- the menu opening, a save being
|
||||||
|
loaded or begun -- so there is no route by which either is on and
|
||||||
|
unreachable. Uninstalling the mod puts both rows back, at whatever they were
|
||||||
|
last set to.
|
||||||
|
|
||||||
|
- **The battle's text box and menus are frosted glass, like the HUDs.** The
|
||||||
|
HUD blocks got panels because black glyphs on grass are not readable. The box
|
||||||
|
at the bottom had the opposite problem and the same cause: it is drawn as an
|
||||||
|
opaque white slab with a black border, which was the field's own colour back
|
||||||
|
when the field was white and is a sheet of paper laid over the bottom third
|
||||||
|
of the diorama now that it is not.
|
||||||
|
|
||||||
|
It gets exactly what the HUDs get -- the world behind it, blurred to frosted
|
||||||
|
glass and laid back down translucent, at the same frost and the same tint --
|
||||||
|
and it is measured into the same brightness verdict, so the ink over the menu
|
||||||
|
flips white with the ink over the HUDs rather than against it. Only the FILL
|
||||||
|
is taken away: the border, the text, the cursor and the down arrow are the
|
||||||
|
engine's own glyphs in their own places. The move menu's TYPE/PP box and
|
||||||
|
Mimic's copy menu get their own panels, trimmed to the rows above the box
|
||||||
|
below them so no pixel is frosted twice.
|
||||||
|
|
||||||
|
## 1.2.1
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **On Android the sky went black below its first couple of bands.** A hard-edged
|
||||||
|
band of black ran from partway down the gradient to the horizon point, with the
|
||||||
|
moon still hanging correctly inside it. Desktop was unaffected.
|
||||||
|
|
||||||
|
What gave it away is that the same colour reached the screen by two routes and
|
||||||
|
only one of them was wrong. The haze filling the void UNDER the horizon is the
|
||||||
|
sky's palest band, and it is delivered by `love.graphics.clear` -- it landed
|
||||||
|
correctly. The bottom of the sky above it is that same band delivered by the
|
||||||
|
shader, and it was black. So the palette was not reaching the fragment shader,
|
||||||
|
and nothing was wrong with the palette, the layout or the camera.
|
||||||
|
|
||||||
|
The bands went in as `uniform vec3 bands[8]`, filled from Lua and read through
|
||||||
|
a loop counter, and on Android's GLSL ES the tail of that array arrived as
|
||||||
|
zero -- which is black. The likeliest reason is the fragment uniform budget:
|
||||||
|
ES 2.0 only guarantees sixteen uniform VECTORS, and eight band slots plus the
|
||||||
|
twilight glow plus LOVE's own built-ins is over it. A driver that truncates a
|
||||||
|
partly-filled array, or one that reflects `bands[0]` and nothing after it,
|
||||||
|
fails identically -- so the fix removes the whole class rather than the one
|
||||||
|
cause.
|
||||||
|
|
||||||
|
The bands are a one-texel-per-band TEXTURE now, sampled nearest, with the
|
||||||
|
band index clamped against the ramp's width. One texture unit replaces eight
|
||||||
|
uniform vectors, there is no array to index and no budget to overrun, and a
|
||||||
|
sample past the last band lands on the last band instead of on nothing. It is
|
||||||
|
still a palette and not a picture -- one texel per band on a single row -- so
|
||||||
|
the sky is still computed per pixel at the size it is displayed at, with
|
||||||
|
nothing resampled and nothing baked.
|
||||||
|
|
||||||
|
Also gone with it: `clamp(x, 0.0, 0.999999)`, which rounds its bound to 1.0 at
|
||||||
|
mediump -- the fragment default on GLSL ES -- and would have indexed one past
|
||||||
|
the last band on the sky's bottom row for the same black result.
|
||||||
|
|
||||||
## 1.1.1
|
## 1.1.1
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -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
|
whatever they land on, and an optional tilt-shift pass sells the
|
||||||
miniature-model look.
|
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
|
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
|
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
|
plays over the empty map, and the battle draws over the nearest patch of
|
||||||
@@ -34,7 +50,23 @@ menu.
|
|||||||
| `6`, or the **T-SHIFT** options row | OFF → 1 → 2 → 3 → OFF (miniature blur) |
|
| `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 |
|
| `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 |
|
| `8`, or the **3D-BTL** options row | ON / OFF — fight on the map instead of on a white field |
|
||||||
| the **DAYTIME** options row | SYNC / DAY / NIGHT / DUSK / DAWN / CYCLE — what time it is outdoors; held at SYNC (and off the menu) while VOXEL is FULL |
|
| `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 **AA** options row | OFF / 2X / 4X — smooth the stair-stepped edges of the 3D world by rendering the diorama larger than the window and folding it back down. The ladder is samples per display pixel: 2X is a canvas root-two wider and taller, 4X one exactly twice the size. Every edge in the projected picture softens with the silhouettes — the tileset's own texels are quads in a perspective view and cross the pixel grid at the same arbitrary angles — so the diorama reads smoother rather than sharper. The most expensive row in the mod, so it is OFF by default and **FULL** leaves it alone |
|
||||||
|
| 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 |
|
||||||
|
|
||||||
**3D-BTL** is on by default and is independent of **VOXEL**: battles draw
|
**3D-BTL** is on by default and is independent of **VOXEL**: battles draw
|
||||||
on the world whether or not the free-roam camera is pitched over.
|
on the world whether or not the free-roam camera is pitched over.
|
||||||
|
|
||||||
|
Two of the engine's own rows are taken away while this mod is installed:
|
||||||
|
**TILT**, which is the flat fake of what this mode does for real, and **GBC
|
||||||
|
FX**, a full-screen present pass over the top of the diorama. Both are held at
|
||||||
|
off rather than merely hidden — a row that is not there cannot switch off a
|
||||||
|
value an older save arrived with. Uninstall and both come back, at whatever
|
||||||
|
they were last set to.
|
||||||
|
|
||||||
|
Everything the battle screen draws as a box — the two HUD blocks, the text
|
||||||
|
box and the menus over it — sits on frosted glass rather than on the white
|
||||||
|
field it used to have behind it: the world underneath, blurred and laid back
|
||||||
|
down translucent, with the ink flipping white where the ground it lands on is
|
||||||
|
dark. Nothing the engine draws inside a box moves; only the paper is gone.
|
||||||
@@ -41,8 +41,15 @@ return {
|
|||||||
-- ------- routes
|
-- ------- routes
|
||||||
-- narrow, deliberately: the route's interior is a 3-cell-wide lane and the
|
-- narrow, deliberately: the route's interior is a 3-cell-wide lane and the
|
||||||
-- wide shape only fits in the western connection border, which staged every
|
-- wide shape only fits in the western connection border, which staged every
|
||||||
-- fight at the edge of the world instead of on the road
|
-- fight at the edge of the world instead of on the road.
|
||||||
["ROUTE_1"] = { x = 9, y = 16, shape = "narrow" },
|
--
|
||||||
|
-- Of the seventeen spots the route has outside that border, fourteen are
|
||||||
|
-- this one mid-route clearing and the other three bury the near mon behind
|
||||||
|
-- a hedge -- which the clearance test passes, since it measures terrain
|
||||||
|
-- height along the sightline and a hedge in the apron row is not terrain.
|
||||||
|
-- So the choice is where in the clearing, and this is its west end: tree
|
||||||
|
-- line square behind the pair, nothing crossing either of them.
|
||||||
|
["ROUTE_1"] = { x = 4, y = 14, shape = "narrow" },
|
||||||
["ROUTE_2"] = { x = 1, y = 49, shape = "wide" },
|
["ROUTE_2"] = { x = 1, y = 49, shape = "wide" },
|
||||||
["ROUTE_3"] = { x = 57, y = 1, shape = "wide" },
|
["ROUTE_3"] = { x = 57, y = 1, shape = "wide" },
|
||||||
["ROUTE_4"] = { x = 46, y = 7, shape = "wide" },
|
["ROUTE_4"] = { x = 46, y = 7, shape = "wide" },
|
||||||
|
|||||||
+191
-6
@@ -110,6 +110,9 @@ return {
|
|||||||
bed = 7,
|
bed = 7,
|
||||||
stool = 8,
|
stool = 8,
|
||||||
counter = 8,
|
counter = 8,
|
||||||
|
-- the raised back band of low seating (the Center couch's back and
|
||||||
|
-- arm strip): half again the 8px seat it rises over
|
||||||
|
backrest = 12,
|
||||||
table = 12,
|
table = 12,
|
||||||
desk = 24,
|
desk = 24,
|
||||||
prop = 16,
|
prop = 16,
|
||||||
@@ -579,6 +582,12 @@ return {
|
|||||||
-- auto-extracting into standing prisms, and the ball/Pokedex
|
-- auto-extracting into standing prisms, and the ball/Pokedex
|
||||||
-- sprites ride the table's authored height.
|
-- sprites ride the table's authored height.
|
||||||
table = { 41, 42, 57, 59, 78, 79 },
|
table = { 41, 42, 57, 59, 78, 79 },
|
||||||
|
-- These tables are drawn 6px tall (3px slab edge over 3px base --
|
||||||
|
-- see the lab_table entry under `buildings`), not the default
|
||||||
|
-- table's 12: the override keeps the volume-built north tables
|
||||||
|
-- and the band-built starter table one height, and stands the
|
||||||
|
-- ball/Pokedex sprites exactly on the top face of both.
|
||||||
|
heights = { table = 6 },
|
||||||
},
|
},
|
||||||
|
|
||||||
-- Red's room and the Copycat's room (one tileset). The detector reads
|
-- Red's room and the Copycat's room (one tileset). The detector reads
|
||||||
@@ -699,14 +708,25 @@ return {
|
|||||||
-- one clean band, so the drawn front panel stands up and the
|
-- one clean band, so the drawn front panel stands up and the
|
||||||
-- counter top stays on top; at 12 they read as wall stubs
|
-- counter top stays on top; at 12 they read as wall stubs
|
||||||
counter = { 8, 10, 24, 25, 56, 90, 91,
|
counter = { 8, 10, 24, 25, 56, 90, 91,
|
||||||
-- and the lounge couch with the man sitting on it.
|
-- and the lounge couch's SEAT column with the man
|
||||||
-- Same half-cell box: its bottom row (42/43, the front
|
-- sitting on it. Same half-cell box: its bottom row
|
||||||
-- base) stands up as the couch's front, and the three
|
-- (43, the front base) stands up as the couch's
|
||||||
-- rows above it -- cushion (38/39) and the man
|
-- front, and the rows above it -- cushion (39) and
|
||||||
-- (36/37 head, 52/53 face) -- ride the top face in
|
-- the man (37 head, 53 face) -- ride the top face in
|
||||||
-- drawn order, each exactly once. See the note below
|
-- drawn order, each exactly once. See the note below
|
||||||
-- on why he cannot be stood upright.
|
-- on why he cannot be stood upright.
|
||||||
36, 37, 38, 39, 42, 43, 52, 53 },
|
37, 39, 43, 53 },
|
||||||
|
-- The couch's WEST tile column: the drawing's left strip is the
|
||||||
|
-- couch's back and arm running north-south (the seat's cushions
|
||||||
|
-- and seams fill the east column), so it rises over the 8px seat
|
||||||
|
-- the way a couch back does instead of lying flush in the same
|
||||||
|
-- box. Per-tile granularity puts the drawn ~6px strip plus a
|
||||||
|
-- 2px sliver of cushion on the raised band -- invisible at tile
|
||||||
|
-- scale, and the alternative is no backrest at all. The figure
|
||||||
|
-- anchor scans for the tallest authored UPRIGHT under the man
|
||||||
|
-- (see Structures.buildFigure), so he keeps sitting at seat
|
||||||
|
-- height beside it.
|
||||||
|
backrest = { 36, 38, 42, 52 },
|
||||||
-- standing per-pixel props, black-outline segmented: the healing
|
-- standing per-pixel props, black-outline segmented: the healing
|
||||||
-- machines' screen tops (58/59/74/75, drawn above the pinned
|
-- machines' screen tops (58/59/74/75, drawn above the pinned
|
||||||
-- bodies so they stand ON them) and the PC (66/70/82/86), which
|
-- bodies so they stand ON them) and the PC (66/70/82/86), which
|
||||||
@@ -3231,5 +3251,170 @@ return {
|
|||||||
slab = 4, frontEave = 4, ledge = nil,
|
slab = 4, frontEave = 4, ledge = nil,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
DOJO = {
|
||||||
|
-- F01: the starter-ball table in Oak's lab (one placement in the
|
||||||
|
-- game: OAKS_LAB cell 6,3) -- the first FURNITURE through the
|
||||||
|
-- band pipeline, and the first drawing whose plot is smaller than
|
||||||
|
-- its grid. Its 24 rows read: 0-15 the tabletop seen from above
|
||||||
|
-- (black rim, white highlight course, grey field); 16-18 the top
|
||||||
|
-- slab's own front edge, black/#555/black -- which is exactly
|
||||||
|
-- what the rim treatment paints, so slab = 3 and those rows fold
|
||||||
|
-- into the roof band instead of extruding under it; 19-21 the
|
||||||
|
-- base band, corner feet and the inset dark panel between them.
|
||||||
|
--
|
||||||
|
-- The legs stand on open FLOOR: the measured ground line lands
|
||||||
|
-- two rows short of the grid (see Buildings measure), and `depth`
|
||||||
|
-- keeps the plot to the blocked cell row -- the grid's third row
|
||||||
|
-- is the walkable cell the player faces the table from, matched
|
||||||
|
-- so the flat leg art is claimed off the floor, not so the model
|
||||||
|
-- stands on it. 16 top rows onto a 16px plot map 1:1: roofBack
|
||||||
|
-- covers the whole depth, nothing cycles, and roofCycle is
|
||||||
|
-- unreachable behind it. The Poke Ball sprites ride the `table`
|
||||||
|
-- pin's height (VoxelScene.groundAt reads the collision tile, not
|
||||||
|
-- this model), so the tileset entry above overrides that height
|
||||||
|
-- to the 6px this drawing actually stands.
|
||||||
|
{
|
||||||
|
id = "lab_table",
|
||||||
|
tiles = {
|
||||||
|
{ 41, 59, 59, 59, 59, 42 },
|
||||||
|
{ 78, 57, 57, 57, 57, 79 },
|
||||||
|
{ 88, 89, 89, 89, 89, 90 },
|
||||||
|
},
|
||||||
|
roofRows = 19, roofBack = 16, roofFront = 0, roofCycle = { 2, 13 },
|
||||||
|
slab = 3, frontEave = 0, ledge = nil, depth = 2,
|
||||||
|
},
|
||||||
|
-- F02: the computer desk on the lab's west side (OAKS_LAB cell
|
||||||
|
-- 0,1) -- the one DESK-SET template: the pipeline's region
|
||||||
|
-- classification at PART granularity (see Buildings
|
||||||
|
-- deskSetModel). The desk is the sibling lab table (fascia rows
|
||||||
|
-- 16-18, base 19-21); on it stand a monitor over its keyboard
|
||||||
|
-- (left), a computer tower over a keyboard and mouse (middle),
|
||||||
|
-- and a sheet of paper LYING FLAT (right). Upright parts anchor
|
||||||
|
-- their drawn bottom row to the desk's top plane and wear their
|
||||||
|
-- own drawn tops as lids; flat parts lie one voxel proud at
|
||||||
|
-- drawn row = depth row -- the same 1:1 the tabletop itself is
|
||||||
|
-- drawn with. The roof fields are inert (roofRows = 0 keeps the
|
||||||
|
-- recess scan over the whole drawing, which is what sinks the
|
||||||
|
-- monitor's screen and the tower's slots). Same drawing, same
|
||||||
|
-- grid, stands in the Hall of Fame on the GYM atlas --
|
||||||
|
-- registered there below.
|
||||||
|
{
|
||||||
|
id = "lab_computers",
|
||||||
|
tiles = {
|
||||||
|
{ 91, 92, 93, 94 },
|
||||||
|
{ 54, 55, 85, 95 },
|
||||||
|
{ 88, 89, 89, 90 },
|
||||||
|
},
|
||||||
|
roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 },
|
||||||
|
slab = 0, frontEave = 0, ledge = nil, depth = 2,
|
||||||
|
desk = { fascia = { 16, 18 }, base = { 19, 21 } },
|
||||||
|
parts = {
|
||||||
|
{ kind = "upright", x = { 2, 13 }, top = { 0, 2 },
|
||||||
|
facade = { 3, 10 }, depth = 4 }, -- the monitor
|
||||||
|
{ kind = "flat", x = { 1, 13 }, rows = { 11, 14 } }, -- keyboard
|
||||||
|
{ kind = "upright", x = { 14, 21 }, top = { 0, 3 },
|
||||||
|
facade = { 4, 10 }, depth = 6 }, -- the tower
|
||||||
|
{ kind = "flat", x = { 14, 21 }, rows = { 11, 14 } }, -- keys+mouse
|
||||||
|
{ kind = "flat", x = { 22, 30 }, rows = { 1, 14 } }, -- the paper
|
||||||
|
},
|
||||||
|
},
|
||||||
|
-- F03: the empty north table beside it (OAKS_LAB cell 2,1): the
|
||||||
|
-- starter table's band table verbatim on a grid two tiles
|
||||||
|
-- narrower.
|
||||||
|
{
|
||||||
|
id = "lab_table_small",
|
||||||
|
tiles = {
|
||||||
|
{ 41, 59, 59, 42 },
|
||||||
|
{ 78, 57, 57, 79 },
|
||||||
|
{ 88, 89, 89, 90 },
|
||||||
|
},
|
||||||
|
roofRows = 19, roofBack = 16, roofFront = 0, roofCycle = { 2, 13 },
|
||||||
|
slab = 3, frontEave = 0, ledge = nil, depth = 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
POKECENTER = {
|
||||||
|
-- F04: the PC in every Center's northeast corner (11
|
||||||
|
-- placements; the Indigo Plateau lobby's twin is registered
|
||||||
|
-- under MART below). The lab desk-set read again: a Mac-style
|
||||||
|
-- unit drawn face-on -- white top band (rows 0-3), bezel, screen
|
||||||
|
-- and drive slot (4-14) -- standing at the back of a low desk
|
||||||
|
-- whose front face is rows 20-23; the drawn top around the unit
|
||||||
|
-- is WHITE, which is what `lid` carries. The keyboard rows 17-19
|
||||||
|
-- are drawn below the desk's 16px top span, so the flat part's
|
||||||
|
-- `z` puts it at the desk's front edge. The old billboard pins
|
||||||
|
-- for these tiles (66/70/82/86, desk 9/88) stay as the
|
||||||
|
-- degradation path -- the claim neutralizes them wherever this
|
||||||
|
-- template stamps.
|
||||||
|
{
|
||||||
|
id = "center_pc",
|
||||||
|
tiles = {
|
||||||
|
{ 66, 70 },
|
||||||
|
{ 82, 86 },
|
||||||
|
{ 9, 88 },
|
||||||
|
},
|
||||||
|
roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 },
|
||||||
|
slab = 0, frontEave = 0, ledge = nil, depth = 2,
|
||||||
|
desk = { fascia = { 20, 21 }, base = { 22, 23 }, lid = "white" },
|
||||||
|
parts = {
|
||||||
|
{ kind = "upright", x = { 2, 13 }, top = { 0, 3 },
|
||||||
|
facade = { 4, 14 }, depth = 6 }, -- the unit
|
||||||
|
{ kind = "flat", x = { 2, 13 }, rows = { 17, 19 },
|
||||||
|
z = 13 }, -- keyboard
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
MART = {
|
||||||
|
-- F04 again: the Indigo Plateau lobby's PC (cell 15,7) -- the
|
||||||
|
-- MART tileset shares the POKECENTER atlas, so this is the same
|
||||||
|
-- drawing tile for tile. Same part table as the POKECENTER entry
|
||||||
|
-- above.
|
||||||
|
{
|
||||||
|
id = "center_pc",
|
||||||
|
tiles = {
|
||||||
|
{ 66, 70 },
|
||||||
|
{ 82, 86 },
|
||||||
|
{ 9, 88 },
|
||||||
|
},
|
||||||
|
roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 },
|
||||||
|
slab = 0, frontEave = 0, ledge = nil, depth = 2,
|
||||||
|
desk = { fascia = { 20, 21 }, base = { 22, 23 }, lid = "white" },
|
||||||
|
parts = {
|
||||||
|
{ kind = "upright", x = { 2, 13 }, top = { 0, 3 },
|
||||||
|
facade = { 4, 14 }, depth = 6 }, -- the unit
|
||||||
|
{ kind = "flat", x = { 2, 13 }, rows = { 17, 19 },
|
||||||
|
z = 13 }, -- keyboard
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
GYM = {
|
||||||
|
-- F02 again: the Hall of Fame's recording machine is the lab's
|
||||||
|
-- computer desk drawing, tile for tile, on the GYM atlas (one
|
||||||
|
-- placement: HALL_OF_FAME cell 4,1). Same part table as the DOJO
|
||||||
|
-- entry above.
|
||||||
|
{
|
||||||
|
id = "lab_computers",
|
||||||
|
tiles = {
|
||||||
|
{ 91, 92, 93, 94 },
|
||||||
|
{ 54, 55, 85, 95 },
|
||||||
|
{ 88, 89, 89, 90 },
|
||||||
|
},
|
||||||
|
roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 },
|
||||||
|
slab = 0, frontEave = 0, ledge = nil, depth = 2,
|
||||||
|
desk = { fascia = { 16, 18 }, base = { 19, 21 } },
|
||||||
|
parts = {
|
||||||
|
{ kind = "upright", x = { 2, 13 }, top = { 0, 2 },
|
||||||
|
facade = { 3, 10 }, depth = 4 }, -- the monitor
|
||||||
|
{ kind = "flat", x = { 1, 13 }, rows = { 11, 14 } }, -- keyboard
|
||||||
|
{ kind = "upright", x = { 14, 21 }, top = { 0, 3 },
|
||||||
|
facade = { 4, 10 }, depth = 6 }, -- the tower
|
||||||
|
{ kind = "flat", x = { 14, 21 }, rows = { 11, 14 } }, -- keys+mouse
|
||||||
|
{ kind = "flat", x = { 22, 30 }, rows = { 1, 14 } }, -- the paper
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
-- Voxel world mode: anti-aliasing, by supersampling.
|
||||||
|
--
|
||||||
|
-- Everything else in this mod is flat art blitted at whole pixels; this one
|
||||||
|
-- pass is real geometry seen through a perspective camera, and a polygon
|
||||||
|
-- edge that lands at an angle across the pixel grid is the one place in the
|
||||||
|
-- game where a hard stair-step is not a stylistic choice. A roof ridge, a
|
||||||
|
-- ledge lip, a tree's silhouette against the sky and the leaning card of a
|
||||||
|
-- character are all cut by an edge that has no reason to line up with
|
||||||
|
-- anything, and at the shallow rungs -- where the diorama reads most like a
|
||||||
|
-- photograph of a model -- they crawl as the camera drifts.
|
||||||
|
--
|
||||||
|
-- SUPERSAMPLING, not MSAA and not a filter over the finished frame, for two
|
||||||
|
-- reasons that both come out of what the pass already is:
|
||||||
|
--
|
||||||
|
-- MSAA would take the water with it. The reflections read the frame's own
|
||||||
|
-- DEPTH buffer as a texture (Voxel3D.beginWater), and a multisampled depth
|
||||||
|
-- attachment is not a thing a fragment shader in this dialect can sample.
|
||||||
|
-- The row would have quietly switched the other row off.
|
||||||
|
--
|
||||||
|
-- An edge filter (FXAA and its relatives) works from the finished colour
|
||||||
|
-- alone, and would be GUESSING where the edges are out of one sample per
|
||||||
|
-- pixel -- inventing detail it never rendered, and unable to tell a
|
||||||
|
-- geometry edge from the boundary between two texels of a tileset.
|
||||||
|
--
|
||||||
|
-- Rendering the pass larger and folding it back down has neither problem:
|
||||||
|
-- the depth buffer stays an ordinary texture, every pass in the frame keeps
|
||||||
|
-- working in the canvas it was handed, and the fold is an average of samples
|
||||||
|
-- that were each rendered honestly. It antialiases everything at once --
|
||||||
|
-- geometry, the alpha-cut outline of a sprite card, the wireframe, the
|
||||||
|
-- water's ray march -- because none of them know it is happening.
|
||||||
|
--
|
||||||
|
-- Be clear about what "everything" means: the artwork softens too. A tileset
|
||||||
|
-- texel out here is not a screen pixel, it is a quad in a perspective view,
|
||||||
|
-- and its boundary crosses the pixel grid at the same arbitrary angle a roof
|
||||||
|
-- ridge does -- so the fold averages across it exactly as it averages across
|
||||||
|
-- the ridge. That is what an honest extra sample says about that pixel, and
|
||||||
|
-- it is also the trade the row IS: the diorama comes out smoother, not
|
||||||
|
-- sharper. Which is why this is a row and not something that is simply on.
|
||||||
|
--
|
||||||
|
-- What it costs is pixels, which is the whole of why this is a row and not
|
||||||
|
-- something that is simply on: 2X is half again as many in each direction,
|
||||||
|
-- 4X is twice, and the scene pass is the most expensive thing in the frame.
|
||||||
|
|
||||||
|
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||||
|
local V = ...
|
||||||
|
|
||||||
|
local ModSetting = V.require("ModSetting")
|
||||||
|
|
||||||
|
local AntiAlias = {}
|
||||||
|
|
||||||
|
-- the key under options.modOptions.DRAMATIC_SHAPE, shared by the row in
|
||||||
|
-- OPTIONS and the mod manager's own settings page for this mod
|
||||||
|
AntiAlias.KEY = "aa"
|
||||||
|
AntiAlias.LABEL = "AA"
|
||||||
|
|
||||||
|
-- The ladder is SAMPLES PER DISPLAY PIXEL, which is how an AA setting reads
|
||||||
|
-- everywhere else, and the canvas scale each rung costs is its square root:
|
||||||
|
-- 2 samples is a canvas 1.41x wider and taller, 4 is one exactly twice the
|
||||||
|
-- size. OFF is the default -- this is a cost knob, and a mod should not
|
||||||
|
-- quietly spend four times the fill rate of the machine it lands on.
|
||||||
|
AntiAlias.setting = ModSetting.new(AntiAlias.KEY, AntiAlias.LABEL,
|
||||||
|
{ 0, 2, 4 }, { "OFF", "2X", "4X" })
|
||||||
|
|
||||||
|
-- The scale the pass currently open was actually expanded by (see expand).
|
||||||
|
-- 1 while there is no supersampling in force, which is also what every
|
||||||
|
-- reader gets on a frame that never opened a pass at all.
|
||||||
|
local live = 1
|
||||||
|
|
||||||
|
function AntiAlias.samples()
|
||||||
|
return tonumber(AntiAlias.setting:get()) or 0
|
||||||
|
end
|
||||||
|
|
||||||
|
-- What the row ASKS for. The scale in force is `factor()`, which is this
|
||||||
|
-- clamped to what the driver will actually allocate.
|
||||||
|
local function wanted()
|
||||||
|
local n = AntiAlias.samples()
|
||||||
|
if n <= 1 then return 1 end
|
||||||
|
return math.sqrt(n)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The biggest canvas this driver admits to, or nil where it will not say.
|
||||||
|
-- A 4K window at 4X asks for 7680 across, which is past the limit on plenty
|
||||||
|
-- of hardware and every phone -- and a refused canvas is not a softer
|
||||||
|
-- diorama, it is beginScene returning false and the whole mode falling back
|
||||||
|
-- to the flat 2D path.
|
||||||
|
local function textureLimit()
|
||||||
|
if not (love.graphics and love.graphics.getSystemLimits) then return nil end
|
||||||
|
local ok, limits = pcall(love.graphics.getSystemLimits)
|
||||||
|
return (ok and limits and limits.texturesize) or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The size to render `w` x `h` display pixels at, and the size everything
|
||||||
|
-- inside the pass then measures itself in.
|
||||||
|
--
|
||||||
|
-- Also where `live` is set, which is why this must be called once per pass
|
||||||
|
-- immediately before beginScene: the wireframe's line width and the FX
|
||||||
|
-- overlay's sprite scale are both quoted in DISPLAY pixels and have to be
|
||||||
|
-- multiplied up into canvas ones, and the honest multiplier is the one this
|
||||||
|
-- returned rather than the one the row asked for.
|
||||||
|
function AntiAlias.expand(w, h)
|
||||||
|
local s = wanted()
|
||||||
|
local max = textureLimit()
|
||||||
|
if max and max > 0 then
|
||||||
|
-- clamped rather than abandoned: a window too big for 4X can usually
|
||||||
|
-- still carry some of it, and half a rung of smoothing is worth more
|
||||||
|
-- than a row that silently does nothing at that size
|
||||||
|
s = math.min(s, max / math.max(1, w), max / math.max(1, h))
|
||||||
|
end
|
||||||
|
if not (s > 1.01) then
|
||||||
|
live = 1
|
||||||
|
return w, h
|
||||||
|
end
|
||||||
|
local ew, eh = math.floor(w * s + 0.5), math.floor(h * s + 0.5)
|
||||||
|
live = ew / math.max(1, w)
|
||||||
|
return ew, eh
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The scale the open pass was expanded by; 1 when it was not.
|
||||||
|
function AntiAlias.factor()
|
||||||
|
return live
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- the fold
|
||||||
|
--
|
||||||
|
-- One target per pass (the free-roam world and the battle's arena are alive
|
||||||
|
-- at different moments but reallocating on every battle entry and exit is
|
||||||
|
-- what the scene canvas's own slots exist to avoid), reallocated only when
|
||||||
|
-- that pass's DISPLAY size changes -- a window resize, or the row itself
|
||||||
|
-- moving, which changes the source and not this.
|
||||||
|
|
||||||
|
local targets = {}
|
||||||
|
|
||||||
|
local function targetFor(slot, w, h)
|
||||||
|
local t = targets[slot]
|
||||||
|
if not (t and t.w == w and t.h == h) then
|
||||||
|
local ok, c = pcall(love.graphics.newCanvas, w, h)
|
||||||
|
if not (ok and c) then return nil end
|
||||||
|
-- nearest, like the canvas it stands in for: this one is composited a
|
||||||
|
-- canvas pixel to a display pixel, and the smoothing has already happened
|
||||||
|
pcall(c.setFilter, c, "nearest", "nearest")
|
||||||
|
if t and t.canvas and t.canvas.release then pcall(t.canvas.release, t.canvas) end
|
||||||
|
t = { canvas = c, w = w, h = h }
|
||||||
|
targets[slot] = t
|
||||||
|
end
|
||||||
|
return t.canvas
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The box filter, and the whole of why it is a shader rather than a scaled
|
||||||
|
-- draw with linear filtering on.
|
||||||
|
--
|
||||||
|
-- The void this pass renders into is cleared to a TRANSPARENT BLACK, and at
|
||||||
|
-- the rungs below FULL a good deal of the frame is still that. Averaging a
|
||||||
|
-- straight-alpha edge against it drags the result toward black as well as
|
||||||
|
-- toward transparent, and then the engine's own composite multiplies by that
|
||||||
|
-- alpha a second time -- so every silhouette against the void would come out
|
||||||
|
-- ringed with a dark fringe, which is exactly the artefact the row is here to
|
||||||
|
-- remove.
|
||||||
|
--
|
||||||
|
-- So the taps are premultiplied before they are averaged and divided back out
|
||||||
|
-- after, which is the arithmetic that makes an edge pixel mean "half covered
|
||||||
|
-- by this colour" instead of "covered by half of this colour".
|
||||||
|
--
|
||||||
|
-- Four taps, half a source texel from the destination centre. At 4X those
|
||||||
|
-- land dead on the four texel centres the destination pixel covers, so it is
|
||||||
|
-- an exact 2x2 box; at 2X the source grid does not divide, and the bilinear
|
||||||
|
-- fetch under each tap widens the box a little rather than missing samples.
|
||||||
|
local SHADER = [[
|
||||||
|
uniform vec2 tap; // half a SOURCE texel, in uv
|
||||||
|
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
|
||||||
|
vec4 a = Texel(tex, tc + vec2(-tap.x, -tap.y));
|
||||||
|
vec4 b = Texel(tex, tc + vec2( tap.x, -tap.y));
|
||||||
|
vec4 c = Texel(tex, tc + vec2(-tap.x, tap.y));
|
||||||
|
vec4 d = Texel(tex, tc + vec2( tap.x, tap.y));
|
||||||
|
float al = (a.a + b.a + c.a + d.a) * 0.25;
|
||||||
|
if (al <= 0.0) return vec4(0.0);
|
||||||
|
vec3 sum = a.rgb * a.a + b.rgb * b.a + c.rgb * c.a + d.rgb * d.a;
|
||||||
|
return vec4(sum * 0.25 / al, al) * color;
|
||||||
|
}
|
||||||
|
]]
|
||||||
|
|
||||||
|
local shader = nil -- nil = untried, false = unavailable
|
||||||
|
|
||||||
|
local function getShader()
|
||||||
|
if shader == nil then
|
||||||
|
local ok, sh = pcall(love.graphics.newShader, SHADER)
|
||||||
|
shader = (ok and sh) or false
|
||||||
|
end
|
||||||
|
return shader or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Fold `canvas` down to `w` x `h` and hand back the result.
|
||||||
|
--
|
||||||
|
-- Returns the input untouched when there is nothing to fold -- the row is
|
||||||
|
-- off, or the canvas already IS that size -- so a caller can run it
|
||||||
|
-- unconditionally, and so can a headless test run. A target that would not
|
||||||
|
-- allocate is the same answer: the pass is lost either way if this hands back
|
||||||
|
-- something the wrong size, so it hands back the input and the frame draws at
|
||||||
|
-- the size it was rendered.
|
||||||
|
function AntiAlias.resolve(canvas, w, h, slot)
|
||||||
|
if not canvas then return canvas end
|
||||||
|
local ok, cw, ch = pcall(canvas.getDimensions, canvas)
|
||||||
|
if not ok or (cw == w and ch == h) then return canvas end
|
||||||
|
local target = targetFor(slot or "world", w, h)
|
||||||
|
if not target then return canvas end
|
||||||
|
|
||||||
|
local sh = getShader()
|
||||||
|
local prevBlend, prevAlpha = love.graphics.getBlendMode()
|
||||||
|
-- the scene canvas filters nearest for its usual 1:1 blit; the taps want
|
||||||
|
-- linear, put back below so every other pass finds what it expects
|
||||||
|
pcall(canvas.setFilter, canvas, "linear", "linear")
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
-- replace, not alpha-blend: this is an image-processing copy, and the alpha
|
||||||
|
-- the shader worked out has to land as itself rather than be composited
|
||||||
|
-- against whatever the target held
|
||||||
|
love.graphics.setBlendMode("replace", "premultiplied")
|
||||||
|
if sh then
|
||||||
|
love.graphics.setShader(sh)
|
||||||
|
pcall(sh.send, sh, "tap", { 0.5 / cw, 0.5 / ch })
|
||||||
|
end
|
||||||
|
local drew = pcall(function()
|
||||||
|
love.graphics.setCanvas(target)
|
||||||
|
love.graphics.clear(0, 0, 0, 0)
|
||||||
|
love.graphics.draw(canvas, 0, 0, 0, w / cw, h / ch)
|
||||||
|
end)
|
||||||
|
love.graphics.setCanvas()
|
||||||
|
love.graphics.setShader()
|
||||||
|
love.graphics.setBlendMode(prevBlend or "alpha", prevAlpha)
|
||||||
|
pcall(canvas.setFilter, canvas, "nearest", "nearest")
|
||||||
|
return drew and target or canvas
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Drop the GPU objects (window resize, hot reload).
|
||||||
|
function AntiAlias.invalidate()
|
||||||
|
for slot, t in pairs(targets) do
|
||||||
|
if t.canvas and t.canvas.release then pcall(t.canvas.release, t.canvas) end
|
||||||
|
targets[slot] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function AntiAlias.row()
|
||||||
|
return AntiAlias.setting:row()
|
||||||
|
end
|
||||||
|
|
||||||
|
return AntiAlias
|
||||||
+224
-34
@@ -9,12 +9,76 @@
|
|||||||
-- of every eye, the highlight down a Pikachu's cheek: all of it turns into a
|
-- of every eye, the highlight down a Pikachu's cheek: all of it turns into a
|
||||||
-- hole with the world showing through, and the mon reads as a stencil.
|
-- hole with the world showing through, and the mon reads as a stencil.
|
||||||
--
|
--
|
||||||
-- So the paper is put back, and only where the paper was: the pic is read
|
-- So the paper is put back, and only where the paper was. Which pixels those
|
||||||
-- back once, the transparent region OUTSIDE the figure is flood-filled from
|
-- are is the whole problem, and it has to be ANSWERED rather than looked up:
|
||||||
-- the border, and every transparent pixel the flood could not reach -- every
|
-- the hardware drew the mon's white belly and the white field behind it with
|
||||||
-- hole enclosed by the artwork -- is filled opaque white. The silhouette is
|
-- the same shade, the decoder keyed both to the same alpha, and nothing in the
|
||||||
-- untouched, so the mon still cuts cleanly against the world; only its
|
-- image says which was which. There is no distinction to recover; there is one
|
||||||
-- insides stop being see-through.
|
-- to draw.
|
||||||
|
--
|
||||||
|
-- The rule is a flood fill from OUTSIDE the figure: whatever the background
|
||||||
|
-- can reach is background, and whatever it cannot is paper. What makes that
|
||||||
|
-- work is where the flood is allowed to start.
|
||||||
|
--
|
||||||
|
-- Start it at the image border and it fills everything and answers nothing.
|
||||||
|
-- Gen 1 figures are open drawings and a belly is not a sealed room: it walks
|
||||||
|
-- out between two legs and off the bottom of the frame. Run over all 352 of
|
||||||
|
-- this game's battle pics, that finds an enclosed hole in NONE of them -- so
|
||||||
|
-- it left every mon a stencil, which is the bug this file exists to fix and
|
||||||
|
-- for a long time did not.
|
||||||
|
--
|
||||||
|
-- So the flood is started at the edges of the artwork's own BOUNDING BOX, and
|
||||||
|
-- the left, the right and the top are seeded whole. The sky between a pair of
|
||||||
|
-- ears reaches the top edge and stays sky; the gap between a body and a raised
|
||||||
|
-- tail reaches the side and stays gap.
|
||||||
|
--
|
||||||
|
-- The BOTTOM is the interesting one, because two completely different things
|
||||||
|
-- meet the underside of a figure and they have to be told apart.
|
||||||
|
--
|
||||||
|
-- A DRAIN is where the drawing simply ran out -- a belly whose white carries
|
||||||
|
-- on down until the artist stopped, leaking to the outside through the inch
|
||||||
|
-- between a body and a leg. Seal it: what is above it is the mon.
|
||||||
|
--
|
||||||
|
-- A MOUTH is the space BETWEEN two legs, or under an arch. It is background
|
||||||
|
-- that happens to be enclosed on three sides. Leave it open: the world
|
||||||
|
-- should show through the gap in a trainer's stride.
|
||||||
|
--
|
||||||
|
-- What separates them is how WIDE the opening is, and on this game's art that
|
||||||
|
-- is not a close call. Measured along the bottom of every battle pic: the
|
||||||
|
-- drains run 3 and 4 pixels (Clefairy's back, Wartortle's back, Red's back)
|
||||||
|
-- and the mouths run 10, 12, 14 and 17 (a Rattata's underbelly, Blue's stride,
|
||||||
|
-- Brock's, a Pikachu's back). Nothing lands between 4 and 10, so the cut is
|
||||||
|
-- taken at 6 with room either side rather than tuned to a single sprite.
|
||||||
|
--
|
||||||
|
-- Apart from that one number the rule is exact: no pixel is filled for what
|
||||||
|
-- surrounds it, only because the background provably cannot get to it. And it
|
||||||
|
-- needs no idea whether it is holding a front pic, a back one or a trainer --
|
||||||
|
-- fronts are near-solid silhouettes with almost nothing inside them to fill,
|
||||||
|
-- and they come back untouched because that is what their own shape says, not
|
||||||
|
-- because they were special-cased.
|
||||||
|
--
|
||||||
|
-- The drain/mouth cut is for a pic STANDING ON THE MAP, where a mouth is a
|
||||||
|
-- real hole with real ground behind it. A pic PINNED TO THE MENU has no such
|
||||||
|
-- hole to be: under BACK SPRITES the player's mon is drawn in the GB's own
|
||||||
|
-- slot with its feet flush on the text box (BattleState.backPlacement pins
|
||||||
|
-- row 96), so the only thing under its lowest row is white box. Nothing can
|
||||||
|
-- reach it from below, whatever the opening's width, and the caller says so
|
||||||
|
-- by asking for a SEALED BOTTOM -- for which the rule stops being a heuristic
|
||||||
|
-- and becomes exact: paper is whatever the background cannot walk to from the
|
||||||
|
-- left, the right or the top.
|
||||||
|
--
|
||||||
|
-- That is the difference between a Pikachu that reads as a mon and one that
|
||||||
|
-- reads as wireframe. The pale-bodied back pics -- Pikachu, Seel, Dewgong,
|
||||||
|
-- Chansey, Jigglypuff -- are drawn as OUTLINES: everything inside the ink is
|
||||||
|
-- shade 0 and every one of them is keyed away, so the figure is a rim with the
|
||||||
|
-- arena showing through it. Each one also has a wide opening along its bottom,
|
||||||
|
-- which the drain cut correctly reads as a mouth and the sealed bottom
|
||||||
|
-- correctly does not. Twelve of this game's 151 back pics turn on it; the
|
||||||
|
-- other 139 come back byte-identical either way, because they had nothing
|
||||||
|
-- under them the flood was getting in through.
|
||||||
|
--
|
||||||
|
-- The silhouette is untouched, so the mon still cuts cleanly against the
|
||||||
|
-- world; only its insides stop being see-through.
|
||||||
--
|
--
|
||||||
-- Read back off the GPU rather than off the asset, deliberately. What comes
|
-- Read back off the GPU rather than off the asset, deliberately. What comes
|
||||||
-- back is the pic the engine actually decided to draw -- species palette,
|
-- back is the pic the engine actually decided to draw -- species palette,
|
||||||
@@ -27,14 +91,23 @@ local V = ...
|
|||||||
|
|
||||||
local BattlePics = {}
|
local BattlePics = {}
|
||||||
|
|
||||||
-- Cached by the image the engine handed over. Weak keys, so a pic that goes
|
-- Cached by the image the engine handed over, one table per bottom rule --
|
||||||
-- out of scope takes its filled twin with it rather than pinning a texture
|
-- the same pic answers differently sealed and unsealed, and a single table
|
||||||
-- for the session.
|
-- would hand the wrong twin back to whichever caller asked second. Weak keys,
|
||||||
local cache = setmetatable({}, { __mode = "k" })
|
-- so a pic that goes out of scope takes its filled twin with it rather than
|
||||||
|
-- pinning a texture for the session.
|
||||||
|
local function newCache()
|
||||||
|
return {
|
||||||
|
[false] = setmetatable({}, { __mode = "k" }),
|
||||||
|
[true] = setmetatable({}, { __mode = "k" }),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
local cache = newCache()
|
||||||
|
|
||||||
-- What an enclosed hole is filled with. White, because white is what the
|
-- What an enclosed hole is filled with when the pic itself offers nothing
|
||||||
-- battle field was: this restores the pixel the artist drew and the engine
|
-- better. White, because white is what the battle field was: this restores the
|
||||||
-- then keyed away, it does not invent a new one.
|
-- pixel the artist drew and the engine then keyed away, it does not invent a
|
||||||
|
-- new one.
|
||||||
BattlePics.FILL = { 1, 1, 1, 1 }
|
BattlePics.FILL = { 1, 1, 1, 1 }
|
||||||
|
|
||||||
-- Anything at or under this alpha counts as keyed-out rather than drawn.
|
-- Anything at or under this alpha counts as keyed-out rather than drawn.
|
||||||
@@ -44,6 +117,21 @@ local CUT = 0.5
|
|||||||
-- its data back, so it is drawn into a canvas of its own size and the canvas
|
-- 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
|
-- is read -- which is also what makes this work for every path that produces
|
||||||
-- a pic, without knowing which one produced this one.
|
-- 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 function readBack(img)
|
||||||
local w, h = img:getDimensions()
|
local w, h = img:getDimensions()
|
||||||
if w <= 0 or h <= 0 then return nil end
|
if w <= 0 or h <= 0 then return nil end
|
||||||
@@ -52,7 +140,7 @@ local function readBack(img)
|
|||||||
local prevR, prevG, prevB, prevA = love.graphics.getColor()
|
local prevR, prevG, prevB, prevA = love.graphics.getColor()
|
||||||
local data = nil
|
local data = nil
|
||||||
local ok = pcall(function()
|
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.setCanvas(canvas)
|
||||||
love.graphics.clear(0, 0, 0, 0)
|
love.graphics.clear(0, 0, 0, 0)
|
||||||
love.graphics.setBlendMode("replace", "premultiplied")
|
love.graphics.setBlendMode("replace", "premultiplied")
|
||||||
@@ -72,32 +160,120 @@ local function readBack(img)
|
|||||||
return ok and data or nil
|
return ok and data or nil
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Mark every transparent pixel reachable from the border. That set is the
|
-- The box the artwork actually occupies, or nil for a pic with no ink in it.
|
||||||
-- OUTSIDE; everything transparent it does not reach is an enclosed hole.
|
--
|
||||||
|
-- Not the image: a pic is centred in a 7x7-tile buffer and a small mon leaves
|
||||||
|
-- whole rows and columns of nothing around itself. The bottom of THIS box is
|
||||||
|
-- the cut the rule below turns on, and the bottom of the image is just empty
|
||||||
|
-- frame some distance under it.
|
||||||
|
local function inkBounds(data, w, h)
|
||||||
|
local x0, y0, x1, y1 = w, h, -1, -1
|
||||||
|
for y = 0, h - 1 do
|
||||||
|
for x = 0, w - 1 do
|
||||||
|
local _, _, _, a = data:getPixel(x, y)
|
||||||
|
if a > CUT then
|
||||||
|
if x < x0 then x0 = x end
|
||||||
|
if x > x1 then x1 = x end
|
||||||
|
if y < y0 then y0 = y end
|
||||||
|
if y > y1 then y1 = y end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if x1 < x0 then return nil end
|
||||||
|
return x0, y0, x1, y1
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The colour the keyed-away shade would have had: the LIGHTEST colour still
|
||||||
|
-- standing in the pic.
|
||||||
|
--
|
||||||
|
-- Pure white is only the right answer while the pic is still grays, and by the
|
||||||
|
-- time it reaches here it usually is not. picImage hands a pic over AFTER the
|
||||||
|
-- bake -- a species SGB colour, a BGP fade mid-animation, PAL_BLACK for the
|
||||||
|
-- whole screen while the blackout text is up -- and shade 0 travels with the
|
||||||
|
-- rest. A white belly inside a blacked-out mon would be the one lit thing on a
|
||||||
|
-- dark screen; inside a warm-palette mon it would be a cold patch the artist
|
||||||
|
-- never drew.
|
||||||
|
--
|
||||||
|
-- So the paper is read off the pic rather than assumed, which needs shade 0 to
|
||||||
|
-- have survived somewhere in it. It always has: every one of this game's 151
|
||||||
|
-- back pics keeps at least one opaque shade-0 pixel -- a highlight down a
|
||||||
|
-- cheek, the white of an eye -- because only the shade-0 pixels the decoder
|
||||||
|
-- could reach were keyed. So what comes back is the baked shade 0 itself, not
|
||||||
|
-- an approximation of it, and it tracks every palette the engine picks without
|
||||||
|
-- being told which one that was.
|
||||||
|
--
|
||||||
|
-- Ranked by channel sum, which orders four DMG shades exactly: a palette maps
|
||||||
|
-- all three channels monotonically, so lightest by sum is lightest full stop.
|
||||||
|
local function paperColor(data, x0, y0, x1, y1)
|
||||||
|
local best, pr, pg, pb = -1, nil, nil, nil
|
||||||
|
for y = y0, y1 do
|
||||||
|
for x = x0, x1 do
|
||||||
|
local r, g, b, a = data:getPixel(x, y)
|
||||||
|
if a > CUT then
|
||||||
|
local lum = r + g + b
|
||||||
|
if lum > best then best, pr, pg, pb = lum, r, g, b end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if best < 0 then return nil end
|
||||||
|
return pr, pg, pb
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The widest opening along the bottom of a figure that still counts as a drain
|
||||||
|
-- rather than a mouth. See the header for the measurements either side of it.
|
||||||
|
BattlePics.DRAIN = 6
|
||||||
|
|
||||||
|
-- Mark every transparent pixel the BACKGROUND can reach, flooding inward from
|
||||||
|
-- the edges of the artwork's box: the left, the right and the top whole, and
|
||||||
|
-- along the bottom only those openings wide enough to be background rather
|
||||||
|
-- than the underside of a figure the drawing ran out of -- or none of them at
|
||||||
|
-- all, for a pic whose feet are on the text box and which therefore has
|
||||||
|
-- nothing behind its lowest row to let in.
|
||||||
|
--
|
||||||
|
-- Confined to the box as well as seeded from it, so the empty frame under a
|
||||||
|
-- short pic cannot walk around a sealed drain and come back up through it.
|
||||||
--
|
--
|
||||||
-- An explicit stack rather than recursion: a 56x56 pic is three thousand
|
-- An explicit stack rather than recursion: a 56x56 pic is three thousand
|
||||||
-- pixels and a keyed-out background is most of them, which is a deeper call
|
-- pixels and a keyed-out background is most of them, which is a deeper call
|
||||||
-- chain than is worth risking for no gain.
|
-- chain than is worth risking for no gain.
|
||||||
local function markOutside(data, w, h)
|
local function markOutside(data, w, h, x0, y0, x1, y1, sealBottom)
|
||||||
local outside = {}
|
local outside = {}
|
||||||
local stack, top = {}, 0
|
local stack, top = {}, 0
|
||||||
|
local function clear(x, y)
|
||||||
|
local _, _, _, a = data:getPixel(x, y)
|
||||||
|
return a <= CUT
|
||||||
|
end
|
||||||
local function push(x, y)
|
local function push(x, y)
|
||||||
if x < 0 or y < 0 or x >= w or y >= h then return end
|
if x < x0 or y < y0 or x > x1 or y > y1 then return end
|
||||||
local key = y * w + x
|
local key = y * w + x
|
||||||
if outside[key] then return end
|
if outside[key] then return end
|
||||||
local _, _, _, a = data:getPixel(x, y)
|
if not clear(x, y) then return end
|
||||||
if a > CUT then return end
|
|
||||||
outside[key] = true
|
outside[key] = true
|
||||||
top = top + 1
|
top = top + 1
|
||||||
stack[top] = key
|
stack[top] = key
|
||||||
end
|
end
|
||||||
for x = 0, w - 1 do
|
for x = x0, x1 do push(x, y0) end
|
||||||
push(x, 0)
|
for y = y0, y1 do
|
||||||
push(x, h - 1)
|
push(x0, y)
|
||||||
|
push(x1, y)
|
||||||
end
|
end
|
||||||
for y = 0, h - 1 do
|
-- the bottom, run by run: a wide one is the gap between two legs and lets
|
||||||
push(0, y)
|
-- the world through, a narrow one is where a belly ran out and is sealed.
|
||||||
push(w - 1, y)
|
-- Skipped whole for a pic on the box, where even the widest of them has
|
||||||
|
-- white paper behind it rather than arena.
|
||||||
|
if not sealBottom then
|
||||||
|
local x = x0
|
||||||
|
while x <= x1 do
|
||||||
|
if clear(x, y1) then
|
||||||
|
local from = x
|
||||||
|
while x <= x1 and clear(x, y1) do x = x + 1 end
|
||||||
|
if (x - from) > BattlePics.DRAIN then
|
||||||
|
for k = from, x - 1 do push(k, y1) end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
x = x + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
while top > 0 do
|
while top > 0 do
|
||||||
local key = stack[top]
|
local key = stack[top]
|
||||||
@@ -114,9 +290,15 @@ end
|
|||||||
-- The pic with its enclosed holes filled, or the pic itself when that could
|
-- The pic with its enclosed holes filled, or the pic itself when that could
|
||||||
-- not be done (no pixel access, a driver that refused the readback). Never
|
-- not be done (no pixel access, a driver that refused the readback). Never
|
||||||
-- nil for a non-nil argument: a caller must always have something to draw.
|
-- nil for a non-nil argument: a caller must always have something to draw.
|
||||||
function BattlePics.filled(img)
|
--
|
||||||
|
-- sealBottom for a pic pinned to the text box rather than standing on the map:
|
||||||
|
-- see the header. A caller that does not say defaults to the map, which is
|
||||||
|
-- where all but one of this mod's pics are.
|
||||||
|
function BattlePics.filled(img, sealBottom)
|
||||||
if not img then return img end
|
if not img then return img end
|
||||||
local hit = cache[img]
|
sealBottom = sealBottom and true or false
|
||||||
|
local slot = cache[sealBottom]
|
||||||
|
local hit = slot[img]
|
||||||
if hit ~= nil then return hit or img end
|
if hit ~= nil then return hit or img end
|
||||||
|
|
||||||
local made = nil
|
local made = nil
|
||||||
@@ -124,16 +306,24 @@ function BattlePics.filled(img)
|
|||||||
local data = readBack(img)
|
local data = readBack(img)
|
||||||
if not data then return end
|
if not data then return end
|
||||||
local w, h = data:getDimensions()
|
local w, h = data:getDimensions()
|
||||||
local outside = markOutside(data, w, h)
|
local x0, y0, x1, y1 = inkBounds(data, w, h)
|
||||||
|
if not x0 then return end -- a pic with nothing drawn in it
|
||||||
|
local outside = markOutside(data, w, h, x0, y0, x1, y1, sealBottom)
|
||||||
local fill = BattlePics.FILL
|
local fill = BattlePics.FILL
|
||||||
|
local pr, pg, pb = paperColor(data, x0, y0, x1, y1)
|
||||||
|
local fr = pr or fill[1]
|
||||||
|
local fg = pg or fill[2]
|
||||||
|
local fb = pb or fill[3]
|
||||||
local changed = false
|
local changed = false
|
||||||
for y = 0, h - 1 do
|
-- only inside the box: everything beyond it is frame the artist never
|
||||||
|
-- reached, and filling that would put the mon in a white rectangle
|
||||||
|
for y = y0, y1 do
|
||||||
local row = y * w
|
local row = y * w
|
||||||
for x = 0, w - 1 do
|
for x = x0, x1 do
|
||||||
if not outside[row + x] then
|
if not outside[row + x] then
|
||||||
local _, _, _, a = data:getPixel(x, y)
|
local _, _, _, a = data:getPixel(x, y)
|
||||||
if a <= CUT then
|
if a <= CUT then
|
||||||
data:setPixel(x, y, fill[1], fill[2], fill[3], fill[4])
|
data:setPixel(x, y, fr, fg, fb, fill[4])
|
||||||
changed = true
|
changed = true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -146,12 +336,12 @@ function BattlePics.filled(img)
|
|||||||
made = out
|
made = out
|
||||||
end)
|
end)
|
||||||
|
|
||||||
cache[img] = (ok and made) or false
|
slot[img] = (ok and made) or false
|
||||||
return made or img
|
return made or img
|
||||||
end
|
end
|
||||||
|
|
||||||
function BattlePics.invalidate()
|
function BattlePics.invalidate()
|
||||||
cache = setmetatable({}, { __mode = "k" })
|
cache = newCache()
|
||||||
end
|
end
|
||||||
|
|
||||||
return BattlePics
|
return BattlePics
|
||||||
|
|||||||
+81
-8
@@ -42,6 +42,7 @@ local BattleCam = V.require("BattleCam")
|
|||||||
local BattleBillboard = V.require("BattleBillboard")
|
local BattleBillboard = V.require("BattleBillboard")
|
||||||
local VoxelGrid = V.require("VoxelGrid")
|
local VoxelGrid = V.require("VoxelGrid")
|
||||||
local DayNight = V.require("DayNight")
|
local DayNight = V.require("DayNight")
|
||||||
|
local AntiAlias = V.require("AntiAlias")
|
||||||
local PaletteFX = require("src.render.PaletteFX")
|
local PaletteFX = require("src.render.PaletteFX")
|
||||||
local Map = require("src.world.Map")
|
local Map = require("src.world.Map")
|
||||||
|
|
||||||
@@ -141,9 +142,10 @@ local function prefetchArena(state, host)
|
|||||||
for _, nb in ipairs(state.neighbors or {}) do live[nb.map.id] = true end
|
for _, nb in ipairs(state.neighbors or {}) do live[nb.map.id] = true end
|
||||||
ChunkMesher.setLive(live)
|
ChunkMesher.setLive(live)
|
||||||
TerrainAtlas.setLive(live)
|
TerrainAtlas.setLive(live)
|
||||||
local terrain = ChunkMesher.request(host, false, nil, true)
|
ChunkMesher.request(host, false, nil, true)
|
||||||
or ChunkMesher.peek(host, true)
|
local terrain, water = ChunkMesher.pair(host, false)
|
||||||
return terrain, {}
|
if not terrain then terrain, water = ChunkMesher.pair(host, true) end
|
||||||
|
return terrain, {}, water, {}
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ------- the sun
|
-- ------- the sun
|
||||||
@@ -227,7 +229,8 @@ local function shadowSignature(state, arena, terrain, nbMesh, token)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh,
|
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
|
if not ShadowMap.available() then return end
|
||||||
local sig = shadowSignature(state, arena, terrain, nbMesh, token)
|
local sig = shadowSignature(state, arena, terrain, nbMesh, token)
|
||||||
if not ShadowMap.stale(sig) then return end
|
if not ShadowMap.stale(sig) then return end
|
||||||
@@ -237,6 +240,14 @@ local function castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh,
|
|||||||
for i, nb in ipairs(neighbors) do
|
for i, nb in ipairs(neighbors) do
|
||||||
ShadowMap.draw(nbMesh[i], atlasFor(nb.map), Mat4.translate(nb.ox, 0, nb.oy))
|
ShadowMap.draw(nbMesh[i], atlasFor(nb.map), Mat4.translate(nb.ox, 0, nb.oy))
|
||||||
end
|
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
|
-- thin cards are snugged toward the sun (ShadowMap.snug) so their shadows
|
||||||
-- keep contact with their bases instead of starting a bias-width away
|
-- keep contact with their bases instead of starting a bias-width away
|
||||||
ShadowMap.draw(ChunkMesher.flowers(host), atlasFor(host),
|
ShadowMap.draw(ChunkMesher.flowers(host), atlasFor(host),
|
||||||
@@ -249,10 +260,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
|
-- 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
|
-- is the silhouette, so what lands on the ground is the shape of the
|
||||||
-- Pokemon rather than a blob standing in for one.
|
-- 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
|
for _, card in ipairs(cards or {}) do
|
||||||
ShadowMap.draw(BattleBillboard.mesh(), card.tex,
|
ShadowMap.draw(BattleBillboard.mesh(), card.tex,
|
||||||
ShadowMap.snug(card.model))
|
ShadowMap.snug(card.model))
|
||||||
end
|
end
|
||||||
|
ShadowMap.sprites(false)
|
||||||
|
|
||||||
ShadowMap.finish(sig)
|
ShadowMap.finish(sig)
|
||||||
end
|
end
|
||||||
@@ -299,9 +315,36 @@ end
|
|||||||
BattleScene.FLASH_COLOR = { 1, 1, 1 }
|
BattleScene.FLASH_COLOR = { 1, 1, 1 }
|
||||||
BattleScene.FLASH_STRENGTH = 0.5
|
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)
|
function BattleScene.render(state, arena, textures, token)
|
||||||
if not (state and state.map and arena) then return nil end
|
if not (state and state.map and arena) then return nil end
|
||||||
if not Voxel3D.available() 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
|
-- the floor the fight is staged on: normally the player's own, sometimes
|
||||||
-- another floor of the same cave or building (see BattleArena)
|
-- another floor of the same cave or building (see BattleArena)
|
||||||
@@ -326,7 +369,7 @@ function BattleScene.render(state, arena, textures, token)
|
|||||||
|
|
||||||
-- shares the free-roam mode's request/evict bookkeeping, so a battle warms
|
-- shares the free-roam mode's request/evict bookkeeping, so a battle warms
|
||||||
-- exactly the meshes walking around would have and nothing extra
|
-- 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
|
if not terrain then return nil end
|
||||||
|
|
||||||
local lx, ly, s, pw, ph = BattleScene.letterbox()
|
local lx, ly, s, pw, ph = BattleScene.letterbox()
|
||||||
@@ -356,7 +399,7 @@ function BattleScene.render(state, arena, textures, token)
|
|||||||
local cards = monCards(arena, groundY, textures)
|
local cards = monCards(arena, groundY, textures)
|
||||||
Voxel3D.camera = nil
|
Voxel3D.camera = nil
|
||||||
castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh, atlasFor,
|
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
|
-- 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
|
-- horizon is genuinely in frame, so it is sky; indoors it is the dark end
|
||||||
@@ -385,7 +428,16 @@ function BattleScene.render(state, arena, textures, token)
|
|||||||
-- its own canvas slot: this renders at the window's pixel size and the
|
-- its own canvas slot: this renders at the window's pixel size and the
|
||||||
-- free-roam pass does too, but the two are alive at different moments
|
-- free-roam pass does too, but the two are alive at different moments
|
||||||
-- and a shared slot would reallocate on every battle entry and exit
|
-- and a shared slot would reallocate on every battle entry and exit
|
||||||
if not Voxel3D.beginScene(pw, ph, cx, cy, vw, vh, sky, "battle") then
|
--
|
||||||
|
-- AA, if the row asks for it, renders it larger still and folds it back
|
||||||
|
-- to pw x ph below (see AntiAlias). The framing is untouched by that:
|
||||||
|
-- the lens was widened by the window's RATIO to the letterbox and the
|
||||||
|
-- rig solved in the GB's own frame, so a bigger canvas is more samples
|
||||||
|
-- of the identical shot -- which is why the pins below still measure in
|
||||||
|
-- pw and ph, and why the HUDs and the depth of field, drawn onto the
|
||||||
|
-- folded canvas afterwards, stay the chunky GB art they are.
|
||||||
|
local rw, rh = AntiAlias.expand(pw, ph)
|
||||||
|
if not Voxel3D.beginScene(rw, rh, cx, cy, vw, vh, sky, "battle") then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
Voxel3D.draw(terrain, atlasFor(host), nil)
|
Voxel3D.draw(terrain, atlasFor(host), nil)
|
||||||
@@ -393,6 +445,22 @@ function BattleScene.render(state, arena, textures, token)
|
|||||||
Voxel3D.draw(nbMesh[i], atlasFor(nb.map),
|
Voxel3D.draw(nbMesh[i], atlasFor(nb.map),
|
||||||
Mat4.translate(nb.ox, 0, nb.oy))
|
Mat4.translate(nb.ox, 0, nb.oy))
|
||||||
end
|
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,
|
-- 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
|
-- 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
|
-- front of it, and the alpha discard cuts the sprite's own outline out of
|
||||||
@@ -442,7 +510,7 @@ function BattleScene.render(state, arena, textures, token)
|
|||||||
Mat4.translate(nb.ox, 0, nb.oy), fpull,
|
Mat4.translate(nb.ox, 0, nb.oy), fpull,
|
||||||
ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy)))
|
ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy)))
|
||||||
end
|
end
|
||||||
local canvas = Voxel3D.endScene()
|
local canvas = AntiAlias.resolve(Voxel3D.endScene(), pw, ph, "battle")
|
||||||
if not canvas then return end
|
if not canvas then return end
|
||||||
|
|
||||||
local vp = Voxel3D.vp
|
local vp = Voxel3D.vp
|
||||||
@@ -473,6 +541,11 @@ function BattleScene.render(state, arena, textures, token)
|
|||||||
-- the letterbox, so the depth-of-field pass can put its sharp band on
|
-- the letterbox, so the depth-of-field pass can put its sharp band on
|
||||||
-- the two marks rather than on a fraction of the window
|
-- the two marks rather than on a fraction of the window
|
||||||
lx = lx, ly = ly, scale = s, pw = pw, ph = ph,
|
lx = lx, ly = ly, scale = s, pw = pw, ph = ph,
|
||||||
|
-- and the hour's light, for anything drawn over this shot that is NOT
|
||||||
|
-- geometry and so never went past the shader that applied it -- the back
|
||||||
|
-- pic pinned to the menu (see OverworldBattle.backPinned). Neutral
|
||||||
|
-- indoors, which is what DayNight.tint answers for a room.
|
||||||
|
tint = Voxel3D.tint,
|
||||||
}
|
}
|
||||||
end)
|
end)
|
||||||
-- the placed camera is ours for exactly this pass; anything else that
|
-- the placed camera is ours for exactly this pass; anything else that
|
||||||
|
|||||||
+180
-6
@@ -190,7 +190,24 @@ local function measure(sp, t)
|
|||||||
top[x] = r
|
top[x] = r
|
||||||
end
|
end
|
||||||
|
|
||||||
local wallH = H - roofRows
|
-- The drawing's own ground line: the row after the last drawn one. A
|
||||||
|
-- building ends on the black threshold row it stands on (ground == H),
|
||||||
|
-- but furniture is drawn standing on open floor -- the lab table's
|
||||||
|
-- legs stop two rows short of its grid -- and extruding against H
|
||||||
|
-- would float it that far above its own plot.
|
||||||
|
local ground = roofRows
|
||||||
|
for sy = H - 1, roofRows, -1 do
|
||||||
|
local drawn = false
|
||||||
|
for sx = 0, W - 1 do
|
||||||
|
if sp.inside[sy * W + sx] then drawn = true break end
|
||||||
|
end
|
||||||
|
if drawn then
|
||||||
|
ground = sy + 1
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local wallH = ground - roofRows
|
||||||
local ytop = wallH - 1 + t.slab
|
local ytop = wallH - 1 + t.slab
|
||||||
|
|
||||||
-- Side faces must not come out as slabs of outline black: where the
|
-- Side faces must not come out as slabs of outline black: where the
|
||||||
@@ -279,20 +296,175 @@ local function measure(sp, t)
|
|||||||
-- sprite taller than its footprint -- the tower's 16-row drawing
|
-- sprite taller than its footprint -- the tower's 16-row drawing
|
||||||
-- stands on the 8 rows of it that are actually on the map, and D = H
|
-- stands on the 8 rows of it that are actually on the map, and D = H
|
||||||
-- would have pushed its body 64px south into the town plaza.
|
-- would have pushed its body 64px south into the town plaza.
|
||||||
return { top = top, ytop = ytop, D = #t.tiles * 8,
|
-- `depth` (in tile rows) names the plot when the grid runs PAST it
|
||||||
|
-- onto ground the drawing merely stands its legs on: the lab table's
|
||||||
|
-- third row is the walkable cell the player faces it from, and the
|
||||||
|
-- full-grid depth would stand the model in their path.
|
||||||
|
return { top = top, ytop = ytop, D = (t.depth or #t.tiles) * 8,
|
||||||
|
ground = ground,
|
||||||
recess = recess, interior = interior, shadeTexel = shadeTexel }
|
recess = recess, interior = interior, shadeTexel = shadeTexel }
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ----------------------------------------------------------------- build --
|
-- ----------------------------------------------------------------- build --
|
||||||
|
|
||||||
|
-- A desk with separately-classified objects on it (a template's `parts`
|
||||||
|
-- list): the methodology's region classification at part granularity.
|
||||||
|
-- Upright parts anchor their drawn bottom row to the desk's top plane
|
||||||
|
-- and wear their own drawn tops as lids; flat parts (a keyboard, a
|
||||||
|
-- sheet of paper) lie one voxel proud at drawn row = depth row -- the
|
||||||
|
-- same 1:1 the tabletop itself is drawn with, so an object's height ON
|
||||||
|
-- the drawing is its position ON the desk. The desk is the lab-table
|
||||||
|
-- slab + base; its lid is the one synthesized surface in the model
|
||||||
|
-- (the objects cover every drawn pixel of the tabletop), continued
|
||||||
|
-- from the sibling tables' pattern in the drawing's own shades.
|
||||||
|
-- tools/building_voxels.py `build_desk_set` is the reference twin.
|
||||||
|
local function deskSetModel(sp, pr, t)
|
||||||
|
local W, H, D = sp.W, sp.H, pr.D
|
||||||
|
local ground = pr.ground
|
||||||
|
local col, inside = sp.col, sp.inside
|
||||||
|
local vox = {}
|
||||||
|
local function key(x, y, z) return (y * D + z) * W + x end
|
||||||
|
local function put(x, y, z, i) vox[key(x, y, z)] = i end
|
||||||
|
|
||||||
|
-- de-outline walk bounded to the part, so a part's side faces show
|
||||||
|
-- its own material and never the neighbour's (the sprite-wide walk
|
||||||
|
-- the facade path uses would cross the black seam between units)
|
||||||
|
local function interiorAt(sx, sy, lo, hi)
|
||||||
|
local i = sy * W + sx
|
||||||
|
if col[i] ~= BLACK then return sx end
|
||||||
|
local step = sx < math.floor((lo + hi) / 2) and 1 or -1
|
||||||
|
for d = 1, 3 do
|
||||||
|
local nx = sx + step * d
|
||||||
|
if nx >= lo and nx <= hi then
|
||||||
|
local ni = sy * W + nx
|
||||||
|
if inside[ni] and col[ni] ~= BLACK then return nx end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return sx
|
||||||
|
end
|
||||||
|
|
||||||
|
local f0, f1 = t.desk.fascia[1], t.desk.fascia[2]
|
||||||
|
local b0, b1 = t.desk.base[1], t.desk.base[2]
|
||||||
|
local plane = (b1 - b0 + 1) + (f1 - f0 + 1)
|
||||||
|
|
||||||
|
-- the base band, extruded exactly like every lab table's
|
||||||
|
for sy = b0, b1 do
|
||||||
|
Budget.tick()
|
||||||
|
local y = ground - 1 - sy
|
||||||
|
for sx = 0, W - 1 do
|
||||||
|
if inside[sy * W + sx] then
|
||||||
|
local ix = interiorAt(sx, sy, 0, W - 1)
|
||||||
|
for z = 0, D - 1 do
|
||||||
|
local px = (z == 0 or z == D - 1) and sx or ix
|
||||||
|
put(sx, y, z, sy * W + px)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for i in pairs(pr.recess) do
|
||||||
|
local sy = math.floor(i / W)
|
||||||
|
if sy >= b0 and sy <= b1 then
|
||||||
|
vox[key(i % W, ground - 1 - sy, D - 1)] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- the slab: fascia rows wrap every side; the lid continues the
|
||||||
|
-- sibling tables' top -- black rim, white highlight courses along
|
||||||
|
-- the north and west, grey field
|
||||||
|
for sy = f0, f1 do
|
||||||
|
Budget.tick()
|
||||||
|
local y = plane - 1 - (sy - f0)
|
||||||
|
for sx = 0, W - 1 do
|
||||||
|
for z = 0, D - 1 do put(sx, y, z, sy * W + sx) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local field = t.desk.lid == "white" and WHITE or GREY
|
||||||
|
for sx = 0, W - 1 do
|
||||||
|
for z = 0, D - 1 do
|
||||||
|
local shade = field
|
||||||
|
if sx == 0 or sx == W - 1 or z == 0 or z == D - 1 then
|
||||||
|
shade = BLACK
|
||||||
|
elseif sx == 1 or z == 1 then
|
||||||
|
shade = WHITE
|
||||||
|
end
|
||||||
|
put(sx, plane - 1, z, pr.shadeTexel[shade])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local ytop = plane
|
||||||
|
for _, p in ipairs(t.parts) do
|
||||||
|
Budget.tick()
|
||||||
|
local x0, x1 = p.x[1], p.x[2]
|
||||||
|
if p.kind == "flat" then
|
||||||
|
-- drawn row = depth row by default; `z` renames the origin when
|
||||||
|
-- the flat sits below the desk's own drawn top span (the Center
|
||||||
|
-- PC's keyboard)
|
||||||
|
local r0 = p.rows[1]
|
||||||
|
local z0 = p.z or r0
|
||||||
|
for sy = r0, p.rows[2] do
|
||||||
|
local z = z0 + (sy - r0)
|
||||||
|
if z >= 0 and z < D then
|
||||||
|
for sx = x0, x1 do
|
||||||
|
if inside[sy * W + sx] then put(sx, plane, z, sy * W + sx) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
local tr0, tr1 = p.top[1], p.top[2]
|
||||||
|
local fr0, fr1 = p.facade[1], p.facade[2]
|
||||||
|
local pd = p.depth
|
||||||
|
local ytp = plane + (fr1 - fr0)
|
||||||
|
if ytp > ytop then ytop = ytp end
|
||||||
|
for sx = x0, x1 do
|
||||||
|
-- the lid: the part's drawn top laid across its depth from the
|
||||||
|
-- back, last row continuing forward; the front lid row is the
|
||||||
|
-- facade's own top row -- the drawn front-top edge
|
||||||
|
for z = 0, pd - 1 do
|
||||||
|
local front = z == pd - 1
|
||||||
|
local sy = front and fr0 or math.min(tr0 + z, tr1)
|
||||||
|
while sy <= tr1 and not inside[sy * W + sx] do sy = sy + 1 end
|
||||||
|
local ok = sy <= tr1 or (front and inside[fr0 * W + sx])
|
||||||
|
if ok then
|
||||||
|
put(sx, ytp, z, (front and fr0 or sy) * W + sx)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- the body: facade rows anchored to the desk's top plane
|
||||||
|
for sy = fr0 + 1, fr1 do
|
||||||
|
local y = plane + (fr1 - sy)
|
||||||
|
local i = sy * W + sx
|
||||||
|
if inside[i] then
|
||||||
|
local ix = interiorAt(sx, sy, x0, x1)
|
||||||
|
for z = 0, pd - 1 do
|
||||||
|
if z == pd - 1 then
|
||||||
|
if not pr.recess[i] then put(sx, y, z, i) end
|
||||||
|
elseif z == 0 then
|
||||||
|
put(sx, y, z, i)
|
||||||
|
else
|
||||||
|
put(sx, y, z, sy * W + ix)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return { at = function(x, y, z)
|
||||||
|
if x < 0 or x >= W or y < 0 or z < 0 or z >= D then return nil end
|
||||||
|
return vox[key(x, y, z)]
|
||||||
|
end,
|
||||||
|
W = W, ytop = ytop, zmin = 0, zmax = D - 1 }
|
||||||
|
end
|
||||||
|
|
||||||
-- The voxel model as a lookup: `at(x, y, z)` is the index of the sprite
|
-- The voxel model as a lookup: `at(x, y, z)` is the index of the sprite
|
||||||
-- pixel that voxel wears, or nil. Build ORDER is expressed as lookup
|
-- pixel that voxel wears, or nil. Build ORDER is expressed as lookup
|
||||||
-- order -- roof first, so it overwrites the walls it intersects, and walls
|
-- order -- roof first, so it overwrites the walls it intersects, and walls
|
||||||
-- are trimmed to its underside so nothing pokes through the surface.
|
-- are trimmed to its underside so nothing pokes through the surface.
|
||||||
local function model(sp, pr, t)
|
local function model(sp, pr, t)
|
||||||
|
if t.parts then return deskSetModel(sp, pr, t) end
|
||||||
local W, H, D = sp.W, sp.H, pr.D
|
local W, H, D = sp.W, sp.H, pr.D
|
||||||
local slab, roofRows = t.slab, t.roofRows
|
local slab, roofRows = t.slab, t.roofRows
|
||||||
local top, ytop = pr.top, pr.ytop
|
local top, ytop, ground = pr.top, pr.ytop, pr.ground
|
||||||
|
|
||||||
-- The roof's drawn span. A sprite inset from its box (B03) leaves outer
|
-- The roof's drawn span. A sprite inset from its box (B03) leaves outer
|
||||||
-- columns undrawn in the roof band; they carry no roof at all, and the
|
-- columns undrawn in the roof band; they carry no roof at all, and the
|
||||||
@@ -367,16 +539,18 @@ local function model(sp, pr, t)
|
|||||||
|
|
||||||
-- the awning: the band juts two voxels past the walls, front and back
|
-- the awning: the band juts two voxels past the walls, front and back
|
||||||
if ledge0 and (z == -2 or z == -1 or z == D or z == D + 1) then
|
if ledge0 and (z == -2 or z == -1 or z == D or z == D + 1) then
|
||||||
local sy = H - 1 - y
|
local sy = ground - 1 - y
|
||||||
if sy >= ledge0 and sy <= ledge1 and sp.inside[sy * W + x] then
|
if sy >= ledge0 and sy <= ledge1 and sp.inside[sy * W + x] then
|
||||||
return sy * W + x
|
return sy * W + x
|
||||||
end
|
end
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
-- the facade, extruded straight back over the footprint
|
-- the facade, extruded straight back over the footprint. Rows map
|
||||||
|
-- against the measured ground line, not the grid's last row: the two
|
||||||
|
-- differ only for furniture standing on open floor (see measure).
|
||||||
if z < 0 or z >= D then return nil end
|
if z < 0 or z >= D then return nil end
|
||||||
local sy = H - 1 - y
|
local sy = ground - 1 - y
|
||||||
local i = sy * W + x
|
local i = sy * W + x
|
||||||
if y == 0 and not sp.inside[i] and sy > 0 and sp.inside[i - W] then
|
if y == 0 and not sp.inside[i] and sy > 0 and sp.inside[i - W] then
|
||||||
-- the drawing's last row is the ground the building stands on, so
|
-- the drawing's last row is the ground the building stands on, so
|
||||||
|
|||||||
+80
-16
@@ -221,8 +221,18 @@ end
|
|||||||
-- Kept free of any GPU call so it can be exercised headless -- the
|
-- 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
|
-- 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.
|
-- 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 push = sink.push
|
||||||
|
local waterPush = waterSink and waterSink.push or nil
|
||||||
local tileset = map.tileset
|
local tileset = map.tileset
|
||||||
local S = Structures.forMap(map)
|
local S = Structures.forMap(map)
|
||||||
local perRow = tileset.tilesPerRow or 16
|
local perRow = tileset.tilesPerRow or 16
|
||||||
@@ -358,12 +368,14 @@ local function runGeometry(map, bodyOnly, masks, sink)
|
|||||||
return aoSide
|
return aoSide
|
||||||
end
|
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)
|
local u0, u1, v0, v1 = uvRect(tile, 0, 8)
|
||||||
push({ { x0, h, z0 }, { x0 + 8, h, z0 },
|
;(to or push)({ { x0, h, z0 }, { x0 + 8, h, z0 },
|
||||||
{ x0 + 8, h, z0 + 8 }, { x0, h, z0 + 8 } },
|
{ x0 + 8, h, z0 + 8 }, { x0, h, z0 + 8 } },
|
||||||
{ { u0, v0 }, { u1, v0 }, { u1, v1 }, { u0, v1 } },
|
{ { u0, v0 }, { u1, v0 }, { u1, v1 }, { u0, v1 } },
|
||||||
aoShades(x0 / 8, z0 / 8, h, shade))
|
aoShades(x0 / 8, z0 / 8, h, shade))
|
||||||
end
|
end
|
||||||
|
|
||||||
-- vertical quad for face direction `d` of the tile column at (x0, z0),
|
-- vertical quad for face direction `d` of the tile column at (x0, z0),
|
||||||
@@ -558,8 +570,14 @@ local function runGeometry(map, bodyOnly, masks, sink)
|
|||||||
end
|
end
|
||||||
topTile = S.tileAt[keyOf(tx, row)]
|
topTile = S.tileAt[keyOf(tx, row)]
|
||||||
end
|
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,
|
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
|
end
|
||||||
|
|
||||||
-- sides: 8px bands wherever the neighbour is lower. Band k spans
|
-- sides: 8px bands wherever the neighbour is lower. Band k spans
|
||||||
@@ -764,18 +782,34 @@ end
|
|||||||
-- The raw geometry for `map`: (vertex list, triangle index list, quad
|
-- The raw geometry for `map`: (vertex list, triangle index list, quad
|
||||||
-- count). Synchronous and GPU-free -- the headless suite and the probes
|
-- count). Synchronous and GPU-free -- the headless suite and the probes
|
||||||
-- exercise the invariants through this.
|
-- 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()
|
local sink = newTableSink()
|
||||||
runGeometry(map, bodyOnly, masks, sink)
|
local waterSink = split and newTableSink() or nil
|
||||||
return sink.results()
|
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
|
end
|
||||||
|
|
||||||
-- Build the mesh for `map` synchronously. Returns nil when there is
|
-- Build the mesh for `map` synchronously. Returns nil when there is
|
||||||
-- nothing to draw or meshes are unavailable (headless).
|
-- 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()
|
local sink = newSink()
|
||||||
runGeometry(map, bodyOnly, masks, sink)
|
local waterSink = split and newSink() or nil
|
||||||
return sink.finish()
|
runGeometry(map, bodyOnly, masks, sink, waterSink)
|
||||||
|
return sink.finish(), waterSink and waterSink.finish() or nil
|
||||||
end
|
end
|
||||||
|
|
||||||
local function quadsMesh(quads)
|
local function quadsMesh(quads)
|
||||||
@@ -858,8 +892,17 @@ local function entry(id)
|
|||||||
return c
|
return c
|
||||||
end
|
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)
|
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]
|
local mesh = c[slot]
|
||||||
if mesh and mesh.release then pcall(mesh.release, mesh) end
|
if mesh and mesh.release then pcall(mesh.release, mesh) end
|
||||||
c[slot] = nil
|
c[slot] = nil
|
||||||
@@ -924,13 +967,17 @@ local function runJob(job)
|
|||||||
if c.stale then c.stale.aux = nil end
|
if c.stale then c.stale.aux = nil end
|
||||||
end
|
end
|
||||||
local sink = newSink()
|
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 mesh = sink.finish()
|
||||||
|
local water = waterSink.finish()
|
||||||
if (gen[job.id] or 0) ~= job.gen then
|
if (gen[job.id] or 0) ~= job.gen then
|
||||||
if mesh and mesh.release then pcall(mesh.release, mesh) end
|
if mesh and mesh.release then pcall(mesh.release, mesh) end
|
||||||
|
if water and water.release then pcall(water.release, water) end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
swapSlot(c, job.slot, mesh or false)
|
swapSlot(c, job.slot, mesh or false)
|
||||||
|
swapSlot(c, waterSlot(job.slot), water or false)
|
||||||
if c.stale then
|
if c.stale then
|
||||||
c.stale[job.slot] = nil
|
c.stale[job.slot] = nil
|
||||||
if not (c.stale.full or c.stale.body or c.stale.aux) then
|
if not (c.stale.full or c.stale.body or c.stale.aux) then
|
||||||
@@ -1032,12 +1079,14 @@ function ChunkMesher.get(map, bodyOnly, masks)
|
|||||||
if c.stale then c.stale.aux = nil end
|
if c.stale then c.stale.aux = nil end
|
||||||
end
|
end
|
||||||
if c[slot] == nil or (c.stale and c.stale[slot]) then
|
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
|
if not ok then
|
||||||
print("[warn] voxel mesh build failed for " .. tostring(map.id)
|
print("[warn] voxel mesh build failed for " .. tostring(map.id)
|
||||||
.. ": " .. tostring(mesh))
|
.. ": " .. tostring(mesh))
|
||||||
end
|
end
|
||||||
swapSlot(c, slot, (ok and mesh) or false)
|
swapSlot(c, slot, (ok and mesh) or false)
|
||||||
|
swapSlot(c, waterSlot(slot), (ok and water) or false)
|
||||||
if c.stale then
|
if c.stale then
|
||||||
c.stale[slot] = nil
|
c.stale[slot] = nil
|
||||||
if not (c.stale.full or c.stale.body or c.stale.aux) then
|
if not (c.stale.full or c.stale.body or c.stale.aux) then
|
||||||
@@ -1058,6 +1107,21 @@ function ChunkMesher.peek(map, bodyOnly)
|
|||||||
return mesh or nil
|
return mesh or nil
|
||||||
end
|
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)
|
function ChunkMesher.grass(map)
|
||||||
local c = cache[map.id]
|
local c = cache[map.id]
|
||||||
return c and c.grass or nil
|
return c and c.grass or nil
|
||||||
|
|||||||
+169
@@ -0,0 +1,169 @@
|
|||||||
|
-- The hour's light on the FLAT world.
|
||||||
|
--
|
||||||
|
-- The clock already reaches everything the 3D pass draws: VoxelScene and
|
||||||
|
-- BattleScene multiply the whole scene by DayNight.tint, so walking around a
|
||||||
|
-- route at dusk warms the diorama and midnight turns it blue. Switch voxel
|
||||||
|
-- mode off and none of that happens -- the tint is a uniform in a shader the
|
||||||
|
-- flat tile path never runs -- so the same evening that fell on the diorama
|
||||||
|
-- left the 2D world at permanent noon. One clock, two worlds, one of them
|
||||||
|
-- ignoring it.
|
||||||
|
--
|
||||||
|
-- So the flat composite gets the same multiply, painted as one rectangle.
|
||||||
|
--
|
||||||
|
-- ------- WHERE, which is the only difficult part
|
||||||
|
--
|
||||||
|
-- Not on the world canvas. In a colorized mode that canvas is grayscale art
|
||||||
|
-- and the blit that puts it on screen runs it through the palette shader,
|
||||||
|
-- which classifies each pixel into a shade BY ITS RED CHANNEL. Multiply a
|
||||||
|
-- night blue over it first and every shade lands in the wrong bucket -- the
|
||||||
|
-- world would not darken, it would change colour into whatever the palette
|
||||||
|
-- said the wrong bucket was.
|
||||||
|
--
|
||||||
|
-- So it goes on AFTER that pass, on the composited world. And not after the
|
||||||
|
-- whole frame either: the UI blit is next, and the dialog boxes, the menus and
|
||||||
|
-- the HUD are paper held up in front of the world rather than part of it --
|
||||||
|
-- the same reason the tilt-shift blur is a worldPresent and not a present.
|
||||||
|
--
|
||||||
|
-- Which leaves one instant: between the world blit and the UI blit, inside
|
||||||
|
-- Renderer:endFrame. There is no seam there -- worldPresent, the engine's own
|
||||||
|
-- hook for exactly this, only runs when a PIPELINE produced the world, which
|
||||||
|
-- in flat mode is the one thing that did not happen. So endFrame is wrapped
|
||||||
|
-- and the UI canvas's own draw call is watched for: `blit` passes the canvas
|
||||||
|
-- as the first argument, so the first draw of Renderer.canvas IS the boundary,
|
||||||
|
-- by identity rather than by counting or guessing.
|
||||||
|
--
|
||||||
|
-- The shader and scissor that call arrives under belong to the UI blit already
|
||||||
|
-- in progress, so both are put aside for the rectangle and handed straight
|
||||||
|
-- back -- otherwise the tint would be palette-remapped and clipped to a zone.
|
||||||
|
--
|
||||||
|
-- ------- WHEN
|
||||||
|
--
|
||||||
|
-- Outdoors, on the flat path, when the hour is not neutral. Each of those is
|
||||||
|
-- load-bearing:
|
||||||
|
--
|
||||||
|
-- the flat path a pipeline that rendered the world already applied the
|
||||||
|
-- tint inside its own shader; painting it again would apply
|
||||||
|
-- the hour twice. worldOverride is exactly "a pipeline drew
|
||||||
|
-- this frame".
|
||||||
|
-- outdoors a room has no sky to take its light from, which is the
|
||||||
|
-- same answer DayNight.tint gives on its own and the same
|
||||||
|
-- one applyRig gives the sun.
|
||||||
|
-- not neutral midday is a multiply by white. Skipped rather than drawn,
|
||||||
|
-- so a game with the clock at DAY issues not one extra call.
|
||||||
|
|
||||||
|
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||||
|
local V = ...
|
||||||
|
|
||||||
|
local DayNight = V.require("DayNight")
|
||||||
|
|
||||||
|
local DayTint = {}
|
||||||
|
|
||||||
|
-- Below this the tint is close enough to white that the rectangle would not
|
||||||
|
-- change a pixel, and the frame is left exactly as it was.
|
||||||
|
DayTint.NEUTRAL = 0.999
|
||||||
|
|
||||||
|
local function outdoorNow()
|
||||||
|
local ok, Game = pcall(require, "src.core.Game")
|
||||||
|
if not ok then return false end
|
||||||
|
local ow = Game and Game.overworld
|
||||||
|
local map = ow and ow.map
|
||||||
|
if not map then return false end
|
||||||
|
local okMap, Map = pcall(require, "src.world.Map")
|
||||||
|
if not okMap then return false end
|
||||||
|
local outdoor = map.def and Map.isOutdoor(map.def) or false
|
||||||
|
-- a canopy floor takes the hour's colour and nothing else of it, exactly as
|
||||||
|
-- it does in the 3D pass (BattleScene, VoxelScene)
|
||||||
|
return outdoor or DayNight.isCanopy(map)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The colour this frame's world should be multiplied by, or nil to leave the
|
||||||
|
-- frame alone.
|
||||||
|
function DayTint.forFrame(renderer)
|
||||||
|
if not renderer then return nil end
|
||||||
|
if renderer.worldOverride then return nil end -- a pipeline drew, and tinted
|
||||||
|
if not renderer.worldActive then return nil end -- no world on screen at all
|
||||||
|
if not outdoorNow() then return nil end
|
||||||
|
local tint = DayNight.tint(true)
|
||||||
|
if not tint then return nil end
|
||||||
|
local r, g, b = tint[1] or 1, tint[2] or 1, tint[3] or 1
|
||||||
|
if r > DayTint.NEUTRAL and g > DayTint.NEUTRAL and b > DayTint.NEUTRAL then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return r, g, b
|
||||||
|
end
|
||||||
|
|
||||||
|
-- One rectangle over the window, multiplied into whatever is under it.
|
||||||
|
--
|
||||||
|
-- The whole window rather than the world's own rect, which is what the
|
||||||
|
-- engine's warp fade does from the same place and for the same reason: the
|
||||||
|
-- border fill, the letterbox bars and the world are all "the world" as far as
|
||||||
|
-- the hour is concerned, and black multiplied by anything is still black.
|
||||||
|
-- Every read of the graphics state is optional, because a headless driver
|
||||||
|
-- ships some of these and not others -- the same reason TerrainAtlas reads the
|
||||||
|
-- engine's seams guarded. What cannot be read cannot be put back either, and a
|
||||||
|
-- missing accessor must cost the tint rather than the frame.
|
||||||
|
local function saved(name, ...)
|
||||||
|
local fn = love.graphics[name]
|
||||||
|
if not fn then return nil end
|
||||||
|
local ok, a, b, c, d = pcall(fn, ...)
|
||||||
|
if not ok then return nil end
|
||||||
|
return a, b, c, d
|
||||||
|
end
|
||||||
|
|
||||||
|
function DayTint.paint(r, g, b)
|
||||||
|
local gfx = love.graphics
|
||||||
|
local shader = saved("getShader")
|
||||||
|
local sx, sy, sw, sh = saved("getScissor")
|
||||||
|
local blend, alpha = saved("getBlendMode")
|
||||||
|
local pr, pg, pb, pa = saved("getColor")
|
||||||
|
local w, h = gfx.getDimensions()
|
||||||
|
|
||||||
|
if gfx.setShader then gfx.setShader() end
|
||||||
|
if gfx.setScissor then gfx.setScissor() end
|
||||||
|
gfx.setBlendMode("multiply", "premultiplied")
|
||||||
|
gfx.setColor(r, g, b, 1)
|
||||||
|
gfx.rectangle("fill", 0, 0, w, h)
|
||||||
|
|
||||||
|
gfx.setBlendMode(blend or "alpha", alpha)
|
||||||
|
gfx.setColor(pr or 1, pg or 1, pb or 1, pa or 1)
|
||||||
|
if gfx.setScissor then
|
||||||
|
if sx then gfx.setScissor(sx, sy, sw, sh) else gfx.setScissor() end
|
||||||
|
end
|
||||||
|
if shader and gfx.setShader then gfx.setShader(shader) end
|
||||||
|
end
|
||||||
|
|
||||||
|
function DayTint.install()
|
||||||
|
local Renderer = require("src.render.Renderer")
|
||||||
|
if Renderer.dramaticShapeTintHook then return end
|
||||||
|
local inner = Renderer.endFrame
|
||||||
|
|
||||||
|
function Renderer:endFrame(zones, worldZones)
|
||||||
|
local r, g, b = DayTint.forFrame(self)
|
||||||
|
if not r then return inner(self, zones, worldZones) end
|
||||||
|
|
||||||
|
local gfx = love.graphics
|
||||||
|
local draw = gfx.draw
|
||||||
|
local ui = self.canvas
|
||||||
|
local painted = false
|
||||||
|
gfx.draw = function(tex, ...)
|
||||||
|
-- the UI canvas reaching the screen: the world is finished, the paper
|
||||||
|
-- in front of it has not started. Restored FIRST so the rectangle's own
|
||||||
|
-- drawing cannot re-enter this, and so a UI blit that draws one quad per
|
||||||
|
-- SGB zone only triggers it once.
|
||||||
|
if not painted and tex == ui then
|
||||||
|
painted = true
|
||||||
|
gfx.draw = draw
|
||||||
|
DayTint.paint(r, g, b)
|
||||||
|
end
|
||||||
|
return draw(tex, ...)
|
||||||
|
end
|
||||||
|
|
||||||
|
local ok, err = pcall(inner, self, zones, worldZones)
|
||||||
|
gfx.draw = draw
|
||||||
|
if not ok then error(err, 0) end
|
||||||
|
end
|
||||||
|
|
||||||
|
Renderer.dramaticShapeTintHook = true
|
||||||
|
end
|
||||||
|
|
||||||
|
return DayTint
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
-- Voxel world mode: decoded pixels, kept.
|
||||||
|
--
|
||||||
|
-- Assets.imageData is deliberately uncached upstream -- "pixel-level reads
|
||||||
|
-- resolve the same way but stay uncached: the caller keeps the derived
|
||||||
|
-- product" (src/render/Assets.lua) -- which is the right contract for the
|
||||||
|
-- flat renderer, whose one caller decodes a strip once and keeps the strip.
|
||||||
|
--
|
||||||
|
-- This mod is not that caller. It reads the same handful of images over and
|
||||||
|
-- over, from several places that do not know about each other:
|
||||||
|
--
|
||||||
|
-- * the tileset atlas, decoded by Structures (its own cache), by
|
||||||
|
-- TerrainAtlas twice (the SGB bake and the RED++ rebake), by
|
||||||
|
-- TerrainAtlas again to learn a tile's shades, and by GlassMask;
|
||||||
|
-- * the FLOWER FRAME files, decoded inside patch() -- which runs every
|
||||||
|
-- time the animation step turns over, about three times a second, for
|
||||||
|
-- as long as the map is on screen. That one is not a load cost at all,
|
||||||
|
-- it is a recurring per-second cost on the render thread, and it was
|
||||||
|
-- the single clearest waste the first profile turned up.
|
||||||
|
--
|
||||||
|
-- So: one table, keyed by the path as the CALLER gave it, holding the
|
||||||
|
-- decoded ImageData. Registered with Assets.invalidate so a hot reload
|
||||||
|
-- drops it alongside every other downstream cache.
|
||||||
|
--
|
||||||
|
-- The entries are never evicted by size. That is deliberate and bounded:
|
||||||
|
-- what lands here is tileset art and animation frames -- a few dozen small
|
||||||
|
-- images for a whole session, tens of kilobytes each -- not per-map bakes,
|
||||||
|
-- which have their own eviction in TerrainAtlas.setLive.
|
||||||
|
|
||||||
|
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||||
|
local V = ...
|
||||||
|
|
||||||
|
local Assets = require("src.render.Assets")
|
||||||
|
local Perf = V.require("Perf")
|
||||||
|
|
||||||
|
local ImageCache = {}
|
||||||
|
|
||||||
|
local cache = {}
|
||||||
|
|
||||||
|
-- The decoded pixels for `path`, or nil when it cannot be read.
|
||||||
|
--
|
||||||
|
-- `false` is cached for an unreadable path, so a missing or corrupt asset
|
||||||
|
-- costs one failed decode for the session rather than one per frame -- the
|
||||||
|
-- same sticky-failure shape the rest of this mod uses for GPU objects.
|
||||||
|
function ImageCache.get(path)
|
||||||
|
if not path then return nil end
|
||||||
|
local hit = cache[path]
|
||||||
|
if hit ~= nil then
|
||||||
|
Perf.count("imageCache.hit")
|
||||||
|
return hit or nil
|
||||||
|
end
|
||||||
|
local t0 = Perf.now()
|
||||||
|
local ok, data = pcall(Assets.imageData, path)
|
||||||
|
Perf.add("ImageCache.decode", t0)
|
||||||
|
Perf.count("imageCache.miss")
|
||||||
|
cache[path] = (ok and data) or false
|
||||||
|
return cache[path] or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function ImageCache.invalidate()
|
||||||
|
cache = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
Assets.register(ImageCache.invalidate)
|
||||||
|
|
||||||
|
return ImageCache
|
||||||
+278
-17
@@ -72,6 +72,63 @@ function OverworldBattle.enabled()
|
|||||||
return OverworldBattle.setting:get() and true or false
|
return OverworldBattle.setting:get() and true or false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- ------- BACK SPRITES: the player's own mon stays on the menu
|
||||||
|
--
|
||||||
|
-- The staged shot stands BOTH mons on the map, which is the mode's whole
|
||||||
|
-- claim -- but it costs the one piece of framing Gen 1 is most recognisable
|
||||||
|
-- by: your own Pokemon, seen from behind, sitting on top of the battle menu
|
||||||
|
-- with its feet on the box. That silhouette is the series' shot.
|
||||||
|
--
|
||||||
|
-- So BACK SPRITES is offered as a middle setting rather than a compromise
|
||||||
|
-- imposed on everyone. With it on the foe is still geometry standing on its
|
||||||
|
-- tile at the far end of the arena, and the player's side goes back to being
|
||||||
|
-- the GB's own flat back pic in the GB's own slot: same art, same 2x, same
|
||||||
|
-- feet on row 96.
|
||||||
|
-- Nothing else about the shot moves -- the arena, the camera and the drift are
|
||||||
|
-- solved exactly as they were, so the foe stands where it always stood and the
|
||||||
|
-- player's cell is simply empty ground in the foreground.
|
||||||
|
--
|
||||||
|
-- OFF by default: what the mode advertises is the pair of them out there.
|
||||||
|
OverworldBattle.BACK_KEY = "battleBack"
|
||||||
|
OverworldBattle.BACK_LABEL = "BACK SPRITES"
|
||||||
|
|
||||||
|
OverworldBattle.backSetting = ModSetting.new(OverworldBattle.BACK_KEY,
|
||||||
|
OverworldBattle.BACK_LABEL,
|
||||||
|
{ false, true }, { "OFF", "ON" })
|
||||||
|
|
||||||
|
-- Gated on 3D-BTL rather than read alone: with staged battles off there is no
|
||||||
|
-- staged shot for a back pic to be pinned in FRONT of, and the engine's own
|
||||||
|
-- battle screen already draws exactly this.
|
||||||
|
function OverworldBattle.backPinned()
|
||||||
|
if not OverworldBattle.enabled() then return false end
|
||||||
|
return OverworldBattle.backSetting:get() and true or false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Whether a pic is the one drawn in the GB's own slot with its feet on the
|
||||||
|
-- text box, rather than geometry standing out on the map.
|
||||||
|
--
|
||||||
|
-- Exactly the player's side under BACK SPRITES -- its mon, or the trainer back
|
||||||
|
-- that holds the slot until "Go!" -- because that is the only pic this mod
|
||||||
|
-- ever leaves flat (see drawPicsLayer below). The foe is a billboard on its
|
||||||
|
-- tile whichever mode is on, and with the mode off the player's side is one
|
||||||
|
-- too, so both of those keep the open bottom that lets the arena through a
|
||||||
|
-- stride. What the answer buys is in BattlePics: a pic on the box has nothing
|
||||||
|
-- behind its lowest row, so its bottom edge seals.
|
||||||
|
-- Read by TRUTHINESS rather than against nil, because sideTexture blanks the
|
||||||
|
-- side it is not rendering by setting the field to FALSE (see OFF) and holds
|
||||||
|
-- it that way for the whole render -- during which the pic layer runs, and
|
||||||
|
-- picImage asks this. A nil test passes a `false` straight through to the
|
||||||
|
-- index below, and the error comes out of sideTexture into the pcall that
|
||||||
|
-- calls it: the foe's billboard is dropped for the frame and the Pokemon
|
||||||
|
-- simply is not there.
|
||||||
|
function OverworldBattle.pinnedPic(battle, img)
|
||||||
|
if not (battle and img) then return false end
|
||||||
|
if not OverworldBattle.backPinned() then return false end
|
||||||
|
if img == battle.playerBackPic then return true end
|
||||||
|
local player = battle.player
|
||||||
|
return (player and img == player.sprite) and true or false
|
||||||
|
end
|
||||||
|
|
||||||
-- ------- both mons face you
|
-- ------- both mons face you
|
||||||
--
|
--
|
||||||
-- Standing on a map, seen from in front, a Pokemon showing you its BACK is
|
-- Standing on a map, seen from in front, a Pokemon showing you its BACK is
|
||||||
@@ -81,6 +138,10 @@ end
|
|||||||
-- through the engine's own pokemon.sprite hook -- the seam that exists for
|
-- through the engine's own pokemon.sprite hook -- the seam that exists for
|
||||||
-- exactly this, so no battle code has to be touched to get it.
|
-- exactly this, so no battle code has to be touched to get it.
|
||||||
--
|
--
|
||||||
|
-- Unless BACK SPRITES is on, the setting that asks for the back pic back:
|
||||||
|
-- that mon is drawn in its own slot on the menu, seen from behind, and the
|
||||||
|
-- front art would be it turned round to face the player it belongs to.
|
||||||
|
--
|
||||||
-- Answered BEFORE a battle exists, because the battler is built before the
|
-- Answered BEFORE a battle exists, because the battler is built before the
|
||||||
-- battle is pushed. So it cannot ask whether this fight is staged; it asks
|
-- battle is pushed. So it cannot ask whether this fight is staged; it asks
|
||||||
-- whether one on this map WOULD be -- the row is on, the 3D pass is
|
-- whether one on this map WOULD be -- the row is on, the 3D pass is
|
||||||
@@ -91,6 +152,7 @@ local staged = { mapId = nil, ok = false }
|
|||||||
|
|
||||||
function OverworldBattle.wantsFront()
|
function OverworldBattle.wantsFront()
|
||||||
if not OverworldBattle.enabled() then return false end
|
if not OverworldBattle.enabled() then return false end
|
||||||
|
if OverworldBattle.backPinned() then return false end
|
||||||
if not Voxel3D.available() then return false end
|
if not Voxel3D.available() then return false end
|
||||||
-- required here rather than through the file's own helper: this runs
|
-- required here rather than through the file's own helper: this runs
|
||||||
-- while a battler is being built, which is before that helper is defined
|
-- while a battler is being built, which is before that helper is defined
|
||||||
@@ -140,6 +202,51 @@ OverworldBattle.HUD_RECT = {
|
|||||||
player = { 72, 56, 88, 40 },
|
player = { 72, 56, 88, 40 },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
-- ------- the box at the bottom, on the same glass
|
||||||
|
--
|
||||||
|
-- The HUDs got frosted panels because black glyphs on grass are not readable.
|
||||||
|
-- The battle's text box and its menu had the opposite problem and the same
|
||||||
|
-- cause: they are drawn as an OPAQUE WHITE slab with a black border, which was
|
||||||
|
-- the field's own colour when the field was white and is a sheet of paper laid
|
||||||
|
-- over the bottom third of the diorama now that it is not.
|
||||||
|
--
|
||||||
|
-- So the box gets exactly what the HUDs get: the world behind it, blurred to
|
||||||
|
-- frosted glass and laid back down translucent, with the border and the text
|
||||||
|
-- drawn over it unchanged, and the same brightness verdict flipping the ink
|
||||||
|
-- when the ground under it is dark. Only the FILL is taken away -- every glyph
|
||||||
|
-- the engine draws inside the box is still the engine's own, in its own place.
|
||||||
|
--
|
||||||
|
-- These are the boxes BattleState:drawTextArea lays down, as GB-frame rects.
|
||||||
|
-- READ-ONLY duplicates of that function's own branches, the same kind of
|
||||||
|
-- mirror hudLive is and for the same reason: there is no seam that reports "a
|
||||||
|
-- move menu is up", and glass has to go down BEFORE the box that sits on it.
|
||||||
|
-- The worst a future engine change can do is frost a rectangle nothing lands
|
||||||
|
-- on, or leave a box unfrosted -- never break a battle.
|
||||||
|
--
|
||||||
|
-- Each rect stops where the next one starts rather than overlapping it: two
|
||||||
|
-- panels over the same pixels would frost it twice and leave a visible step
|
||||||
|
-- along the seam.
|
||||||
|
OverworldBattle.TEXT_RECT = {
|
||||||
|
box = { 0, 96, 160, 48 }, -- Font.drawBox(0, 12, 20, 6), always
|
||||||
|
-- moveSelect's TYPE/PP box, Font.drawBox(0, 8, 11, 5), trimmed to the rows
|
||||||
|
-- above the box above -- its last tile row sits inside that one
|
||||||
|
moves = { 0, 64, 88, 32 },
|
||||||
|
-- mimicSelect's copy menu, Font.drawBox(0, 7, 16, 6), trimmed the same way
|
||||||
|
mimic = { 0, 56, 128, 40 },
|
||||||
|
}
|
||||||
|
|
||||||
|
function OverworldBattle.textRects(battle)
|
||||||
|
if not battle or battle.blankForAskName then return {} end
|
||||||
|
local r = OverworldBattle.TEXT_RECT
|
||||||
|
local out = { box = r.box }
|
||||||
|
if battle.phase == "moveSelect" then
|
||||||
|
out.moves = r.moves
|
||||||
|
elseif battle.phase == "mimicSelect" then
|
||||||
|
out.mimic = r.mimic
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
-- ------- the HUDs, out at the window's own edges
|
-- ------- the HUDs, out at the window's own edges
|
||||||
--
|
--
|
||||||
-- The battle screen is 160x144 in the MIDDLE of the window and the world is the
|
-- The battle screen is 160x144 in the MIDDLE of the window and the world is the
|
||||||
@@ -188,6 +295,17 @@ function OverworldBattle.snapRects(shot)
|
|||||||
return rects, { enemy = ex, player = px }
|
return rects, { enemy = ex, player = px }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- A rect measured in the GB frame, in WORLD-canvas pixels: where the letterbox
|
||||||
|
-- blit will actually put it. The text box has not moved anywhere -- it is drawn
|
||||||
|
-- where it always was -- but its glass is laid into the world image alongside
|
||||||
|
-- the HUDs' (see snapHUDs), which is the surface that reaches the screen a
|
||||||
|
-- pixel to a pixel rather than magnified out of a 160x144 canvas.
|
||||||
|
local function toWorld(rect, shot)
|
||||||
|
local s = shot.scale
|
||||||
|
return { shot.lx + rect[1] * s, shot.ly + rect[2] * s,
|
||||||
|
rect[3] * s, rect[4] * s }
|
||||||
|
end
|
||||||
|
|
||||||
-- ------- the live battle
|
-- ------- the live battle
|
||||||
--
|
--
|
||||||
-- nil when no overworld battle is running. Never more than one: battles do
|
-- nil when no overworld battle is running. Never more than one: battles do
|
||||||
@@ -471,6 +589,81 @@ local function withoutBackgroundFill(battle, fn)
|
|||||||
if not ok then error(err, 0) end
|
if not ok then error(err, 0) end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- ------- the box, without its paper
|
||||||
|
--
|
||||||
|
-- Font.drawBox is a white fill and then six border glyphs, and the fill is the
|
||||||
|
-- opaque slab the frosted panel underneath is there to replace. So for the
|
||||||
|
-- length of one drawTextArea the white fills are dropped and everything else
|
||||||
|
-- -- the border, the text, the cursor, the down arrow -- draws exactly as it
|
||||||
|
-- always did, over the glass instead of over paper.
|
||||||
|
--
|
||||||
|
-- Every fill drawTextArea issues is one of those: the box's own, and the two
|
||||||
|
-- eight-pixel cells MoveSelectionMenu wipes back to box white before it writes
|
||||||
|
-- the border glyphs that hardware would have overwritten. Both are opaque
|
||||||
|
-- white, both are paper, and both go.
|
||||||
|
--
|
||||||
|
-- The same shim shape as withoutBackgroundFill above, and scoped as tightly:
|
||||||
|
-- installed around a single call, removed on the way out including on error,
|
||||||
|
-- never live outside a battle frame this mode is drawing.
|
||||||
|
local function withoutBoxFill(battle, fn)
|
||||||
|
local g = love.graphics
|
||||||
|
local rectangle = g.rectangle
|
||||||
|
g.rectangle = function(mode, ...)
|
||||||
|
if mode == "fill" then
|
||||||
|
local r, gr, b, a = g.getColor()
|
||||||
|
if r > 0.99 and gr > 0.99 and b > 0.99 and a > 0.99 then return end
|
||||||
|
end
|
||||||
|
return rectangle(mode, ...)
|
||||||
|
end
|
||||||
|
local ok, err = pcall(fn, battle)
|
||||||
|
g.rectangle = rectangle
|
||||||
|
if not ok then error(err, 0) end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- the hour's light, on a pic that is not geometry
|
||||||
|
--
|
||||||
|
-- Everything standing in the arena goes through the voxel shader, and that
|
||||||
|
-- shader multiplies by the hour's tint: at dusk the whole diorama warms, at
|
||||||
|
-- night it goes blue, and the two mons' cards go with it because they are
|
||||||
|
-- drawn in the same pass as the ground they stand on.
|
||||||
|
--
|
||||||
|
-- A back pic pinned to the menu is not in that pass. It is the engine's own
|
||||||
|
-- flat blit over the finished shot, so it arrived at noon while the world
|
||||||
|
-- behind it was at midnight -- a mon lit by nothing in the frame.
|
||||||
|
--
|
||||||
|
-- So the tint is applied by hand, to that one draw. Every colour the pics
|
||||||
|
-- layer sets is multiplied on its way past, which is the whole of it: the
|
||||||
|
-- layer draws the pic with love.graphics.draw and LOVE multiplies by the draw
|
||||||
|
-- colour, so tinting the colour tints the pixels -- and the alpha, the faint
|
||||||
|
-- slide's fade and the blink's own colour all compose with it rather than
|
||||||
|
-- being overwritten.
|
||||||
|
--
|
||||||
|
-- What this does NOT get is the sun: the cards are shadow-mapped, so one
|
||||||
|
-- standing under a tree is darker than the tint alone, and this pic has no
|
||||||
|
-- position in the scene to be shadowed at. It carries the hour and not the
|
||||||
|
-- weather, which is the part the eye reads.
|
||||||
|
local function withTint(tint, fn, ...)
|
||||||
|
if not tint then return fn(...) end
|
||||||
|
local r, g, b = tint[1] or 1, tint[2] or 1, tint[3] or 1
|
||||||
|
if r > 0.999 and g > 0.999 and b > 0.999 then return fn(...) end
|
||||||
|
local gfx = love.graphics
|
||||||
|
local setColor = gfx.setColor
|
||||||
|
gfx.setColor = function(cr, cg, cb, ca, ...)
|
||||||
|
if type(cr) == "table" then
|
||||||
|
return setColor({ (cr[1] or 1) * r, (cr[2] or 1) * g, (cr[3] or 1) * b,
|
||||||
|
cr[4] }, cg, ...)
|
||||||
|
end
|
||||||
|
if cr == nil then return setColor(cr, cg, cb, ca, ...) end
|
||||||
|
return setColor(cr * r, (cg or 1) * g, (cb or 1) * b, ca, ...)
|
||||||
|
end
|
||||||
|
local ok, err = pcall(fn, ...)
|
||||||
|
gfx.setColor = setColor
|
||||||
|
-- the layer leaves whatever colour it last set, and that one is tinted;
|
||||||
|
-- hand the next caller plain white rather than a dimmed one
|
||||||
|
setColor(1, 1, 1, 1)
|
||||||
|
if not ok then error(err, 0) end
|
||||||
|
end
|
||||||
|
|
||||||
-- ------- the mons, as textures for the 3D pass
|
-- ------- the mons, as textures for the 3D pass
|
||||||
--
|
--
|
||||||
-- The two Pokemon are not composited over the world any more: they are quads
|
-- The two Pokemon are not composited over the world any more: they are quads
|
||||||
@@ -600,11 +793,19 @@ function OverworldBattle.flashing(battle)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- Both sides, or nil when neither has anything to show.
|
-- Both sides, or nil when neither has anything to show.
|
||||||
|
--
|
||||||
|
-- One side under BACK SPRITES: the player's mon is not standing on the map at all
|
||||||
|
-- there, it is on the menu, so it has no card to be a texture for -- and
|
||||||
|
-- nothing downstream has to know that. No billboard, and no shadow on the
|
||||||
|
-- ground under a mon that is not on it.
|
||||||
function OverworldBattle.textures(battle)
|
function OverworldBattle.textures(battle)
|
||||||
if not battle then return nil end
|
if not battle then return nil end
|
||||||
local out = {}
|
local out = {}
|
||||||
local okE, enemy = pcall(OverworldBattle.sideTexture, battle, "enemy")
|
local okE, enemy = pcall(OverworldBattle.sideTexture, battle, "enemy")
|
||||||
local okP, player = pcall(OverworldBattle.sideTexture, battle, "player")
|
local okP, player = true, nil
|
||||||
|
if not OverworldBattle.backPinned() then
|
||||||
|
okP, player = pcall(OverworldBattle.sideTexture, battle, "player")
|
||||||
|
end
|
||||||
out.enemy = okE and enemy or nil
|
out.enemy = okE and enemy or nil
|
||||||
out.player = okP and player or nil
|
out.player = okP and player or nil
|
||||||
if not (out.enemy or out.player) then return nil end
|
if not (out.enemy or out.player) then return nil end
|
||||||
@@ -655,11 +856,17 @@ function OverworldBattle.install()
|
|||||||
-- behind it. There is a world back there now, so they are filled here
|
-- behind it. There is a world back there now, so they are filled here
|
||||||
-- instead -- see BattlePics, which puts the paper back without touching
|
-- instead -- see BattlePics, which puts the paper back without touching
|
||||||
-- the silhouette.
|
-- the silhouette.
|
||||||
|
--
|
||||||
|
-- The pinned pic is told that its feet are on the box, which is what lets
|
||||||
|
-- the pale-bodied back sprites be filled at all: their bellies leak out
|
||||||
|
-- through an opening too wide to read as a drain, and only the box under
|
||||||
|
-- them settles that it is not a hole. Passed the pre-bake image, because
|
||||||
|
-- that is the one the battle holds a reference to.
|
||||||
local innerPic = BattleState.picImage
|
local innerPic = BattleState.picImage
|
||||||
function BattleState:picImage(img)
|
function BattleState:picImage(img)
|
||||||
local out = innerPic(self, img)
|
local out = innerPic(self, img)
|
||||||
if not OverworldBattle.shot() then return out end
|
if not OverworldBattle.shot() then return out end
|
||||||
return BattlePics.filled(out)
|
return BattlePics.filled(out, OverworldBattle.pinnedPic(self, img))
|
||||||
end
|
end
|
||||||
|
|
||||||
-- While a billboard texture is being rendered both pics are put in the same
|
-- While a billboard texture is being rendered both pics are put in the same
|
||||||
@@ -719,10 +926,44 @@ function OverworldBattle.install()
|
|||||||
-- before this screen is composited at all, so the flat pics layer has
|
-- before this screen is composited at all, so the flat pics layer has
|
||||||
-- nothing left to do here. Skipped rather than left to draw underneath, or
|
-- nothing left to do here. Skipped rather than left to draw underneath, or
|
||||||
-- every Pokemon would appear twice: once on its tile and once in its slot.
|
-- every Pokemon would appear twice: once on its tile and once in its slot.
|
||||||
|
--
|
||||||
|
-- Except under BACK SPRITES, where the player's side never became geometry and this
|
||||||
|
-- layer is the only thing that draws it. The engine's own onlySide argument
|
||||||
|
-- does the whole job: one call, the player's branches alone, in the slot and
|
||||||
|
-- at the scale the GB always put them -- feet on the box, 2x, back view.
|
||||||
innerPics = BattleState.drawPicsLayer
|
innerPics = BattleState.drawPicsLayer
|
||||||
function BattleState:drawPicsLayer(slide, sx, sy)
|
function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip)
|
||||||
if self.dramaticShapeShot then return end
|
local shot = self.dramaticShapeShot
|
||||||
return innerPics(self, slide, sx, sy)
|
if not shot then
|
||||||
|
return innerPics(self, slide, sx, sy, onlySide, skipMenuClip)
|
||||||
|
end
|
||||||
|
if OverworldBattle.backPinned() and onlySide ~= "enemy" then
|
||||||
|
-- under the hour's own light, like everything else in the frame -- see
|
||||||
|
-- withTint, and the tint BattleScene hands over with the shot.
|
||||||
|
--
|
||||||
|
-- Except on the wavy path, where the pic is baked into the GRAYSCALE bg
|
||||||
|
-- canvas for the zone pass to colour by region. That pass keys off the
|
||||||
|
-- red channel, and a night tint pulls red down -- it would not darken
|
||||||
|
-- the mon, it would remap it to the wrong shade. SE_WAVY_SCREEN lasts a
|
||||||
|
-- second and the hour survives it fine.
|
||||||
|
local tint = not self.grayPics and shot.tint or nil
|
||||||
|
return withTint(tint, innerPics, self, slide, sx, sy, "player",
|
||||||
|
skipMenuClip)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The battle's text box and its menus, over the frosted glass laid down for
|
||||||
|
-- them rather than over their own white paper -- and their ink flipped with
|
||||||
|
-- the HUD's when the ground under the frame is dark, by the same rule and
|
||||||
|
-- off the same verdict.
|
||||||
|
local innerText = BattleState.drawTextArea
|
||||||
|
function BattleState:drawTextArea()
|
||||||
|
if not self.dramaticShapeShot then return innerText(self) end
|
||||||
|
local battle = self
|
||||||
|
if not self.dramaticShapeDark then return withoutBoxFill(battle, innerText) end
|
||||||
|
BattleHud.flipGlyphs(BattleScene.GB_W, BattleScene.GB_H, function()
|
||||||
|
withoutBoxFill(battle, innerText)
|
||||||
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Move animations are authored against the pics' fixed slots, and a single
|
-- Move animations are authored against the pics' fixed slots, and a single
|
||||||
@@ -740,10 +981,13 @@ function OverworldBattle.install()
|
|||||||
-- mons' projected positions, less the midpoint of the slots they used to
|
-- mons' projected positions, less the midpoint of the slots they used to
|
||||||
-- sit in. A hit still lands on the mon it is aimed at.
|
-- sit in. A hit still lands on the mon it is aimed at.
|
||||||
local a = OverworldBattle.ANCHOR
|
local a = OverworldBattle.ANCHOR
|
||||||
local dx = (shot.enemy[1] + shot.player[1]) / 2
|
-- BACK SPRITES leaves the player's mon exactly where the GB put it, so that side
|
||||||
- (a.enemy[1] + a.player[1]) / 2
|
-- contributes no movement at all and the pair's centre has gone half as
|
||||||
local dy = (shot.enemy[2] + shot.player[2]) / 2
|
-- far as the foe's mark did.
|
||||||
- (a.enemy[2] + a.player[2]) / 2
|
local px, py = shot.player[1], shot.player[2]
|
||||||
|
if OverworldBattle.backPinned() then px, py = a.player[1], a.player[2] end
|
||||||
|
local dx = (shot.enemy[1] + px) / 2 - (a.enemy[1] + a.player[1]) / 2
|
||||||
|
local dy = (shot.enemy[2] + py) / 2 - (a.enemy[2] + a.player[2]) / 2
|
||||||
love.graphics.push()
|
love.graphics.push()
|
||||||
love.graphics.translate(math.floor(dx + 0.5), math.floor(dy + 0.5))
|
love.graphics.translate(math.floor(dx + 0.5), math.floor(dy + 0.5))
|
||||||
local ok, err = pcall(innerAnim, self, colorized)
|
local ok, err = pcall(innerAnim, self, colorized)
|
||||||
@@ -882,10 +1126,21 @@ function OverworldBattle.snapHUDs(battle, shot)
|
|||||||
local live = {}
|
local live = {}
|
||||||
if enemy then live.enemy = rects.enemy end
|
if enemy then live.enemy = rects.enemy end
|
||||||
if player then live.player = rects.player end
|
if player then live.player = rects.player end
|
||||||
|
-- and the text box's own glass, on the same pass. It stays in the middle of
|
||||||
|
-- the frame where the engine draws it -- only the HUDs were snapped out --
|
||||||
|
-- so its GB rect is mapped into the letterbox rather than to an edge.
|
||||||
|
for key, rect in pairs(OverworldBattle.textRects(battle)) do
|
||||||
|
live[key] = toWorld(rect, shot)
|
||||||
|
end
|
||||||
-- measured under the SNAPPED rects: the panels are over whatever the world
|
-- measured under the SNAPPED rects: the panels are over whatever the world
|
||||||
-- shows at the window's edges now, which is not what was behind them in the
|
-- shows at the window's edges now, which is not what was behind them in the
|
||||||
-- middle of the frame
|
-- middle of the frame. ONE verdict over all of them, HUDs and box together,
|
||||||
|
-- for the reason BattleHud.verdict gives: a frame with white glyphs in the
|
||||||
|
-- corner and black ones on the menu reads as a bug rather than as adaptation.
|
||||||
local dark = BattleHud.verdict(live, shot, true)
|
local dark = BattleHud.verdict(live, shot, true)
|
||||||
|
-- the box's own ink is flipped where the engine draws it, in the GB frame,
|
||||||
|
-- so the answer has to outlive this function (see drawHudPanels)
|
||||||
|
if session then session.dark = dark end
|
||||||
local layer = OverworldBattle.hudTexture(battle, slide, dark)
|
local layer = OverworldBattle.hudTexture(battle, slide, dark)
|
||||||
if not layer then return false end
|
if not layer then return false end
|
||||||
|
|
||||||
@@ -911,23 +1166,29 @@ function OverworldBattle.snapHUDs(battle, shot)
|
|||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Lay the frosted glass down under whichever HUD is about to draw, and
|
-- Lay the frosted glass down under whichever HUD and box are about to draw,
|
||||||
-- record which way the glyphs have to flip.
|
-- and record which way the glyphs have to flip.
|
||||||
--
|
--
|
||||||
-- The fallback path only: with the HUDs snapped out to the window's edges their
|
-- The panels are the fallback path only: normally the HUDs are snapped out to
|
||||||
-- panels went with them, and there is nothing left inside the GB frame to lay
|
-- the window's edges and their glass, and the box's, went into the world image
|
||||||
-- glass under.
|
-- with them (snapHUDs). The VERDICT is needed either way -- the box's ink is
|
||||||
|
-- drawn here, in the GB frame, whichever path laid the glass under it.
|
||||||
function OverworldBattle.drawHudPanels(battle)
|
function OverworldBattle.drawHudPanels(battle)
|
||||||
local shot = battle.dramaticShapeShot
|
local shot = battle.dramaticShapeShot
|
||||||
battle.dramaticShapeDark = nil
|
battle.dramaticShapeDark = nil
|
||||||
if not shot or snapped() then return end
|
if not shot then return end
|
||||||
|
if snapped() then
|
||||||
|
battle.dramaticShapeDark = session and session.dark or nil
|
||||||
|
return
|
||||||
|
end
|
||||||
local slide = (battle.introSlide or 0) * 4
|
local slide = (battle.introSlide or 0) * 4
|
||||||
local enemy, player = OverworldBattle.hudLive(battle, slide)
|
local enemy, player = OverworldBattle.hudLive(battle, slide)
|
||||||
if not (enemy or player) then return end
|
|
||||||
local rect = OverworldBattle.HUD_RECT
|
local rect = OverworldBattle.HUD_RECT
|
||||||
local live = {}
|
local live = {}
|
||||||
if enemy then live.enemy = rect.enemy end
|
if enemy then live.enemy = rect.enemy end
|
||||||
if player then live.player = rect.player end
|
if player then live.player = rect.player end
|
||||||
|
for key, r in pairs(OverworldBattle.textRects(battle)) do live[key] = r end
|
||||||
|
if not next(live) then return end
|
||||||
local dark = BattleHud.verdict(live, shot)
|
local dark = BattleHud.verdict(live, shot)
|
||||||
battle.dramaticShapeDark = dark
|
battle.dramaticShapeDark = dark
|
||||||
for _, r in pairs(live) do BattleHud.panel(r, shot, dark) end
|
for _, r in pairs(live) do BattleHud.panel(r, shot, dark) end
|
||||||
|
|||||||
+353
@@ -0,0 +1,353 @@
|
|||||||
|
-- Voxel world mode: the instrumentation core.
|
||||||
|
--
|
||||||
|
-- Ships DARK. Every entry point is one boolean test away from doing
|
||||||
|
-- nothing, and the boolean is false unless a run explicitly asks for
|
||||||
|
-- measurement (DS_PERF in the environment, or a ds_perf.flag file in the
|
||||||
|
-- save directory for a device that has no environment to set). A mod that
|
||||||
|
-- measures itself in every player's session is a mod that costs every
|
||||||
|
-- player the measurement, so the default has to be off and the off path
|
||||||
|
-- has to be free.
|
||||||
|
--
|
||||||
|
-- What it measures, and why those three things:
|
||||||
|
--
|
||||||
|
-- * LABELS -- named spans (a bake, a mesh build, a shader compile),
|
||||||
|
-- accumulated as {n, total, max}. `max` is the one that matters: a
|
||||||
|
-- bake that costs 40ms ONCE is a visible hitch, and an average hides
|
||||||
|
-- it completely.
|
||||||
|
-- * FRAMES -- a ring of the last N whole-frame times, stamped once per
|
||||||
|
-- rendered frame. Frame time is the only number the player actually
|
||||||
|
-- experiences; every label total is a hypothesis about which frames.
|
||||||
|
-- * COUNTERS -- plain integers a caller bumps (sun-pass redraws, atlas
|
||||||
|
-- rebakes). Cheaper than a span when the question is "how often",
|
||||||
|
-- not "how long".
|
||||||
|
--
|
||||||
|
-- Spans are wall time, and on a GPU that means submission time, not
|
||||||
|
-- completion time -- the driver is free to finish the work later. So a
|
||||||
|
-- GPU-side saving shows up in the FRAME numbers rather than in the label
|
||||||
|
-- for the pass that caused it, and both are reported.
|
||||||
|
|
||||||
|
local Perf = {}
|
||||||
|
|
||||||
|
local clock = (love and love.timer and love.timer.getTime) or os.clock
|
||||||
|
|
||||||
|
-- Read through pcall: the loader's sandbox does not hand a mod `os`, and
|
||||||
|
-- instrumentation must never be the reason the mod fails to load. Same
|
||||||
|
-- shape as OverworldBattle's DS_BATTLE_DEBUG probe.
|
||||||
|
local function envFlag(name)
|
||||||
|
local ok, value = pcall(function() return os.getenv(name) end)
|
||||||
|
if not ok then return nil end
|
||||||
|
if value == nil or value == "" or value == "0" then return nil end
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
|
||||||
|
local function flagFile()
|
||||||
|
if not (love and love.filesystem and love.filesystem.getInfo) then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
local ok, info = pcall(love.filesystem.getInfo, "ds_perf.flag")
|
||||||
|
return ok and info ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
Perf.enabled = (envFlag("DS_PERF") ~= nil) or flagFile()
|
||||||
|
|
||||||
|
Perf.labels = {} -- label -> { n, total, max }
|
||||||
|
Perf.order = {} -- insertion order, so a report reads chronologically
|
||||||
|
Perf.counters = {} -- name -> integer
|
||||||
|
Perf.frames = {} -- ring of frame times, seconds
|
||||||
|
Perf.frameCount = 0
|
||||||
|
Perf.RING = 4096
|
||||||
|
|
||||||
|
-- The segment a frame belongs to ("map:ROUTE_1:first"). A benchmark
|
||||||
|
-- names the phase it is driving; every frame and every label span
|
||||||
|
-- recorded while that name is set is attributed to it, which is what
|
||||||
|
-- turns "the walk was slow" into "the walk was slow ONLY on the frames
|
||||||
|
-- right after ROUTE_1 came into view".
|
||||||
|
Perf.segment = nil
|
||||||
|
Perf.segments = {} -- name -> { frames = {}, labels = {}, order = {} }
|
||||||
|
|
||||||
|
local function segmentEntry()
|
||||||
|
local name = Perf.segment
|
||||||
|
if not name then return nil end
|
||||||
|
local s = Perf.segments[name]
|
||||||
|
if not s then
|
||||||
|
s = { name = name, frames = {}, labels = {}, order = {} }
|
||||||
|
Perf.segments[name] = s
|
||||||
|
Perf.segments[#Perf.segments + 1] = s -- array half preserves order
|
||||||
|
end
|
||||||
|
return s
|
||||||
|
end
|
||||||
|
|
||||||
|
function Perf.setSegment(name)
|
||||||
|
Perf.segment = name
|
||||||
|
if name then segmentEntry() end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------- spans
|
||||||
|
--
|
||||||
|
-- Call shape at the measured site:
|
||||||
|
--
|
||||||
|
-- local t0 = Perf.now()
|
||||||
|
-- ... the work ...
|
||||||
|
-- Perf.add("TerrainAtlas.staticAtlas", t0)
|
||||||
|
--
|
||||||
|
-- When disabled, now() returns nil and add() returns on the nil -- two
|
||||||
|
-- function calls and a branch, no table touched, no string built. Sites
|
||||||
|
-- that would run thousands of times a frame (per draw call, per vertex)
|
||||||
|
-- are still too hot for that and are deliberately NOT instrumented; the
|
||||||
|
-- frame ring covers them in aggregate.
|
||||||
|
|
||||||
|
function Perf.now()
|
||||||
|
if not Perf.enabled then return nil end
|
||||||
|
return clock()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function bump(store, order, label, dt)
|
||||||
|
local s = store[label]
|
||||||
|
if not s then
|
||||||
|
s = { n = 0, total = 0, max = 0 }
|
||||||
|
store[label] = s
|
||||||
|
order[#order + 1] = label
|
||||||
|
end
|
||||||
|
s.n = s.n + 1
|
||||||
|
s.total = s.total + dt
|
||||||
|
if dt > s.max then s.max = dt end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Perf.add(label, t0)
|
||||||
|
if t0 == nil then return end
|
||||||
|
local dt = clock() - t0
|
||||||
|
bump(Perf.labels, Perf.order, label, dt)
|
||||||
|
local seg = segmentEntry()
|
||||||
|
if seg then bump(seg.labels, seg.order, label, dt) end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Wrap a function in a table, in place. Used by drivers to instrument
|
||||||
|
-- module internals they do not own; the mod's own code calls now()/add()
|
||||||
|
-- directly so the label is visible at the site.
|
||||||
|
function Perf.wrap(tbl, name, label)
|
||||||
|
local orig = tbl and tbl[name]
|
||||||
|
if not orig then return false end
|
||||||
|
tbl[name] = function(...)
|
||||||
|
if not Perf.enabled then return orig(...) end
|
||||||
|
local t0 = clock()
|
||||||
|
local a, b, c, d = orig(...)
|
||||||
|
Perf.add(label or name, t0)
|
||||||
|
return a, b, c, d
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------- counters
|
||||||
|
|
||||||
|
function Perf.count(name, by)
|
||||||
|
if not Perf.enabled then return end
|
||||||
|
Perf.counters[name] = (Perf.counters[name] or 0) + (by or 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- --------------------------------------------------------------- frames
|
||||||
|
--
|
||||||
|
-- Called once per RENDERED frame (the endFrame seam), not once per
|
||||||
|
-- logic update: a scripted run can step the game many times per render,
|
||||||
|
-- and a frame the player never saw cannot have hitched for them.
|
||||||
|
|
||||||
|
local lastFrame = nil
|
||||||
|
|
||||||
|
function Perf.frame()
|
||||||
|
if not Perf.enabled then return end
|
||||||
|
local t = clock()
|
||||||
|
if lastFrame then
|
||||||
|
local dt = t - lastFrame
|
||||||
|
local n = Perf.frameCount + 1
|
||||||
|
Perf.frameCount = n
|
||||||
|
Perf.frames[(n - 1) % Perf.RING + 1] = dt
|
||||||
|
local seg = segmentEntry()
|
||||||
|
if seg then seg.frames[#seg.frames + 1] = dt end
|
||||||
|
end
|
||||||
|
lastFrame = t
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Discard the pending frame stamp: after a long blocking operation the
|
||||||
|
-- next frame delta would include it and libel the renderer.
|
||||||
|
function Perf.resync()
|
||||||
|
lastFrame = Perf.enabled and clock() or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------ reporting
|
||||||
|
|
||||||
|
local function percentile(sorted, p)
|
||||||
|
local n = #sorted
|
||||||
|
if n == 0 then return 0 end
|
||||||
|
local i = math.ceil(p * n)
|
||||||
|
if i < 1 then i = 1 end
|
||||||
|
if i > n then i = n end
|
||||||
|
return sorted[i]
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Frame statistics in MILLISECONDS. p95/p99 rather than the average
|
||||||
|
-- because smoothness is a tail property: a run that averages 9ms and
|
||||||
|
-- spikes to 60ms four times reads as stuttering, and its average reads
|
||||||
|
-- as fine.
|
||||||
|
function Perf.frameStats(list)
|
||||||
|
local src = list or Perf.frames
|
||||||
|
local sorted = {}
|
||||||
|
for i = 1, #src do sorted[i] = src[i] * 1000 end
|
||||||
|
table.sort(sorted)
|
||||||
|
local n = #sorted
|
||||||
|
local total = 0
|
||||||
|
for i = 1, n do total = total + sorted[i] end
|
||||||
|
local over16, over33 = 0, 0
|
||||||
|
for i = 1, n do
|
||||||
|
if sorted[i] > 16.7 then over16 = over16 + 1 end
|
||||||
|
if sorted[i] > 33.3 then over33 = over33 + 1 end
|
||||||
|
end
|
||||||
|
return {
|
||||||
|
n = n,
|
||||||
|
avg = n > 0 and total / n or 0,
|
||||||
|
p50 = percentile(sorted, 0.50),
|
||||||
|
p95 = percentile(sorted, 0.95),
|
||||||
|
p99 = percentile(sorted, 0.99),
|
||||||
|
worst = n > 0 and sorted[n] or 0,
|
||||||
|
over16 = over16,
|
||||||
|
over33 = over33,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
function Perf.reset()
|
||||||
|
Perf.labels, Perf.order = {}, {}
|
||||||
|
Perf.counters = {}
|
||||||
|
Perf.frames, Perf.frameCount = {}, 0
|
||||||
|
Perf.segments = {}
|
||||||
|
Perf.segment = nil
|
||||||
|
lastFrame = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function sortedLabels(store, order)
|
||||||
|
local out = {}
|
||||||
|
for _, lbl in ipairs(order) do out[#out + 1] = lbl end
|
||||||
|
table.sort(out, function(a, b) return store[a].total > store[b].total end)
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
function Perf.printReport(title)
|
||||||
|
print(("[perf] ==== %s ===="):format(tostring(title or "report")))
|
||||||
|
local f = Perf.frameStats()
|
||||||
|
print(("[perf] frames n=%d avg=%.2fms p50=%.2f p95=%.2f p99=%.2f worst=%.2f >16.7ms=%d >33.3ms=%d")
|
||||||
|
:format(f.n, f.avg, f.p50, f.p95, f.p99, f.worst, f.over16, f.over33))
|
||||||
|
for _, seg in ipairs(Perf.segments) do
|
||||||
|
local s = Perf.frameStats(seg.frames)
|
||||||
|
print(("[perf] seg %-28s n=%4d avg=%6.2f p95=%6.2f p99=%6.2f worst=%7.2f >16.7=%3d >33.3=%3d")
|
||||||
|
:format(seg.name, s.n, s.avg, s.p95, s.p99, s.worst, s.over16, s.over33))
|
||||||
|
end
|
||||||
|
print("[perf] ---- labels (ms, sorted by total) ----")
|
||||||
|
for _, lbl in ipairs(sortedLabels(Perf.labels, Perf.order)) do
|
||||||
|
local s = Perf.labels[lbl]
|
||||||
|
print(("[perf] %-46s n=%6d total=%9.1f max=%8.2f")
|
||||||
|
:format(lbl, s.n, s.total * 1000, s.max * 1000))
|
||||||
|
end
|
||||||
|
local names = {}
|
||||||
|
for k in pairs(Perf.counters) do names[#names + 1] = k end
|
||||||
|
table.sort(names)
|
||||||
|
if #names > 0 then print("[perf] ---- counters ----") end
|
||||||
|
for _, k in ipairs(names) do
|
||||||
|
print(("[perf] %-46s %d"):format(k, Perf.counters[k]))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------ json
|
||||||
|
--
|
||||||
|
-- Hand-rolled rather than pulled from the engine: the report has to be
|
||||||
|
-- readable by a diff tool between two runs, and that means stable key
|
||||||
|
-- ORDER, which a generic serializer does not promise.
|
||||||
|
|
||||||
|
local function q(s)
|
||||||
|
return '"' .. tostring(s):gsub('[%c"\\]', function(c)
|
||||||
|
if c == '"' then return '\\"' end
|
||||||
|
if c == "\\" then return "\\\\" end
|
||||||
|
return ("\\u%04x"):format(c:byte())
|
||||||
|
end) .. '"'
|
||||||
|
end
|
||||||
|
|
||||||
|
local function num(x)
|
||||||
|
return ("%.4f"):format(x)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function statsJson(f)
|
||||||
|
return ("{\"n\":%d,\"avg\":%s,\"p50\":%s,\"p95\":%s,\"p99\":%s,\"worst\":%s,\"over16\":%d,\"over33\":%d}")
|
||||||
|
:format(f.n, num(f.avg), num(f.p50), num(f.p95), num(f.p99),
|
||||||
|
num(f.worst), f.over16, f.over33)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function labelsJson(store, order)
|
||||||
|
local parts = {}
|
||||||
|
for _, lbl in ipairs(sortedLabels(store, order)) do
|
||||||
|
local s = store[lbl]
|
||||||
|
parts[#parts + 1] = ("%s:{\"n\":%d,\"total\":%s,\"max\":%s}")
|
||||||
|
:format(q(lbl), s.n, num(s.total * 1000), num(s.max * 1000))
|
||||||
|
end
|
||||||
|
return "{" .. table.concat(parts, ",") .. "}"
|
||||||
|
end
|
||||||
|
|
||||||
|
function Perf.toJson(meta)
|
||||||
|
local parts = {}
|
||||||
|
parts[#parts + 1] = "{"
|
||||||
|
parts[#parts + 1] = "\"meta\":{"
|
||||||
|
local m = {}
|
||||||
|
for k, v in pairs(meta or {}) do
|
||||||
|
m[#m + 1] = q(k) .. ":" .. (type(v) == "number" and num(v) or q(v))
|
||||||
|
end
|
||||||
|
table.sort(m)
|
||||||
|
parts[#parts + 1] = table.concat(m, ",") .. "},"
|
||||||
|
parts[#parts + 1] = "\"frames\":" .. statsJson(Perf.frameStats()) .. ","
|
||||||
|
parts[#parts + 1] = "\"segments\":{"
|
||||||
|
local segs = {}
|
||||||
|
for _, seg in ipairs(Perf.segments) do
|
||||||
|
segs[#segs + 1] = q(seg.name) .. ":{\"frames\":"
|
||||||
|
.. statsJson(Perf.frameStats(seg.frames))
|
||||||
|
.. ",\"labels\":" .. labelsJson(seg.labels, seg.order) .. "}"
|
||||||
|
end
|
||||||
|
parts[#parts + 1] = table.concat(segs, ",") .. "},"
|
||||||
|
parts[#parts + 1] = "\"labels\":" .. labelsJson(Perf.labels, Perf.order) .. ","
|
||||||
|
local cs = {}
|
||||||
|
for k, v in pairs(Perf.counters) do cs[#cs + 1] = q(k) .. ":" .. v end
|
||||||
|
table.sort(cs)
|
||||||
|
parts[#parts + 1] = "\"counters\":{" .. table.concat(cs, ",") .. "}"
|
||||||
|
parts[#parts + 1] = "}"
|
||||||
|
return table.concat(parts, "")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Written through love.filesystem (the save directory) rather than io:
|
||||||
|
-- a driver run and an Android session both have one, and neither is
|
||||||
|
-- guaranteed a writable working directory.
|
||||||
|
function Perf.write(name, meta)
|
||||||
|
local body = Perf.toJson(meta)
|
||||||
|
if love and love.filesystem then
|
||||||
|
pcall(love.filesystem.createDirectory, "ds_bench")
|
||||||
|
local ok = pcall(love.filesystem.write, "ds_bench/" .. name .. ".json", body)
|
||||||
|
if ok then
|
||||||
|
print("[perf] wrote " .. tostring(love.filesystem.getSaveDirectory())
|
||||||
|
.. "/ds_bench/" .. name .. ".json")
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
print("[perf] JSON " .. name .. ": " .. body)
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ----------------------------------------------------------- draw stats
|
||||||
|
--
|
||||||
|
-- love.graphics.getStats() resets per frame, so it is only meaningful
|
||||||
|
-- read at the END of a frame -- which is where Perf.frame() runs.
|
||||||
|
|
||||||
|
function Perf.drawStats()
|
||||||
|
if not (love and love.graphics and love.graphics.getStats) then return end
|
||||||
|
local s = love.graphics.getStats()
|
||||||
|
Perf.count("stat.drawcalls", s.drawcalls or 0)
|
||||||
|
Perf.count("stat.canvasswitches", s.canvasswitches or 0)
|
||||||
|
Perf.count("stat.shaderswitches", s.shaderswitches or 0)
|
||||||
|
Perf.count("stat.frames", 1)
|
||||||
|
Perf.texturememory = s.texturememory
|
||||||
|
Perf.canvases = s.canvases
|
||||||
|
Perf.images = s.images
|
||||||
|
end
|
||||||
|
|
||||||
|
return Perf
|
||||||
+30
-2
@@ -130,17 +130,23 @@ local SHADER = [[
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
#ifdef PIXEL
|
#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) {
|
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
|
||||||
// the same alpha discard the main pass uses: a sprite card casts its
|
// the same alpha discard the main pass uses: a sprite card casts its
|
||||||
// silhouette, not its 16x16 bounding box
|
// silhouette, not its 16x16 bounding box
|
||||||
if (Texel(tex, tc).a < 0.5) discard;
|
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;
|
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
|
#endif
|
||||||
]]
|
]]
|
||||||
|
|
||||||
|
ShadowMap._source = function() return SHADER end -- named for the suite
|
||||||
|
|
||||||
local shader = nil -- nil = untried, false = unavailable
|
local shader = nil -- nil = untried, false = unavailable
|
||||||
local canvas = nil -- nil = untried, false = unavailable
|
local canvas = nil -- nil = untried, false = unavailable
|
||||||
local canvasRes = 0 -- the edge `canvas` was made at
|
local canvasRes = 0 -- the edge `canvas` was made at
|
||||||
@@ -440,6 +446,9 @@ function ShadowMap.begin(cx, cy, vw, vh)
|
|||||||
love.graphics.setShader(sh)
|
love.graphics.setShader(sh)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
pcall(sh.send, sh, "lightVP", "row", ShadowMap.clipVP)
|
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
|
drawing = true
|
||||||
ready = false
|
ready = false
|
||||||
return true
|
return true
|
||||||
@@ -448,6 +457,25 @@ end
|
|||||||
-- Draw one caster. Same signature as Voxel3D.draw minus the camera-ward
|
-- 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
|
-- pull, which is a trick for the VIEW's depth buffer and would drag a
|
||||||
-- shadow off whatever throws it.
|
-- 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)
|
function ShadowMap.draw(mesh, texture, model)
|
||||||
if not (drawing and mesh) then return end
|
if not (drawing and mesh) then return end
|
||||||
local sh = getShader()
|
local sh = getShader()
|
||||||
|
|||||||
+161
-50
@@ -16,11 +16,13 @@
|
|||||||
-- as four stripes. No clouds, nothing moving.
|
-- as four stripes. No clouds, nothing moving.
|
||||||
--
|
--
|
||||||
-- NOTHING IS RESAMPLED, which is the whole of why it is drawn this way. There is
|
-- NOTHING IS RESAMPLED, which is the whole of why it is drawn this way. There is
|
||||||
-- no baked 160x144 picture scaled up to the window, no downsized buffer blown
|
-- no baked 160x144 picture scaled up to the window and no downsized buffer blown
|
||||||
-- back up, no texture of any kind: one full-region rectangle through a shader
|
-- back up: one full-region rectangle through a shader that answers every pixel
|
||||||
-- that answers every pixel from its own canvas coordinate. A pixel of sky is
|
-- from its own canvas coordinate. A pixel of sky is computed at the size it is
|
||||||
-- computed at the size it is displayed at, so there is nothing for a filter to
|
-- displayed at, so there is nothing for a filter to soften and nothing to go
|
||||||
-- soften and nothing to go stale when the window or the zoom changes.
|
-- stale when the window or the zoom changes. The shader does bind one texture,
|
||||||
|
-- but it is a palette rather than an image -- the bands, one texel each, sampled
|
||||||
|
-- nearest (see rampFor, and why it is not a uniform array).
|
||||||
--
|
--
|
||||||
-- THE PIXEL GRID follows the zoom for the same reason. Bands and dither cells
|
-- THE PIXEL GRID follows the zoom for the same reason. Bands and dither cells
|
||||||
-- are measured in DIORAMA pixels -- the pass's own pixels-per-world-pixel, handed
|
-- are measured in DIORAMA pixels -- the pass's own pixels-per-world-pixel, handed
|
||||||
@@ -51,13 +53,11 @@ local V = ...
|
|||||||
local DayNight = V.require("DayNight")
|
local DayNight = V.require("DayNight")
|
||||||
local PaletteFX = require("src.render.PaletteFX")
|
local PaletteFX = require("src.render.PaletteFX")
|
||||||
|
|
||||||
local unpack = table.unpack or unpack
|
|
||||||
|
|
||||||
local Sky = {}
|
local Sky = {}
|
||||||
|
|
||||||
-- The shader carries a fixed-size array, because a GLSL uniform array is a
|
-- The most bands a phase palette may paint with. Eight leaves headroom over
|
||||||
-- fixed size; eight leaves headroom over DayNight's six-band phase palettes
|
-- DayNight's six-band ones without paying for more; the ramp the shader reads
|
||||||
-- without paying for more.
|
-- them from is built at the width actually used, so the cap costs nothing.
|
||||||
Sky.MAX_BANDS = 8
|
Sky.MAX_BANDS = 8
|
||||||
|
|
||||||
-- The checkerboard between bands. DITHER_START is how far down a band it begins,
|
-- The checkerboard between bands. DITHER_START is how far down a band it begins,
|
||||||
@@ -84,7 +84,7 @@ Sky.SPAN = 0.23
|
|||||||
--
|
--
|
||||||
-- Memoised, because this runs once a frame and the answer only moves when the
|
-- Memoised, because this runs once a frame and the answer only moves when the
|
||||||
-- mode does.
|
-- mode does.
|
||||||
local cache = { bands = nil, key = {} }
|
local cache = { bands = nil, key = {}, ramp = nil }
|
||||||
|
|
||||||
function Sky.bands()
|
function Sky.bands()
|
||||||
local pal = DayNight.palette()
|
local pal = DayNight.palette()
|
||||||
@@ -102,6 +102,11 @@ function Sky.bands()
|
|||||||
end
|
end
|
||||||
if same then return cache.bands end
|
if same then return cache.bands end
|
||||||
|
|
||||||
|
-- the ramp is these bands as a texture (see rampFor); a new list is a new
|
||||||
|
-- ramp, and the old one is nothing's to keep
|
||||||
|
if cache.ramp and cache.ramp.release then pcall(cache.ramp.release, cache.ramp) end
|
||||||
|
cache.ramp, cache.rampFor = nil, nil
|
||||||
|
|
||||||
local bands = {}
|
local bands = {}
|
||||||
for i = 1, n do
|
for i = 1, n do
|
||||||
-- backwards: the palette's darkest rung is the top band
|
-- backwards: the palette's darkest rung is the top band
|
||||||
@@ -150,17 +155,18 @@ end
|
|||||||
|
|
||||||
-- ------- the pass
|
-- ------- the pass
|
||||||
--
|
--
|
||||||
-- One rectangle, one shader, no texture. Every pixel answers for itself from its
|
-- One rectangle, one shader. Every pixel answers for itself from its canvas
|
||||||
-- canvas coordinate, so the sky is drawn at exactly the resolution it is
|
-- coordinate, so the sky is drawn at exactly the resolution it is displayed at
|
||||||
-- displayed at -- there is no image being scaled and so nothing to be soft.
|
-- -- there is no image being scaled and so nothing to be soft. The one texture
|
||||||
|
-- bound is the band ramp, which is a PALETTE and not a picture: n texels wide,
|
||||||
|
-- sampled nearest, one lookup per pixel (see rampFor).
|
||||||
--
|
--
|
||||||
-- `cell` quantises BOTH the band edges and the dither: the y a pixel is judged
|
-- `cell` quantises BOTH the band edges and the dither: the y a pixel is judged
|
||||||
-- by is the top of its own cell row, so a whole cell row is one colour and every
|
-- by is the top of its own cell row, so a whole cell row is one colour and every
|
||||||
-- edge in the sky lands on the diorama's pixel grid.
|
-- edge in the sky lands on the diorama's pixel grid.
|
||||||
local SHADER_SRC = [[
|
local SHADER_SRC = [[
|
||||||
#define MAXB %d
|
uniform Image ramp; // the bands, one texel each, top of the sky first
|
||||||
uniform vec3 bands[MAXB];
|
uniform float count; // how many texels wide that ramp is
|
||||||
uniform int count;
|
|
||||||
uniform float edge; // the sky's bottom, in canvas pixels
|
uniform float edge; // the sky's bottom, in canvas pixels
|
||||||
uniform float cell; // the diorama's pixel size, in canvas pixels
|
uniform float cell; // the diorama's pixel size, in canvas pixels
|
||||||
uniform float start; // where the checker begins inside a band
|
uniform float start; // where the checker begins inside a band
|
||||||
@@ -170,26 +176,23 @@ uniform vec2 glowPos; // the sun disc, in canvas pixels
|
|||||||
uniform float glowInvR; // 1 / the glow's reach
|
uniform float glowInvR; // 1 / the glow's reach
|
||||||
uniform vec3 glowColor;
|
uniform vec3 glowColor;
|
||||||
|
|
||||||
// Indexed through a loop counter, which every GLSL ES compiler accepts for a
|
// Band `i`, read from its own texel centre. The index is clamped rather than
|
||||||
// uniform array; a bare bands[idx] is not portable.
|
// trusted: `pos` below can land exactly on `count` when the arithmetic is
|
||||||
vec3 bandAt(int idx) {
|
// carried at mediump -- which is the fragment default on GLSL ES -- and a
|
||||||
vec3 c = bands[0];
|
// sample past the last band must be the last band, not whatever is off the
|
||||||
for (int i = 1; i < MAXB; i++) {
|
// end of the image.
|
||||||
if (i == idx) { c = bands[i]; }
|
vec3 bandAt(float i) {
|
||||||
}
|
return Texel(ramp, vec2((clamp(i, 0.0, count - 1.0) + 0.5) / count, 0.5)).rgb;
|
||||||
return c;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
|
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
|
||||||
float n = float(count);
|
|
||||||
float row = floor(sc.y / cell) * cell; // top of this cell row
|
float row = floor(sc.y / cell) * cell; // top of this cell row
|
||||||
float pos = clamp(row / max(edge, 1.0), 0.0, 0.999999) * n;
|
float pos = min(row / max(edge, 1.0), 1.0) * count;
|
||||||
float base = floor(pos);
|
float base = min(floor(pos), count - 1.0);
|
||||||
int idx = int(base);
|
vec3 c = bandAt(base);
|
||||||
vec3 c = bandAt(idx);
|
|
||||||
float parity = mod(floor(sc.x / cell) + floor(sc.y / cell), 2.0);
|
float parity = mod(floor(sc.x / cell) + floor(sc.y / cell), 2.0);
|
||||||
if (idx < count - 1 && (pos - base) > start) {
|
if (base < count - 1.0 && (pos - base) > start) {
|
||||||
if (parity < 0.5) { c = bandAt(idx + 1); }
|
if (parity < 0.5) { c = bandAt(base + 1.0); }
|
||||||
}
|
}
|
||||||
// The sunset's warmth, radiating from the disc: posterised to a few rungs
|
// The sunset's warmth, radiating from the disc: posterised to a few rungs
|
||||||
// and checker-dithered between them -- the same 8-bit move as the bands,
|
// and checker-dithered between them -- the same 8-bit move as the bands,
|
||||||
@@ -207,14 +210,87 @@ vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
|
|||||||
}
|
}
|
||||||
]]
|
]]
|
||||||
|
|
||||||
|
-- ------- the ramp
|
||||||
|
--
|
||||||
|
-- The bands as a one-texel-per-band TEXTURE rather than as a uniform array,
|
||||||
|
-- which is what they used to be: `uniform vec3 bands[8]`, filled from Lua and
|
||||||
|
-- read through a loop counter. On desktop GL that is as portable as it looks.
|
||||||
|
-- On Android it was not. The sky's lower bands came back BLACK -- a hard-edged
|
||||||
|
-- strip running from partway down the gradient to the horizon point, with the
|
||||||
|
-- moon still drawn correctly over it, and with the haze BELOW the sky (the
|
||||||
|
-- palest band again, but delivered by love.graphics.clear instead of by the
|
||||||
|
-- array) landing in exactly the right colour. Same colour, two routes, one of
|
||||||
|
-- them black: the fault was the array, not the palette.
|
||||||
|
--
|
||||||
|
-- Which of the ES failure modes it was hardly matters -- a driver that
|
||||||
|
-- truncates a partially-filled array, a fragment uniform budget the guaranteed
|
||||||
|
-- floor of which is sixteen vectors (eight bands plus the glow plus LOVE's own
|
||||||
|
-- built-ins is over it), a reflection that finds bands[0] and nothing after --
|
||||||
|
-- because they all have the same shape: slots past the first few read as zero,
|
||||||
|
-- and zero is black.
|
||||||
|
--
|
||||||
|
-- A sampler has none of them. One texture unit replaces eight uniform vectors,
|
||||||
|
-- there is no array to index and no budget to overrun, and a texel that does
|
||||||
|
-- not exist cannot read as black because the image is built at exactly the
|
||||||
|
-- width the shader divides by. Nearest and clamped, so a sample lands on one
|
||||||
|
-- band's own colour and an out-of-range one lands on the end band rather than
|
||||||
|
-- on nothing.
|
||||||
|
--
|
||||||
|
-- Rebuilt only when the bands move, which is when the clock or the display
|
||||||
|
-- mode does; Sky.bands drops it as it rebuilds the list it is made from.
|
||||||
|
local function rampFor(bands)
|
||||||
|
if cache.ramp and cache.rampFor == bands then return cache.ramp end
|
||||||
|
if not (love.image and love.image.newImageData
|
||||||
|
and love.graphics and love.graphics.newImage) then return nil end
|
||||||
|
local n = #bands
|
||||||
|
if n < 1 then return nil end
|
||||||
|
local ok, data = pcall(love.image.newImageData, n, 1)
|
||||||
|
if not (ok and data) then return nil end
|
||||||
|
for i = 1, n do
|
||||||
|
local c = bands[i]
|
||||||
|
pcall(data.setPixel, data, i - 1, 0, c[1], c[2], c[3], 1)
|
||||||
|
end
|
||||||
|
local built, img = pcall(love.graphics.newImage, data)
|
||||||
|
if not (built and img) then return nil end
|
||||||
|
-- nearest: a band is a flat colour, not something to interpolate between.
|
||||||
|
-- clamp: the shader clamps its index too, so this is the second of two
|
||||||
|
-- guards against ever sampling off the end -- and it returns the edge band.
|
||||||
|
pcall(img.setFilter, img, "nearest", "nearest")
|
||||||
|
pcall(img.setWrap, img, "clamp", "clamp")
|
||||||
|
cache.ramp, cache.rampFor = img, bands
|
||||||
|
return img
|
||||||
|
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 shader = nil -- nil = untried, false = unavailable
|
||||||
|
|
||||||
local function getShader()
|
local function getShader()
|
||||||
if shader == nil then
|
if shader == nil then
|
||||||
shader = false
|
shader = false
|
||||||
if love.graphics and love.graphics.newShader then
|
if love.graphics and love.graphics.newShader then
|
||||||
local ok, sh = pcall(love.graphics.newShader,
|
local ok, sh = pcall(love.graphics.newShader, SHADER_SRC)
|
||||||
SHADER_SRC:format(Sky.MAX_BANDS))
|
|
||||||
if ok and sh then
|
if ok and sh then
|
||||||
shader = sh
|
shader = sh
|
||||||
elseif V and V.mod and V.mod.log then
|
elseif V and V.mod and V.mod.log then
|
||||||
@@ -268,20 +344,51 @@ end
|
|||||||
Sky.DISC_FRAC = 0.030 -- disc radius, as a fraction of the frame height
|
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
|
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
|
-- 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 },
|
-- Public because the water's reflection draws the same moon (see Water):
|
||||||
{ -0.15, 0.7 }, { 0.05, 0.05 } }
|
-- 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 function paintDisc(body, edge, cell, w, h)
|
||||||
local g = love.graphics
|
local g = love.graphics
|
||||||
if not (body and body.y and g.setScissor) then return end
|
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 = Sky.discShades(body.moon)
|
||||||
local shades = PaletteFX.effectiveColors(src) or src
|
local twilight = looming(body)
|
||||||
local twilight = (body.glowAmt or 0) > 0.25 and not body.moon
|
local _, r = Sky.discRadius(h, cell, body)
|
||||||
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
|
|
||||||
-- snap the centre to the cell grid, like everything else in this sky
|
-- snap the centre to the cell grid, like everything else in this sky
|
||||||
local bx = math.floor(body.x / cell) * cell + cell / 2
|
local bx = math.floor(body.x / cell) * cell + cell / 2
|
||||||
local by = math.floor(body.y / cell) * cell + cell / 2
|
local by = math.floor(body.y / cell) * cell + cell / 2
|
||||||
@@ -358,12 +465,13 @@ function Sky.paint(w, h, sky, horizonY, cell, body)
|
|||||||
|
|
||||||
local glowAmt = body and not body.moon and (body.glowAmt or 0) or 0
|
local glowAmt = body and not body.moon and (body.glowAmt or 0) or 0
|
||||||
local sh = getShader()
|
local sh = getShader()
|
||||||
|
local ramp = sh and rampFor(bands)
|
||||||
|
if not ramp then sh = nil end -- no ramp, no gradient: paint it flat
|
||||||
if sh then
|
if sh then
|
||||||
local sent = pcall(function()
|
local sent = pcall(function()
|
||||||
-- one send per band would be one uniform lookup per band; the array takes
|
-- the bands arrive as a texture, one texel each, and `count` is that
|
||||||
-- them all at once, and it must be the LAST argument or Lua truncates the
|
-- texture's width -- see rampFor for why they are not a uniform array
|
||||||
-- unpack to a single value
|
sh:send("ramp", ramp)
|
||||||
sh:send("bands", unpack(bands))
|
|
||||||
sh:send("count", #bands)
|
sh:send("count", #bands)
|
||||||
sh:send("edge", edge)
|
sh:send("edge", edge)
|
||||||
sh:send("cell", cell)
|
sh:send("cell", cell)
|
||||||
@@ -373,7 +481,7 @@ function Sky.paint(w, h, sky, horizonY, cell, body)
|
|||||||
if glowAmt > 0 then
|
if glowAmt > 0 then
|
||||||
local gc = body.glowColor or { 248, 224, 168 }
|
local gc = body.glowColor or { 248, 224, 168 }
|
||||||
sh:send("glowPos", { body.x, body.y })
|
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 })
|
sh:send("glowColor", { gc[1] / 255, gc[2] / 255, gc[3] / 255 })
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
@@ -399,9 +507,12 @@ function Sky.paint(w, h, sky, horizonY, cell, body)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- Drop the compiled shader (window resize, hot reload), so a re-created graphics
|
-- Drop the compiled shader (window resize, hot reload), so a re-created graphics
|
||||||
-- context builds a new one instead of drawing with a handle from the old.
|
-- context builds a new one instead of drawing with a handle from the old. The
|
||||||
|
-- ramp is a GPU object on the same context and goes with it.
|
||||||
function Sky.invalidate()
|
function Sky.invalidate()
|
||||||
shader = nil
|
shader = nil
|
||||||
|
if cache.ramp and cache.ramp.release then pcall(cache.ramp.release, cache.ramp) end
|
||||||
|
cache.ramp, cache.rampFor = nil, nil
|
||||||
end
|
end
|
||||||
|
|
||||||
return Sky
|
return Sky
|
||||||
|
|||||||
+13
-5
@@ -2253,14 +2253,22 @@ local function buildFigure(S, map, fig, tx, ty, perRow)
|
|||||||
-- He stands ON the furniture he was drawn into -- the same lift a pinned
|
-- He stands ON the furniture he was drawn into -- the same lift a pinned
|
||||||
-- prop above a pinned box takes (see buildObject), and gated the same
|
-- prop above a pinned box takes (see buildObject), and gated the same
|
||||||
-- way: a thing set down on furniture occupies a BLOCKED cell, while a
|
-- way: a thing set down on furniture occupies a BLOCKED cell, while a
|
||||||
-- seat you merely walk up to is in a walkable one.
|
-- seat you merely walk up to is in a walkable one. The row under his
|
||||||
|
-- card is SCANNED for the tallest authored upright rather than read at
|
||||||
|
-- its west corner: the corner tile can be furniture that is not his
|
||||||
|
-- seat (the couch's raised backrest column stands there, `top` art and
|
||||||
|
-- taller than the cushion he actually sits on).
|
||||||
local baseY = 0
|
local baseY = 0
|
||||||
local bs = S.shapeAt[keyOf(tx, ty + fig.h)]
|
|
||||||
local blocked = not map:isWalkableCell(math.floor(tx / 2),
|
local blocked = not map:isWalkableCell(math.floor(tx / 2),
|
||||||
math.floor((ty + fig.h - 1) / 2))
|
math.floor((ty + fig.h - 1) / 2))
|
||||||
if blocked and bs and bs.authored and bs.art == "upright"
|
if blocked then
|
||||||
and (bs.h or 0) > 0 then
|
for dx = 0, fig.w - 1 do
|
||||||
baseY = bs.h
|
local bs = S.shapeAt[keyOf(tx + dx, ty + fig.h)]
|
||||||
|
if bs and bs.authored and bs.art == "upright"
|
||||||
|
and (bs.h or 0) > baseY then
|
||||||
|
baseY = bs.h
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local atlasW = map.tileset.imageWidth or 128
|
local atlasW = map.tileset.imageWidth or 128
|
||||||
|
|||||||
@@ -270,7 +270,12 @@ local function readback(image)
|
|||||||
local prev = love.graphics.getCanvas()
|
local prev = love.graphics.getCanvas()
|
||||||
local ok, data = pcall(function()
|
local ok, data = pcall(function()
|
||||||
local w, h = image:getDimensions()
|
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.setCanvas(canvas)
|
||||||
love.graphics.clear(0, 0, 0, 0)
|
love.graphics.clear(0, 0, 0, 0)
|
||||||
-- straight copy: no blending against the cleared target, no tint from
|
-- straight copy: no blending against the cleared target, no tint from
|
||||||
|
|||||||
@@ -83,6 +83,10 @@ local FALLBACK_HEIGHTS = {
|
|||||||
bed = 7,
|
bed = 7,
|
||||||
stool = 8,
|
stool = 8,
|
||||||
counter = 8,
|
counter = 8,
|
||||||
|
-- the raised back band of low seating: the Center couch's west strip
|
||||||
|
-- is drawn from above like the rest of the couch, but depicts the
|
||||||
|
-- back and arm rising over the 8px seat
|
||||||
|
backrest = 12,
|
||||||
table = 12,
|
table = 12,
|
||||||
desk = 24,
|
desk = 24,
|
||||||
prop = 16,
|
prop = 16,
|
||||||
@@ -145,6 +149,9 @@ local ART = {
|
|||||||
-- profile archetype Structures builds real steps for -- rising flights
|
-- profile archetype Structures builds real steps for -- rising flights
|
||||||
-- for stairs leading up, sunken stairwells for stairs leading down
|
-- for stairs leading up, sunken stairwells for stairs leading down
|
||||||
bed = "top",
|
bed = "top",
|
||||||
|
-- a backrest's art is the couch seen from above, so like the bed it
|
||||||
|
-- rides the top face of its taller box
|
||||||
|
backrest = "top",
|
||||||
stool = "billboard",
|
stool = "billboard",
|
||||||
-- half-cell furniture: a service counter, a low couch. One 8px band,
|
-- half-cell furniture: a service counter, a low couch. One 8px band,
|
||||||
-- so exactly the drawing's bottom row stands up as the front and
|
-- so exactly the drawing's bottom row stands up as the front and
|
||||||
@@ -295,6 +302,24 @@ function TileShape.forMap(map)
|
|||||||
if cache[id] then return cache[id] end
|
if cache[id] then return cache[id] end
|
||||||
|
|
||||||
local heights = TileShape.heights()
|
local heights = TileShape.heights()
|
||||||
|
-- Per-tileset height overrides (a tileset entry's `heights`): the class
|
||||||
|
-- vocabulary is global but the drawings are not -- the DOJO lab tables
|
||||||
|
-- are drawn 6px tall where the default `table` is 12 -- and the height
|
||||||
|
-- a sprite RIDES at (VoxelScene.groundAt) must be the height the art
|
||||||
|
-- actually stands, or the starter balls float over their own table.
|
||||||
|
-- Same gate as the global list: known classes, numbers only.
|
||||||
|
do
|
||||||
|
local s = load()
|
||||||
|
local entry = s and s.tilesets and s.tilesets[id]
|
||||||
|
local over = entry and entry.heights
|
||||||
|
if type(over) == "table" then
|
||||||
|
for class, h in pairs(over) do
|
||||||
|
if type(h) == "number" and FALLBACK_HEIGHTS[class] then
|
||||||
|
heights[class] = h
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
local authored = authoredGroups(id, heights)
|
local authored = authoredGroups(id, heights)
|
||||||
local count = math.floor((tileset.imageWidth or 128) / 8)
|
local count = math.floor((tileset.imageWidth or 128) / 8)
|
||||||
* math.floor((tileset.imageHeight or 48) / 8)
|
* math.floor((tileset.imageHeight or 48) / 8)
|
||||||
|
|||||||
+239
-15
@@ -283,8 +283,66 @@ local activeShader = nil -- the variant this pass bound
|
|||||||
-- resize, so the pair is stable for a session.
|
-- resize, so the pair is stable for a session.
|
||||||
local slots = {}
|
local slots = {}
|
||||||
local canvas, canvasW, canvasH = nil, 0, 0 -- the slot this pass bound
|
local canvas, canvasW, canvasH = nil, 0, 0 -- the slot this pass bound
|
||||||
|
local held = nil -- and the whole record for it
|
||||||
local active = false
|
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()
|
local IDENTITY = Mat4.identity()
|
||||||
|
|
||||||
-- Whether the driver admits to supporting derivatives. Only a hint --
|
-- Whether the driver admits to supporting derivatives. Only a hint --
|
||||||
@@ -379,6 +437,41 @@ end
|
|||||||
-- way either way.
|
-- way either way.
|
||||||
Voxel3D.camera = nil
|
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
|
-- View and projection for a `vw` x `vh` world-pixel view centred on
|
||||||
-- (cx, cy) in world pixels. Returns the combined matrix.
|
-- (cx, cy) in world pixels. Returns the combined matrix.
|
||||||
function Voxel3D.viewProjection(cx, cy, vw, vh)
|
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
|
-- 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
|
-- question about which way this camera looks, and only these two answer it
|
||||||
Voxel3D.focus = focus
|
Voxel3D.focus = focus
|
||||||
|
setLook(eye, focus)
|
||||||
local dx = eye[1] - focus[1]
|
local dx = eye[1] - focus[1]
|
||||||
local dy = eye[2] - focus[2]
|
local dy = eye[2] - focus[2]
|
||||||
local dz = eye[3] - focus[3]
|
local dz = eye[3] - focus[3]
|
||||||
local dist = math.max(1, math.sqrt(dx * dx + dy * dy + dz * dz))
|
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,
|
local proj = Mat4.perspective(cam.fov, vw / vh,
|
||||||
math.max(1, dist * 0.05), dist * 4 + 4096)
|
math.max(1, dist * 0.05), dist * 4 + 4096)
|
||||||
-- the same clip-space Y flip the orbit needs, for the same reason: we
|
-- the same clip-space Y flip the orbit needs, for the same reason: we
|
||||||
@@ -409,12 +507,14 @@ function Voxel3D.viewProjection(cx, cy, vw, vh)
|
|||||||
-- the FOV that makes a straight-down camera at `dist` frame exactly `vh`
|
-- the FOV that makes a straight-down camera at `dist` frame exactly `vh`
|
||||||
-- world pixels, which is the framing the flat view already has
|
-- world pixels, which is the framing the flat view already has
|
||||||
local fov = 2 * math.atan(1 / (2 * focal))
|
local fov = 2 * math.atan(1 / (2 * focal))
|
||||||
|
Voxel3D.fovY = fov
|
||||||
|
|
||||||
local focus = { cx, 0, cy }
|
local focus = { cx, 0, cy }
|
||||||
local eye = { cx, dist * math.cos(a), cy + dist * math.sin(a) }
|
local eye = { cx, dist * math.cos(a), cy + dist * math.sin(a) }
|
||||||
-- exposed for camera-facing billboards (VoxelScene yaws sprites at it)
|
-- exposed for camera-facing billboards (VoxelScene yaws sprites at it)
|
||||||
Voxel3D.eye = eye
|
Voxel3D.eye = eye
|
||||||
Voxel3D.focus = focus
|
Voxel3D.focus = focus
|
||||||
|
setLook(eye, focus)
|
||||||
-- perpendicular to the view direction in the YZ plane: north is screen-up
|
-- 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
|
-- 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.
|
-- parallel to the view direction, so there is no degenerate a = 0 case.
|
||||||
@@ -538,22 +638,29 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot)
|
|||||||
end
|
end
|
||||||
if not sh then return false end
|
if not sh then return false end
|
||||||
local name = slot or "world"
|
local name = slot or "world"
|
||||||
local held = slots[name]
|
local slotHeld = slots[name]
|
||||||
if not (held and held.w == w and held.h == h) then
|
if not (slotHeld and slotHeld.w == w and slotHeld.h == h) then
|
||||||
local ok, c = pcall(love.graphics.newCanvas, w, h)
|
local ok, c = pcall(love.graphics.newCanvas, w, h)
|
||||||
if not ok then return false end
|
if not ok then return false end
|
||||||
c:setFilter("nearest", "nearest")
|
c:setFilter("nearest", "nearest")
|
||||||
if held and held.canvas and held.canvas.release then
|
if slotHeld then releaseSlot(slotHeld) end
|
||||||
pcall(held.canvas.release, held.canvas)
|
-- the depth canvas is sized with its colour, so a window resize
|
||||||
end
|
-- reallocates the pair together and they can never disagree
|
||||||
held = { canvas = c, w = w, h = h }
|
slotHeld = { canvas = c, w = w, h = h, depth = newDepth(w, h) }
|
||||||
slots[name] = held
|
slots[name] = slotHeld
|
||||||
end
|
end
|
||||||
|
held = slotHeld
|
||||||
canvas, canvasW, canvasH = held.canvas, w, h
|
canvas, canvasW, canvasH = held.canvas, w, h
|
||||||
-- a depth buffer is what makes occlusion real: walk behind a building and
|
-- a depth buffer is what makes occlusion real: walk behind a building and
|
||||||
-- the building wins, with no y-sorting anywhere
|
-- the building wins, with no y-sorting anywhere
|
||||||
local ok = pcall(love.graphics.setCanvas,
|
local ok = pcall(love.graphics.setCanvas, depthTarget())
|
||||||
{ canvas, depth = true })
|
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
|
if not ok then
|
||||||
pcall(love.graphics.setCanvas)
|
pcall(love.graphics.setCanvas)
|
||||||
return false
|
return false
|
||||||
@@ -561,6 +668,14 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot)
|
|||||||
-- Ahead of the clear, because the sky's bands are placed off the ground
|
-- 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.
|
-- plane's vanishing line and that is a property of this matrix.
|
||||||
Voxel3D.vp = Voxel3D.viewProjection(cx, cy, vw, vh)
|
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
|
if sky then
|
||||||
love.graphics.clear(sky[1], sky[2], sky[3], sky[4] or 1, true, true)
|
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
|
-- The sky goes down here, in the one window in this function where a
|
||||||
@@ -573,7 +688,7 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot)
|
|||||||
-- are the same size as the world's own and follow every resize and zoom.
|
-- 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
|
-- 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.
|
-- 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)
|
sky.bands and Voxel3D.skyBody(w, h) or nil)
|
||||||
else
|
else
|
||||||
love.graphics.clear(0, 0, 0, 0, true, true)
|
love.graphics.clear(0, 0, 0, 0, true, true)
|
||||||
@@ -600,7 +715,7 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot)
|
|||||||
pcall(sh.send, sh, "sunTexel", { texel, texel })
|
pcall(sh.send, sh, "sunTexel", { texel, texel })
|
||||||
if grid then
|
if grid then
|
||||||
pcall(sh.send, sh, "gridDark", VoxelGrid.DARK)
|
pcall(sh.send, sh, "gridDark", VoxelGrid.DARK)
|
||||||
pcall(sh.send, sh, "gridWidth", VoxelGrid.WIDTH)
|
pcall(sh.send, sh, "gridWidth", VoxelGrid.width())
|
||||||
end
|
end
|
||||||
-- ordinary shading until the silhouette pass asks for otherwise. Sent
|
-- ordinary shading until the silhouette pass asks for otherwise. Sent
|
||||||
-- every frame rather than once, because a scene that opened mid-ghost --
|
-- every frame rather than once, because a scene that opened mid-ghost --
|
||||||
@@ -720,6 +835,108 @@ function Voxel3D.flatten(color, amount)
|
|||||||
end
|
end
|
||||||
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
|
-- Whether what is drawn next carries the voxel wireframe. false for the
|
||||||
-- length of a draw, true to put it back.
|
-- length of a draw, true to put it back.
|
||||||
--
|
--
|
||||||
@@ -931,18 +1148,25 @@ function Voxel3D.canvas()
|
|||||||
return canvas
|
return canvas
|
||||||
end
|
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).
|
-- Drop the GPU objects (window resize, hot reload).
|
||||||
function Voxel3D.invalidate()
|
function Voxel3D.invalidate()
|
||||||
for name, held in pairs(slots) do
|
for name, slotHeld in pairs(slots) do
|
||||||
if held.canvas and held.canvas.release then
|
releaseSlot(slotHeld)
|
||||||
pcall(held.canvas.release, held.canvas)
|
|
||||||
end
|
|
||||||
slots[name] = nil
|
slots[name] = nil
|
||||||
end
|
end
|
||||||
canvas, canvasW, canvasH = nil, 0, 0
|
canvas, canvasW, canvasH = nil, 0, 0
|
||||||
|
held = nil
|
||||||
ShadowMap.invalidate()
|
ShadowMap.invalidate()
|
||||||
-- the sky is part of this pass and holds a shader of its own
|
-- the sky is part of this pass and holds a shader of its own
|
||||||
Sky.invalidate()
|
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
|
-- and the glass masks are textures of this context too
|
||||||
GlassMask.invalidate()
|
GlassMask.invalidate()
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -44,6 +44,19 @@ VoxelGrid.DARK = 0.45
|
|||||||
-- 1.0 here is the one-pixel wireframe.
|
-- 1.0 here is the one-pixel wireframe.
|
||||||
VoxelGrid.WIDTH = 1.0
|
VoxelGrid.WIDTH = 1.0
|
||||||
|
|
||||||
|
-- The same width in the CANVAS pixels the shader measures in, which is what
|
||||||
|
-- every sender of it actually wants.
|
||||||
|
--
|
||||||
|
-- The two are the same number until AA renders the pass larger than the
|
||||||
|
-- window (see AntiAlias): there a canvas pixel is a fraction of a display
|
||||||
|
-- one, and a width left at 1.0 would come out a half or a quarter of a line
|
||||||
|
-- after the fold -- the wireframe fading as the smoothing goes up, which
|
||||||
|
-- reads as one row breaking the other. Scaled, it stays a one-pixel seam and
|
||||||
|
-- simply gains the antialiasing everything else in the frame just gained.
|
||||||
|
function VoxelGrid.width()
|
||||||
|
return VoxelGrid.WIDTH * V.require("AntiAlias").factor()
|
||||||
|
end
|
||||||
|
|
||||||
-- where it persists and the rows that cycle it (see ModSetting)
|
-- where it persists and the rows that cycle it (see ModSetting)
|
||||||
VoxelGrid.setting = ModSetting.new(VoxelGrid.KEY, VoxelGrid.LABEL,
|
VoxelGrid.setting = ModSetting.new(VoxelGrid.KEY, VoxelGrid.LABEL,
|
||||||
{ false, true }, { "OFF", "ON" })
|
{ false, true }, { "OFF", "ON" })
|
||||||
|
|||||||
+182
-36
@@ -21,6 +21,8 @@ local TileShape = V.require("TileShape")
|
|||||||
local TerrainAtlas = V.require("TerrainAtlas")
|
local TerrainAtlas = V.require("TerrainAtlas")
|
||||||
local Voxel = V.require("VoxelState")
|
local Voxel = V.require("VoxelState")
|
||||||
local Sky = V.require("Sky")
|
local Sky = V.require("Sky")
|
||||||
|
local Water = V.require("Water")
|
||||||
|
local VoxelGrid = V.require("VoxelGrid")
|
||||||
local DayNight = V.require("DayNight")
|
local DayNight = V.require("DayNight")
|
||||||
local PaletteFX = require("src.render.PaletteFX")
|
local PaletteFX = require("src.render.PaletteFX")
|
||||||
local Map = require("src.world.Map")
|
local Map = require("src.world.Map")
|
||||||
@@ -404,17 +406,25 @@ function VoxelScene.prefetch(state)
|
|||||||
-- crossing demotes the map just left, and it must not vanish from
|
-- crossing demotes the map just left, and it must not vanish from
|
||||||
-- behind the player while its body variant builds; its ring is
|
-- 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.
|
-- 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
|
if not terrain then
|
||||||
terrain = ChunkMesher.peek(state.map, true)
|
terrain, water = ChunkMesher.pair(state.map, true)
|
||||||
end
|
end
|
||||||
local nbMesh = {}
|
local nbMesh, nbWater = {}, {}
|
||||||
for i, nb in ipairs(state.neighbors or {}) do
|
for i, nb in ipairs(state.neighbors or {}) do
|
||||||
nbMesh[i] = ChunkMesher.request(nb.map, true)
|
ChunkMesher.request(nb.map, true)
|
||||||
or ChunkMesher.peek(nb.map, false)
|
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
|
end
|
||||||
Voxel.ready = terrain ~= nil
|
Voxel.ready = terrain ~= nil
|
||||||
return terrain, nbMesh
|
return terrain, nbMesh, water, nbWater
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Capture every entity's pose for this frame. pose() advances the hop /
|
-- Capture every entity's pose for this frame. pose() advances the hop /
|
||||||
@@ -490,6 +500,124 @@ end
|
|||||||
|
|
||||||
local glint = {}
|
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
|
-- 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
|
-- 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
|
-- redrawing the whole world from the sun would buy nothing -- which is
|
||||||
@@ -540,7 +668,7 @@ end
|
|||||||
-- left out on purpose: thousands of tufts would cast a speckle no bigger
|
-- 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.
|
-- 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,
|
local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh,
|
||||||
atlasFor)
|
atlasFor, water, nbWater)
|
||||||
if not ShadowMap.available() then return end
|
if not ShadowMap.available() then return end
|
||||||
local sig = shadowSignature(terrain, nbMesh, posed, cx, cy, vw, vh)
|
local sig = shadowSignature(terrain, nbMesh, posed, cx, cy, vw, vh)
|
||||||
if not ShadowMap.stale(sig) then return end
|
if not ShadowMap.stale(sig) then return end
|
||||||
@@ -551,6 +679,15 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh,
|
|||||||
ShadowMap.draw(nbMesh[i], atlasFor(nb.map),
|
ShadowMap.draw(nbMesh[i], atlasFor(nb.map),
|
||||||
Mat4.translate(nb.ox, 0, nb.oy))
|
Mat4.translate(nb.ox, 0, nb.oy))
|
||||||
end
|
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
|
-- flower billboards live outside the terrain mesh (they draw after the
|
||||||
-- characters, pulled -- see render), but the sun still sees them: a
|
-- characters, pulled -- see render), but the sun still sees them: a
|
||||||
-- handful of cutouts per meadow, unlike the grass left out below.
|
-- handful of cutouts per meadow, unlike the grass left out below.
|
||||||
@@ -563,6 +700,11 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh,
|
|||||||
ShadowMap.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
|
ShadowMap.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
|
||||||
ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy)))
|
ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy)))
|
||||||
end
|
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
|
-- 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
|
-- handful of cards per map, and a person with no shadow reads as pasted on
|
||||||
eachFigure(state.map, 0, 0, function(mesh, _, caster)
|
eachFigure(state.map, 0, 0, function(mesh, _, caster)
|
||||||
@@ -584,6 +726,7 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh,
|
|||||||
mirror)))
|
mirror)))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
ShadowMap.sprites(false)
|
||||||
|
|
||||||
ShadowMap.finish(sig)
|
ShadowMap.finish(sig)
|
||||||
end
|
end
|
||||||
@@ -593,7 +736,7 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
|
|||||||
-- return nil: the engine keeps the 2D path for the frame and
|
-- return nil: the engine keeps the 2D path for the frame and
|
||||||
-- Voxel.ready holds the camera tween at flat, so the switch waits
|
-- Voxel.ready holds the camera tween at flat, so the switch waits
|
||||||
-- invisibly instead of freezing or tilting an empty stage.
|
-- 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
|
if not terrain then return nil end
|
||||||
|
|
||||||
local cam = state.camera
|
local cam = state.camera
|
||||||
@@ -630,7 +773,8 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local posed, me = posesOf(state, spriteColors)
|
local posed, me = posesOf(state, spriteColors)
|
||||||
castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, atlasFor)
|
castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, atlasFor,
|
||||||
|
water, nbWater)
|
||||||
|
|
||||||
if not Voxel3D.beginScene(w, h, cx, cy, vw, vh, skyFor(state.map)) then
|
if not Voxel3D.beginScene(w, h, cx, cy, vw, vh, skyFor(state.map)) then
|
||||||
return nil
|
return nil
|
||||||
@@ -658,6 +802,34 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
|
|||||||
Voxel3D.endShadows()
|
Voxel3D.endShadows()
|
||||||
end
|
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
|
-- 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
|
-- 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
|
-- the panes' atlas positions stripe the cast with lamplight at night
|
||||||
@@ -689,33 +861,7 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
|
|||||||
-- drawEntity resolves the lean-over-the-wall-in-front case, and a
|
-- drawEntity resolves the lean-over-the-wall-in-front case, and a
|
||||||
-- character genuinely behind a building is far deeper and loses the
|
-- character genuinely behind a building is far deeper and loses the
|
||||||
-- test, so buildings and trees really occlude.
|
-- test, so buildings and trees really occlude.
|
||||||
Voxel3D.seams(false)
|
drawCast(state, posed, atlasFor)
|
||||||
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)
|
|
||||||
-- tall grass last, pulled camera-ward exactly as far as the characters
|
-- tall grass last, pulled camera-ward exactly as far as the characters
|
||||||
-- were (same per-vertex shader bias, so grass never drifts either):
|
-- were (same per-vertex shader bias, so grass never drifts either):
|
||||||
-- relative depth between a walker and the tuft row south of their feet
|
-- relative depth between a walker and the tuft row south of their feet
|
||||||
|
|||||||
+1303
File diff suppressed because it is too large
Load Diff
@@ -80,6 +80,9 @@ local WorldCurve = V.require("WorldCurve")
|
|||||||
local OverworldBattle = V.require("OverworldBattle")
|
local OverworldBattle = V.require("OverworldBattle")
|
||||||
local BattleExit = V.require("BattleExit")
|
local BattleExit = V.require("BattleExit")
|
||||||
local DayNight = V.require("DayNight")
|
local DayNight = V.require("DayNight")
|
||||||
|
local DayTint = V.require("DayTint")
|
||||||
|
local Water = V.require("Water")
|
||||||
|
local AntiAlias = V.require("AntiAlias")
|
||||||
|
|
||||||
-- Forward declaration: the voxel pipeline's update hook (registered below)
|
-- Forward declaration: the voxel pipeline's update hook (registered below)
|
||||||
-- calls this, and it is defined further down with the settings it drives.
|
-- calls this, and it is defined further down with the settings it drives.
|
||||||
@@ -201,20 +204,34 @@ mod.content.render_pipelines:register("voxel", {
|
|||||||
-- a magnified low-res image, while the FX closures keep drawing in
|
-- a magnified low-res image, while the FX closures keep drawing in
|
||||||
-- world-pixel units.
|
-- world-pixel units.
|
||||||
local sw, sh = sceneSize(ctx)
|
local sw, sh = sceneSize(ctx)
|
||||||
local canvas = VoxelScene.render(ctx.state, sw, sh,
|
-- With AA on, the whole pass runs into a canvas BIGGER than the window
|
||||||
|
-- and is folded back down at the end (see AntiAlias). Nothing between
|
||||||
|
-- these two lines knows: every pass in the frame measures itself in the
|
||||||
|
-- canvas it was handed, so the sky's dither, the water's march and the
|
||||||
|
-- camera itself all come out the same picture at a higher sample rate.
|
||||||
|
local rw, rh = AntiAlias.expand(sw, sh)
|
||||||
|
local canvas = VoxelScene.render(ctx.state, rw, rh,
|
||||||
ctx.vw, ctx.vh, ctx.paletteFor)
|
ctx.vw, ctx.vh, ctx.paletteFor)
|
||||||
if not canvas then return nil end -- fall back to the 2D path
|
if not canvas then return nil end -- fall back to the 2D path
|
||||||
if Voxel3D.beginOverlay() then
|
if Voxel3D.beginOverlay() then
|
||||||
|
-- the FX closures are ordinary 2D draws sized in DISPLAY pixels, and
|
||||||
|
-- they are drawing into the supersampled canvas alongside everything
|
||||||
|
-- else -- so the scale goes up with it, or the "!" bubble lands the
|
||||||
|
-- right place at half the size. project() already answers in canvas
|
||||||
|
-- pixels, so only the scale needs saying.
|
||||||
ctx.drawFx(function(wx, wy) return Voxel3D.project(wx, 0, wy) end,
|
ctx.drawFx(function(wx, wy) return Voxel3D.project(wx, 0, wy) end,
|
||||||
ctx.scale)
|
ctx.scale * AntiAlias.factor())
|
||||||
Voxel3D.endOverlay()
|
Voxel3D.endOverlay()
|
||||||
end
|
end
|
||||||
return canvas
|
-- and back to the window's own size, which is what the engine composites
|
||||||
|
-- one canvas pixel to one display pixel. A pass-through when AA is off.
|
||||||
|
return AntiAlias.resolve(canvas, sw, sh, "world")
|
||||||
end,
|
end,
|
||||||
|
|
||||||
invalidate = function()
|
invalidate = function()
|
||||||
Voxel3D.invalidate()
|
Voxel3D.invalidate()
|
||||||
OverworldBattle.invalidate()
|
OverworldBattle.invalidate()
|
||||||
|
AntiAlias.invalidate()
|
||||||
ChunkMesher.invalidate() -- no map id = every cached mesh
|
ChunkMesher.invalidate() -- no map id = every cached mesh
|
||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
@@ -281,13 +298,22 @@ applyFull = function(level)
|
|||||||
-- the horizon flat. The curve bends the world away from a walking player,
|
-- the horizon flat. The curve bends the world away from a walking player,
|
||||||
-- which fights a fixed diorama framing
|
-- which fights a fixed diorama framing
|
||||||
WorldCurve.setting:setIndex(1, Game)
|
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
|
-- and the view fitted to the window
|
||||||
opts.zoom = 0
|
opts.zoom = 0
|
||||||
Zoom.applyOptions(opts)
|
Zoom.applyOptions(opts)
|
||||||
-- battles on the map too: FULL means the whole mode, and a fight is where
|
-- battles on the map too: FULL means the whole mode, and a fight is where
|
||||||
-- half of it is spent. Set rather than forced -- the row is gone from the
|
-- half of it is spent. Set and then LET GO of -- unlike the rows above, both
|
||||||
-- menu while FULL is on, but a save that already had it off gets it on.
|
-- battle rows stay on the menu under FULL (see the rows hook), so this is
|
||||||
|
-- where the preset puts them and not where they are held.
|
||||||
OverworldBattle.setting:setIndex(1, Game)
|
OverworldBattle.setting:setIndex(1, Game)
|
||||||
|
-- with both mons out there on it: BACK SPRITES keeps the player's own on the
|
||||||
|
-- menu, which is the one part of the old screen FULL is least about. Set the
|
||||||
|
-- same way, and changed back on the same row a keypress later.
|
||||||
|
OverworldBattle.backSetting:setIndex(1, Game)
|
||||||
-- and the battle screen the staged fight is composed for. WIDE re-lays that
|
-- and the battle screen the staged fight is composed for. WIDE re-lays that
|
||||||
-- screen out on a 304x144 surface, which moves every anchor the arena camera
|
-- screen out on a 304x144 surface, which moves every anchor the arena camera
|
||||||
-- is solved against (OverworldBattle.forceOG); FULL has just switched staged
|
-- is solved against (OverworldBattle.forceOG); FULL has just switched staged
|
||||||
@@ -302,29 +328,69 @@ applyFull = function(level)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- Whether a fight can be staged on the map, as far as the OPTIONS menu is
|
-- Whether a fight can be staged on the map, as far as the OPTIONS menu is
|
||||||
-- concerned: 3D-BTL is on, or FULL is selected -- which owns that row and
|
-- concerned: the 3D-BTL row, and nothing else.
|
||||||
-- switches it on. Deliberately NOT gated on Voxel3D.available(): the engine
|
--
|
||||||
-- offers a pipeline's row whether or not the hardware can run it
|
-- It used to answer yes under FULL as well, on the grounds that FULL owned
|
||||||
-- (Pipelines.rows), so this mode's rows say ON on a machine without a depth
|
-- that row and switched it on. FULL no longer owns it -- the row stays on the
|
||||||
-- buffer too, and a menu that claims 3D battles are on must not also offer the
|
-- menu under FULL and can be switched off there (see the rows hook) -- so that
|
||||||
-- layout they cannot be drawn in.
|
-- clause would now claim staged battles for a preset the player had just
|
||||||
|
-- turned them off inside, pinning BATTLE LAYOUT to OG for a fight that is
|
||||||
|
-- never staged. The row is the only thing that decides, which is what every
|
||||||
|
-- other reader of this setting already believed: OverworldBattle.begin and
|
||||||
|
-- wantsFront both gate on enabled() alone.
|
||||||
|
--
|
||||||
|
-- Deliberately NOT gated on Voxel3D.available(): the engine offers a
|
||||||
|
-- pipeline's row whether or not the hardware can run it (Pipelines.rows), so
|
||||||
|
-- this mode's rows say ON on a machine without a depth buffer too, and a menu
|
||||||
|
-- that claims 3D battles are on must not also offer the layout they cannot be
|
||||||
|
-- drawn in.
|
||||||
local function stagedBattles()
|
local function stagedBattles()
|
||||||
local Pipelines = require("src.render.Pipelines")
|
return OverworldBattle.enabled()
|
||||||
return OverworldBattle.enabled() or Voxel.isFull(Pipelines.level("voxel"))
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local SETTINGS = {
|
local SETTINGS = {
|
||||||
{ VoxelGrid.setting, "One-pixel wireframe along every voxel edge." },
|
{ VoxelGrid.setting, "One-pixel wireframe along every voxel edge." },
|
||||||
{ WorldCurve.setting,
|
{ WorldCurve.setting,
|
||||||
"Bend the world down over the horizon, Animal Crossing style." },
|
"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,
|
{ OverworldBattle.setting,
|
||||||
"Fight on the map: the battle draws over the nearest clear ground, "
|
"Fight on the map: the battle draws over the nearest clear ground, "
|
||||||
.. "shot over the shoulder with a slow parallax drift." },
|
.. "shot over the shoulder with a slow parallax drift.",
|
||||||
|
full = true },
|
||||||
|
-- Only offered while a fight can actually be staged on the map: with 3D-BTL
|
||||||
|
-- off the engine draws the classic screen, which is this row's ON already,
|
||||||
|
-- and a row that no longer decides anything is worse than no row.
|
||||||
|
{ OverworldBattle.backSetting,
|
||||||
|
"Keep your own Pokemon on the battle menu, seen from behind in its "
|
||||||
|
.. "original slot, instead of standing it on the map facing the foe. "
|
||||||
|
.. "The foe is still out there on its own tile.",
|
||||||
|
when = function() return stagedBattles() end, full = true },
|
||||||
{ DayNight.setting,
|
{ DayNight.setting,
|
||||||
"What time it is outdoors: pin the sky to DAY, NIGHT, DUSK or DAWN, "
|
"What time it is outdoors: pin the sky to DAY, NIGHT, DUSK or DAWN, "
|
||||||
.. "let CYCLE run it -- ten minutes of sun, ten of moon, with the "
|
.. "let CYCLE run it -- ten minutes of sun, ten of moon, with the "
|
||||||
.. "shadows, the sky and the light following -- or SYNC it to the "
|
.. "shadows, the sky and the light following -- or SYNC it to the "
|
||||||
.. "clock on the wall, so Kanto's evening falls when yours does." },
|
.. "clock on the wall, so Kanto's evening falls when yours does." },
|
||||||
|
-- Marked `full` for the opposite reason the battle rows are: this is not a
|
||||||
|
-- knob on the look at all, it is what the look COSTS. FULL is a preset for
|
||||||
|
-- the diorama, not a licence to spend four times the fill rate on the
|
||||||
|
-- machine it happens to be running on, so it neither sets this nor takes
|
||||||
|
-- the row away -- the player decides what their hardware can carry, from
|
||||||
|
-- inside FULL like anywhere else.
|
||||||
|
{ AntiAlias.setting,
|
||||||
|
"Smooth the stair-stepped edges of the 3D world -- roof ridges, ledge "
|
||||||
|
.. "lips, a tree against the sky -- by rendering the diorama larger than "
|
||||||
|
.. "the window and folding it back down. Every edge in the picture "
|
||||||
|
.. "softens with them, the tileset's own texels included, so the diorama "
|
||||||
|
.. "reads smoother rather than sharper. 2X costs half again as many "
|
||||||
|
.. "pixels in each direction and 4X twice, which makes this the most "
|
||||||
|
.. "expensive row in the mod.",
|
||||||
|
full = true },
|
||||||
}
|
}
|
||||||
|
|
||||||
local schema = {}
|
local schema = {}
|
||||||
@@ -340,6 +406,7 @@ mod.options:define(schema)
|
|||||||
-- 6 T-SHIFT cycle the blur ladder (was 9)
|
-- 6 T-SHIFT cycle the blur ladder (was 9)
|
||||||
-- 7 V-CURVE cycle the horizon bend (new)
|
-- 7 V-CURVE cycle the horizon bend (new)
|
||||||
-- 8 3D-BTL toggle overworld battles (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
|
-- 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,
|
-- engine's own display keys FIRST and returns -- 2 COLORS, 3 TILT, 4 ZOOM,
|
||||||
@@ -354,9 +421,12 @@ mod.options:define(schema)
|
|||||||
-- AND the engine's TILT on the same press.
|
-- AND the engine's TILT on the same press.
|
||||||
--
|
--
|
||||||
-- Consequences worth being explicit about: while this mod is enabled, TILT
|
-- Consequences worth being explicit about: while this mod is enabled, TILT
|
||||||
-- (3) and GBC FX (5) are unreachable by key. Both are still reachable on
|
-- (3) and GBC FX (5) are unreachable by key -- and unreachable on the OPTIONS
|
||||||
-- the OPTIONS menu, and TILT is the one this mode supersedes anyway -- the
|
-- menu too, where both rows are taken away and both values held at zero (see
|
||||||
-- registry already forces it off whenever a world pipeline takes the pass.
|
-- pinEngineFx). Nothing is being hidden that still does something: TILT is the
|
||||||
|
-- flat fake of what this mode does for real, the registry already forces it
|
||||||
|
-- off whenever a world pipeline takes the pass, and GBC FX is a full-screen
|
||||||
|
-- present pass over the top of the diorama. Uninstalling puts both back.
|
||||||
--
|
--
|
||||||
-- Everything the engine does around a pipeline hotkey has to happen here
|
-- Everything the engine does around a pipeline hotkey has to happen here
|
||||||
-- too, so the work is DELEGATED rather than reimplemented: Pipelines.hotkey
|
-- too, so the work is DELEGATED rather than reimplemented: Pipelines.hotkey
|
||||||
@@ -369,6 +439,7 @@ local HOTKEYS = {
|
|||||||
["5"] = VoxelGrid.setting,
|
["5"] = VoxelGrid.setting,
|
||||||
["7"] = WorldCurve.setting,
|
["7"] = WorldCurve.setting,
|
||||||
["8"] = OverworldBattle.setting,
|
["8"] = OverworldBattle.setting,
|
||||||
|
["9"] = Water.setting,
|
||||||
}
|
}
|
||||||
|
|
||||||
do
|
do
|
||||||
@@ -418,19 +489,19 @@ do
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
elseif Pipelines.canToggle("voxel", top, self.overworld) then
|
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
|
-- borrowed from the registry rather than restated, so a press
|
||||||
-- mid-warp or mid-cutscene is refused for the wireframe exactly when
|
-- mid-warp or mid-cutscene is refused for the wireframe exactly when
|
||||||
-- it would be for the mode itself. Two of them parameterise that
|
-- it would be for the mode itself. Three of them parameterise that
|
||||||
-- pass; the third (3D-BTL) decides what a battle is drawn over, and
|
-- 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
|
-- 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
|
-- when the fight starts, so flipping it from inside one would be a
|
||||||
-- switch that appeared to do nothing.
|
-- switch that appeared to do nothing.
|
||||||
claim:cycle(self)
|
claim:cycle(self)
|
||||||
-- 8 is one of the two ways staged battles get switched on, and they
|
-- 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
|
-- 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
|
if stagedBattles() then OverworldBattle.forceOG(self) end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -464,10 +535,12 @@ local function insertGrouped(out, extra)
|
|||||||
return out
|
return out
|
||||||
end
|
end
|
||||||
|
|
||||||
-- FULL owns every one of those settings, so while it is selected they are
|
-- FULL owns the settings that describe the LOOK, so while it is selected those
|
||||||
-- taken off the menu rather than left to be changed under it -- including
|
-- are taken off the menu rather than left to be changed under it -- including
|
||||||
-- T-SHIFT, which is a pipeline row the engine put there. A row that no
|
-- T-SHIFT, which is a pipeline row the engine put there. A row that no longer
|
||||||
-- longer decides anything is worse than no row.
|
-- decides anything is worse than no row.
|
||||||
|
--
|
||||||
|
-- The battle rows are the exception and they stay; see the rows hook.
|
||||||
local function dropRow(out, id)
|
local function dropRow(out, id)
|
||||||
for i = #out, 1, -1 do
|
for i = #out, 1, -1 do
|
||||||
if type(out[i]) == "table" and out[i].id == id then table.remove(out, i) end
|
if type(out[i]) == "table" and out[i].id == id then table.remove(out, i) end
|
||||||
@@ -475,12 +548,50 @@ local function dropRow(out, id)
|
|||||||
return out
|
return out
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- ------- TILT and GBC FX are gone while this mod is installed
|
||||||
|
--
|
||||||
|
-- Both fight the diorama, and both were already half-taken: the mode's own key
|
||||||
|
-- (3) forces them off on every press, and the registry switches TILT off
|
||||||
|
-- whenever a world pipeline takes the pass. What was left was two rows the
|
||||||
|
-- player could set and watch get reverted -- TILT is the flat fake of what
|
||||||
|
-- this mode does for real, and GBC FX is a full-screen present pass over the
|
||||||
|
-- top of the whole thing.
|
||||||
|
--
|
||||||
|
-- So they come OFF the menu, and are HELD at zero rather than merely dropped.
|
||||||
|
-- Hiding a live setting is a trap: a save written before the mod was installed
|
||||||
|
-- can carry TILT 3, and a row that is not there is a row that cannot turn it
|
||||||
|
-- back off. Pinned wherever the value could have arrived from -- the menu
|
||||||
|
-- opening, a save being loaded or begun -- so there is no route by which one
|
||||||
|
-- of them is on and unreachable.
|
||||||
|
--
|
||||||
|
-- Everything they did is still reachable: uninstall the mod and both rows are
|
||||||
|
-- back, at whatever they were last set to.
|
||||||
|
local function pinEngineFx(game)
|
||||||
|
game = game or require("src.core.Game")
|
||||||
|
local opts = game and game.save and game.save.options
|
||||||
|
local Tilt = require("src.render.Tilt")
|
||||||
|
local GBCFX = require("src.render.GBCFX")
|
||||||
|
local changed = false
|
||||||
|
if opts then
|
||||||
|
changed = (opts.tilt or 0) ~= 0 or (opts.gbcfx or 0) ~= 0
|
||||||
|
opts.tilt, opts.gbcfx = 0, 0
|
||||||
|
end
|
||||||
|
pcall(Tilt.setLevel, 0)
|
||||||
|
pcall(GBCFX.setLevel, 0)
|
||||||
|
if changed and game.writeOptions then pcall(game.writeOptions, game) end
|
||||||
|
end
|
||||||
|
|
||||||
-- call next() first and decorate what comes back, so every other mod's
|
-- call next() first and decorate what comes back, so every other mod's
|
||||||
-- rows survive this one
|
-- rows survive this one
|
||||||
mod.hooks:wrap("ui.options.rows", function(next, game, rows)
|
mod.hooks:wrap("ui.options.rows", function(next, game, rows)
|
||||||
local out = next(game, rows)
|
local out = next(game, rows)
|
||||||
if type(out) ~= "table" then return out end
|
if type(out) ~= "table" then return out end
|
||||||
local Pipelines = require("src.render.Pipelines")
|
local Pipelines = require("src.render.Pipelines")
|
||||||
|
-- ahead of every branch below, including FULL's early return: these two are
|
||||||
|
-- off the menu whatever else this mod is or is not doing
|
||||||
|
pinEngineFx(game)
|
||||||
|
dropRow(out, "tilt")
|
||||||
|
dropRow(out, "gbcfx")
|
||||||
-- BATTLE LAYOUT is the ENGINE's row, and this is the one place the mod takes
|
-- BATTLE LAYOUT is the ENGINE's row, and this is the one place the mod takes
|
||||||
-- one away. While a fight can be staged on the map, OG is the only layout it
|
-- one away. While a fight can be staged on the map, OG is the only layout it
|
||||||
-- can be composed in (OverworldBattle.forceOG), so the value is pinned there
|
-- can be composed in (OverworldBattle.forceOG), so the value is pinned there
|
||||||
@@ -492,14 +603,32 @@ mod.hooks:wrap("ui.options.rows", function(next, game, rows)
|
|||||||
OverworldBattle.forceOG(game)
|
OverworldBattle.forceOG(game)
|
||||||
dropRow(out, "battleLayout")
|
dropRow(out, "battleLayout")
|
||||||
end
|
end
|
||||||
if Voxel.isFull(Pipelines.level("voxel")) then
|
local full = Voxel.isFull(Pipelines.level("voxel"))
|
||||||
-- FULL keeps every mod row off the menu (the early return skips the
|
if full then
|
||||||
-- insert below), and holds DAYTIME at SYNC while the row is unreachable
|
-- FULL owns the rows that PARAMETERISE the diorama -- the wireframe, the
|
||||||
|
-- horizon bend, the blur, the hour -- so those come off the menu and
|
||||||
|
-- DAYTIME is held at SYNC while its row is unreachable.
|
||||||
DayNight.forceSync(game)
|
DayNight.forceSync(game)
|
||||||
return dropRow(out, "pipeline:tiltshift")
|
dropRow(out, "pipeline:tiltshift")
|
||||||
end
|
end
|
||||||
local extra = {}
|
local extra = {}
|
||||||
for _, entry in ipairs(SETTINGS) do extra[#extra + 1] = entry[1]:row() end
|
for _, entry in ipairs(SETTINGS) do
|
||||||
|
-- Two things decide whether a row is offered.
|
||||||
|
--
|
||||||
|
-- FULL: a preset that owns the look, so the rows that describe the look go
|
||||||
|
-- with it. The BATTLE rows are not that -- 3D-BTL decides what a fight is
|
||||||
|
-- drawn OVER and BACK SPRITES how it is framed, and neither is a knob on
|
||||||
|
-- the diorama FULL is a preset for. FULL still SETS them on arrival (see
|
||||||
|
-- applyFull); it does not hold them, so leaving them on the menu is the
|
||||||
|
-- difference between a preset and a lock.
|
||||||
|
--
|
||||||
|
-- And a row whose own switch is off the table this frame (BACK SPRITES,
|
||||||
|
-- which needs a staged fight to be about) is left off with it. The mod
|
||||||
|
-- manager's page carries every one of them either way.
|
||||||
|
local offered = (entry.full or not full)
|
||||||
|
and (not entry.when or entry.when())
|
||||||
|
if offered then extra[#extra + 1] = entry[1]:row() end
|
||||||
|
end
|
||||||
return insertGrouped(out, extra)
|
return insertGrouped(out, extra)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
@@ -714,6 +843,16 @@ mod.content.transitions:register(BattleExit.ID, {
|
|||||||
|
|
||||||
BattleExit.install()
|
BattleExit.install()
|
||||||
|
|
||||||
|
-- ------- and the hour on the flat world
|
||||||
|
--
|
||||||
|
-- The clock reaches the diorama through the voxel shader's own tint uniform,
|
||||||
|
-- which the 2D tile path never runs -- so with the mode off, the same evening
|
||||||
|
-- that fell on the diorama left the flat world at permanent noon. One clock,
|
||||||
|
-- two worlds, one of them ignoring it. DayTint paints the same multiply over
|
||||||
|
-- the composited flat world, between the world blit and the UI blit; the
|
||||||
|
-- reasoning for that exact instant is in the file.
|
||||||
|
DayTint.install()
|
||||||
|
|
||||||
-- ------- what time it is
|
-- ------- what time it is
|
||||||
--
|
--
|
||||||
-- The cycle's clock rides the SAVE SLOT (save.modData, via mod.save): what
|
-- The cycle's clock rides the SAVE SLOT (save.modData, via mod.save): what
|
||||||
@@ -728,10 +867,16 @@ end)
|
|||||||
|
|
||||||
mod.events:on("save.loaded", function()
|
mod.events:on("save.loaded", function()
|
||||||
DayNight.restore()
|
DayNight.restore()
|
||||||
|
-- a save written before this mod was installed can carry TILT or GBC FX
|
||||||
|
-- switched on, and their rows are not there to switch them back off (see
|
||||||
|
-- pinEngineFx). Answered here rather than only when the menu opens, so a
|
||||||
|
-- player who never opens it is not left playing under one.
|
||||||
|
pinEngineFx()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
mod.events:on("save.created", function()
|
mod.events:on("save.created", function()
|
||||||
DayNight.restore()
|
DayNight.restore()
|
||||||
|
pinEngineFx()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
-- The engine's own time-of-day seam. OverworldState:timeOfDay() is an
|
-- The engine's own time-of-day seam. OverworldState:timeOfDay() is an
|
||||||
@@ -745,7 +890,7 @@ mod.hooks:wrap("world.tod", function(next, tod, ctx)
|
|||||||
return DayNight.tod()
|
return DayNight.tod()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
mod.exports.version = "1.2.0"
|
mod.exports.version = "1.4.1"
|
||||||
-- exposed so a companion mod can pin its own tiles' shapes or read the
|
-- exposed so a companion mod can pin its own tiles' shapes or read the
|
||||||
-- camera without reaching into this mod's file layout
|
-- camera without reaching into this mod's file layout
|
||||||
mod.exports.lib = V
|
mod.exports.lib = V
|
||||||
|
|||||||
+3
-2
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "DRAMATIC_SHAPE",
|
"id": "DRAMATIC_SHAPE",
|
||||||
"name": "Dramatic Shape Voxel Mod",
|
"name": "Dramatic Shape Voxel Mod",
|
||||||
"version": "1.2.0",
|
"version": "1.4.1",
|
||||||
"api": 2,
|
"api": 2,
|
||||||
"entry": "main.lua",
|
"entry": "main.lua",
|
||||||
"profile": "content",
|
"profile": "content",
|
||||||
@@ -15,5 +15,6 @@
|
|||||||
"engine_internals"
|
"engine_internals"
|
||||||
],
|
],
|
||||||
"affects_link": false,
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,23 +11,29 @@ return {
|
|||||||
"with VOXEL on, the overworld draws as 3D geometry instead of flat tiles",
|
"with VOXEL on, the overworld draws as 3D geometry instead of flat tiles",
|
||||||
"occlusion comes from a depth buffer rather than a y-sort, so buildings really hide what is behind them",
|
"occlusion comes from a depth buffer rather than a y-sort, so buildings really hide what is behind them",
|
||||||
"with 3D-BTL on, a battle draws over the map's nearest clear ground instead of over a white field",
|
"with 3D-BTL on, a battle draws over the map's nearest clear ground instead of over a white field",
|
||||||
|
"the battle's text box and menu are frosted glass over that ground rather than an opaque white slab, on the same panels the HUDs sit on",
|
||||||
"the map's NPCs are culled for the length of a battle, so the wipe plays over an empty map",
|
"the map's NPCs are culled for the length of a battle, so the wipe plays over an empty map",
|
||||||
"a battle's letterbox voids go black rather than white, because the battle canvas is no longer white",
|
"a battle's letterbox voids go black rather than white, because the battle canvas is no longer white",
|
||||||
"VOXEL and the engine's TILT are mutually exclusive -- turning one on switches the other off",
|
"the engine's TILT and GBC FX rows are taken OFF the OPTIONS menu and held at off for as long as this mod is installed -- TILT is the flat fake of what this mode does for real, GBC FX is a full-screen pass over the top of it; uninstalling puts both rows back",
|
||||||
"hotkeys 3 and 5 are taken over from the engine's TILT and GBC FX; both remain on the OPTIONS menu",
|
"hotkeys 3 and 5 are taken over from those two, which have no key and no row while this is loaded",
|
||||||
"the VOXEL key (3) turns TILT and GBC FX off on every press -- both fight the diorama, and 3 is now the only key that reaches either",
|
|
||||||
},
|
},
|
||||||
added = {
|
added = {
|
||||||
"VOXEL options row and hotkey 3 (OFF / 15 / 35 / 50 / 75 degrees)",
|
"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",
|
"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",
|
"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",
|
"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",
|
||||||
"an over-the-shoulder battle camera on a slow parallax orbit, with a depth-of-field pass that holds both mons sharp",
|
"an over-the-shoulder battle camera on a slow parallax orbit, with a depth-of-field pass that holds both mons sharp",
|
||||||
"a sky behind the diorama at the 75-degree rung, outdoor maps only, coloured by the active palette mode",
|
"a sky behind the diorama at the 75-degree rung, outdoor maps only, coloured by the active palette mode",
|
||||||
"a hand-authored tile shape profile (data/voxel_heights.lua) a mod can extend",
|
"a hand-authored tile shape profile (data/voxel_heights.lua) a mod can extend",
|
||||||
},
|
},
|
||||||
known = {
|
known = {
|
||||||
"needs shader and depth-canvas support; without them the rows still cycle but the world stays 2D and battles draw plainly",
|
"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",
|
"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 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",
|
"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",
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
-- Driver: one scene, once per rung of the AA row.
|
||||||
|
--
|
||||||
|
-- The AA row is the one setting in this mod whose whole effect is a pixel
|
||||||
|
-- wide, so it is also the one that cannot be judged from a description. This
|
||||||
|
-- renders the SAME frame at each rung and writes one PNG per rung; put two of
|
||||||
|
-- them side by side, magnified, and the row is either doing something or it
|
||||||
|
-- is not.
|
||||||
|
--
|
||||||
|
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/aa_shots.lua \
|
||||||
|
-- SHOT_DIR=<dir> lovec.exe .
|
||||||
|
--
|
||||||
|
-- knobs (env):
|
||||||
|
-- SHOT_DIR output directory (created if missing) (default "shots/aa")
|
||||||
|
-- AA_MAP map id (default VIRIDIAN_CITY)
|
||||||
|
-- AA_SPOT "x,y[,facing]" (default 20,26,up)
|
||||||
|
-- AA_RUNG the voxel camera rung (default 5, the 75 one)
|
||||||
|
--
|
||||||
|
-- The scene defaults to a town at the LOW camera on purpose: roof ridges, the
|
||||||
|
-- diagonal of a fence and a tree's silhouette against the sky are the edges
|
||||||
|
-- that stair-step, and 75 degrees is the rung that puts the most of them at an
|
||||||
|
-- angle to the pixel grid.
|
||||||
|
--
|
||||||
|
-- Determinism matters here for the same reason it does in voxel_shots_ab: the
|
||||||
|
-- three shots differ ONLY by the row under test, or comparing them means
|
||||||
|
-- nothing. The clock is pinned, the animated tile slots are frozen, the
|
||||||
|
-- townsfolk are stopped where they stand, and the tilt-shift is held at zero
|
||||||
|
-- (a gaussian over the frame would smear away the very edges being looked at).
|
||||||
|
--
|
||||||
|
-- Nothing here writes the player's options: the row is moved with
|
||||||
|
-- ModSetting:sync, which moves the cached index and persists nothing.
|
||||||
|
return function(game)
|
||||||
|
local U = dofile("tests/drivers/util.lua")
|
||||||
|
local Pipelines = require("src.render.Pipelines")
|
||||||
|
local OverworldState = require("src.world.OverworldController")
|
||||||
|
|
||||||
|
local ROOT = os.getenv("SHOT_DIR") or "shots/aa"
|
||||||
|
|
||||||
|
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||||
|
if not (handle and handle.lib) then
|
||||||
|
print("[aa] DRAMATIC_SHAPE mod not loaded -- nothing to shoot")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local V = handle.lib
|
||||||
|
local DayNight = V.require("DayNight")
|
||||||
|
local AntiAlias = V.require("AntiAlias")
|
||||||
|
local ChunkMesher = V.require("ChunkMesher")
|
||||||
|
local Voxel = V.require("VoxelState")
|
||||||
|
local ShadowMap = V.require("ShadowMap")
|
||||||
|
|
||||||
|
local MAP = os.getenv("AA_MAP") or "VIRIDIAN_CITY"
|
||||||
|
local SPOT = os.getenv("AA_SPOT") or "20,26,up"
|
||||||
|
local RUNG = math.floor(tonumber(os.getenv("AA_RUNG")) or 5)
|
||||||
|
local sx, sy, sf = SPOT:match("^(%-?%d+),%s*(%-?%d+),?%s*(%a*)$")
|
||||||
|
sx, sy = tonumber(sx) or 20, tonumber(sy) or 26
|
||||||
|
if sf == "" then sf = "up" end
|
||||||
|
|
||||||
|
OverworldState.rollEncounter = function() return nil end
|
||||||
|
|
||||||
|
local NPC = require("src.world.NPC")
|
||||||
|
if not NPC.dramaticShapeAaFreeze then
|
||||||
|
local inner = NPC.update
|
||||||
|
function NPC:update(...)
|
||||||
|
self.frozen = true
|
||||||
|
return inner(self, ...)
|
||||||
|
end
|
||||||
|
NPC.dramaticShapeAaFreeze = true
|
||||||
|
end
|
||||||
|
pcall(love.math.setRandomSeed, 20260801)
|
||||||
|
|
||||||
|
local TileRenderer = require("src.render.TileRenderer")
|
||||||
|
TileRenderer.tick = function() end
|
||||||
|
TileRenderer.animFrame = function() return 0 end
|
||||||
|
|
||||||
|
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
|
||||||
|
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
|
||||||
|
|
||||||
|
local Zoom = require("src.render.Zoom")
|
||||||
|
pcall(function()
|
||||||
|
game.save.options.zoom = 1
|
||||||
|
Zoom.applyOptions(game.save.options)
|
||||||
|
end)
|
||||||
|
|
||||||
|
local function cameraStill()
|
||||||
|
local o = game.overworld
|
||||||
|
local c = o and o.camera
|
||||||
|
if not c then return true end
|
||||||
|
local lx, ly, held = nil, nil, 0
|
||||||
|
for _ = 1, 300 do
|
||||||
|
if c.x == lx and c.y == ly then
|
||||||
|
held = held + 1
|
||||||
|
if held >= 10 then return true end
|
||||||
|
else
|
||||||
|
held = 0
|
||||||
|
lx, ly = c.x, c.y
|
||||||
|
end
|
||||||
|
U.wait(1)
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The camera PITCH, which is the one that caught this driver out. The tween
|
||||||
|
-- runs on wall-clock dt and Voxel.t reaching 1 is not the same instant the
|
||||||
|
-- angle stops moving, so the first shot of a run came out at 67 degrees
|
||||||
|
-- while the two after it were at 75 -- three frames that differ by the
|
||||||
|
-- camera, in a comparison whose entire subject is a pixel.
|
||||||
|
local function angleStill()
|
||||||
|
local last, held = nil, 0
|
||||||
|
for _ = 1, 600 do
|
||||||
|
if Voxel.angle == last then
|
||||||
|
held = held + 1
|
||||||
|
if held >= 10 then return true end
|
||||||
|
else
|
||||||
|
held = 0
|
||||||
|
last = Voxel.angle
|
||||||
|
end
|
||||||
|
U.wait(1)
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function settle()
|
||||||
|
for _ = 1, 900 do
|
||||||
|
if ChunkMesher.pending() == 0 then break end
|
||||||
|
U.wait(1)
|
||||||
|
end
|
||||||
|
for _ = 1, 300 do
|
||||||
|
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
|
||||||
|
U.wait(1)
|
||||||
|
end
|
||||||
|
angleStill()
|
||||||
|
cameraStill()
|
||||||
|
-- the sun map is only redrawn when its inputs move, and the AA row is not
|
||||||
|
-- one of them -- so force one pass at the settled camera rather than
|
||||||
|
-- comparing a frame against a map fitted a few hundredths of a pixel ago
|
||||||
|
if ShadowMap.forget then ShadowMap.forget() end
|
||||||
|
U.wait(20)
|
||||||
|
end
|
||||||
|
|
||||||
|
DayNight.setting:sync("day")
|
||||||
|
U.teleport(game, MAP, sx, sy, sf)
|
||||||
|
Pipelines.setLevel("voxel", RUNG)
|
||||||
|
Pipelines.setLevel("tiltshift", 0)
|
||||||
|
|
||||||
|
-- Warm up before the FIRST shot, not just between them.
|
||||||
|
--
|
||||||
|
-- Neighbour maps are requested from inside the render itself
|
||||||
|
-- (VoxelScene.prefetch), so an empty build queue right after a teleport
|
||||||
|
-- means "nothing has been asked for yet", not "everything is here". The
|
||||||
|
-- first capture of a run came out with the map beyond Viridian missing --
|
||||||
|
-- a whole tree line absent from one frame of a three-way comparison, which
|
||||||
|
-- looks exactly like the row under test doing something enormous. Settling
|
||||||
|
-- twice lets the first render request the neighbourhood and the second
|
||||||
|
-- drain it.
|
||||||
|
settle()
|
||||||
|
settle()
|
||||||
|
|
||||||
|
local shots, missed = 0, 0
|
||||||
|
for _, samples in ipairs({ 0, 2, 4 }) do
|
||||||
|
AntiAlias.setting:sync(samples)
|
||||||
|
settle()
|
||||||
|
-- AA_TRACE=1 prints the state each shot was taken in. When two shots of a
|
||||||
|
-- run disagree by more than the row could account for, this is what says
|
||||||
|
-- which input moved -- it is how the camera-tween and the neighbour-mesh
|
||||||
|
-- settles above were both found.
|
||||||
|
if os.getenv("AA_TRACE") == "1" then
|
||||||
|
local Voxel3D = V.require("Voxel3D")
|
||||||
|
local o = game.overworld
|
||||||
|
local cw, chh = Voxel3D.size()
|
||||||
|
print(("[aa] trace samples=%d angle=%.6f fov=%.6f cell=%.4f canvas=%dx%d cam=(%.3f,%.3f) eye=(%.2f,%.2f,%.2f) factor=%.4f")
|
||||||
|
:format(samples, Voxel.angle or -1, Voxel3D.fovY or -1,
|
||||||
|
Voxel3D.cell or -1, cw or 0, chh or 0,
|
||||||
|
o and o.camera and o.camera.x or -1,
|
||||||
|
o and o.camera and o.camera.y or -1,
|
||||||
|
(Voxel3D.eye or {})[1] or 0, (Voxel3D.eye or {})[2] or 0,
|
||||||
|
(Voxel3D.eye or {})[3] or 0, AntiAlias.factor()))
|
||||||
|
end
|
||||||
|
local path = ("%s/aa_%d.png"):format(ROOT, samples)
|
||||||
|
game.capturePath = path
|
||||||
|
U.wait(6)
|
||||||
|
local f = io.open(path, "rb")
|
||||||
|
if f then
|
||||||
|
f:close()
|
||||||
|
shots = shots + 1
|
||||||
|
print(("[aa] %s samples=%d"):format(path, samples))
|
||||||
|
else
|
||||||
|
missed = missed + 1
|
||||||
|
print("[aa] capture did not reach disk: " .. path)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- left where it was found, so a run cannot leak a rung into the next one
|
||||||
|
AntiAlias.setting:sync(0)
|
||||||
|
|
||||||
|
print(("[aa] %d shots into %s (%d failed to reach disk)")
|
||||||
|
:format(shots, ROOT, missed))
|
||||||
|
end
|
||||||
+1129
-15
File diff suppressed because it is too large
Load Diff
@@ -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,218 @@
|
|||||||
|
-- Driver: propose and photograph several battle arenas for ONE map.
|
||||||
|
--
|
||||||
|
-- data/battle_arenas.lua holds a single authored spot per area, chosen by
|
||||||
|
-- arena_pick's nearest-to-the-middle search and then looked at. This is the
|
||||||
|
-- other half of that job: when the shipped spot is up for review, it lays out
|
||||||
|
-- the ALTERNATIVES -- every arena on the map both mons can be seen in, spread
|
||||||
|
-- along the map so the shortlist is places rather than neighbours -- and
|
||||||
|
-- stages a real battle in each so they can be compared by eye.
|
||||||
|
--
|
||||||
|
-- SHOT_DIR=.scratchpad/route1_candidates CAND_MAP=ROUTE_1 CAND_N=5 \
|
||||||
|
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/route1_candidates.lua love .
|
||||||
|
--
|
||||||
|
-- CAND_MAP is the map id (default ROUTE_1), CAND_N how many to photograph.
|
||||||
|
-- One `CAND` line per shot, ready to paste into the data file, plus a PNG
|
||||||
|
-- named for its corner and shape.
|
||||||
|
return function(game)
|
||||||
|
local U = dofile("tests/drivers/util.lua")
|
||||||
|
local DIR = os.getenv("SHOT_DIR") or ".scratchpad/route1_candidates"
|
||||||
|
local MAP = os.getenv("CAND_MAP") or "ROUTE_1"
|
||||||
|
local WANT = tonumber(os.getenv("CAND_N") or "") or 5
|
||||||
|
local Pokemon = require("src.pokemon.Pokemon")
|
||||||
|
local BattleState = require("src.battle.BattleState")
|
||||||
|
|
||||||
|
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 45) }
|
||||||
|
game.save.player.name = "RED"
|
||||||
|
|
||||||
|
local exports = game.mods and game.mods.exports
|
||||||
|
local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
|
||||||
|
if not lib then
|
||||||
|
U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local Arena = lib.require("BattleArena")
|
||||||
|
local Battles = lib.require("OverworldBattle")
|
||||||
|
|
||||||
|
U.teleport(game, MAP, 1, 1, "down")
|
||||||
|
local map = game.overworld.map
|
||||||
|
U.log(("%s is %dx%d cells"):format(MAP, map.widthCells, map.heightCells))
|
||||||
|
|
||||||
|
-- ------- every arena the map can offer
|
||||||
|
--
|
||||||
|
-- BattleArena.search answers "the nearest one", which is the wrong question
|
||||||
|
-- for a shortlist -- it returns one spot and hides the rest. So walk the
|
||||||
|
-- same grid ourselves and keep them all, tagged with whether the pair would
|
||||||
|
-- actually be SEEN there (Arena.clearance), because an obstructed spot is
|
||||||
|
-- not a candidate no matter how good the ground looks.
|
||||||
|
--
|
||||||
|
-- The map's outermost cells are its CONNECTION BORDER -- the strip the
|
||||||
|
-- neighbouring map is drawn into, walkable so the player can step across.
|
||||||
|
-- Ground there passes every test and is still the wrong answer: a fight
|
||||||
|
-- staged on it happens at the edge of the world with the border ring's tree
|
||||||
|
-- wall at the mons' backs, and every spot in the strip looks like every
|
||||||
|
-- other one. CAND_MARGIN keeps the shortlist on the route proper.
|
||||||
|
local MARGIN = tonumber(os.getenv("CAND_MARGIN") or "") or 2
|
||||||
|
local cands = {}
|
||||||
|
for _, shape in ipairs(Arena.SHAPES) do
|
||||||
|
for y = MARGIN, map.heightCells - shape.h - MARGIN do
|
||||||
|
for x = MARGIN, map.widthCells - shape.w - MARGIN do
|
||||||
|
local fits = true
|
||||||
|
for cy = y, y + shape.h - 1 do
|
||||||
|
for cx = x, x + shape.w - 1 do
|
||||||
|
if not Arena.openCell(map, cx, cy, false) then fits = false break end
|
||||||
|
end
|
||||||
|
if not fits then break end
|
||||||
|
end
|
||||||
|
if fits then
|
||||||
|
local a = Arena.at(x, y, shape.id)
|
||||||
|
if a and Arena.clearance(map, a) then
|
||||||
|
cands[#cands + 1] = { x = x, y = y, shape = shape.id,
|
||||||
|
mx = x + (shape.w - 1) / 2,
|
||||||
|
my = y + (shape.h - 1) / 2 }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
U.log(("%d clear arenas on %s"):format(#cands, MAP))
|
||||||
|
if #cands == 0 then U.log("done -- nothing to propose") return end
|
||||||
|
|
||||||
|
for _, c in ipairs(cands) do
|
||||||
|
U.log((" fit %d,%d %s"):format(c.x, c.y, c.shape))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- CAND_AT=x,y,shape;x,y,shape;... photographs an explicit shortlist instead
|
||||||
|
-- of the spread one. The spread is a first pass over ground the clearance
|
||||||
|
-- test approved, and that test measures terrain height along the sightline
|
||||||
|
-- only -- it has no opinion on a hedge sitting in the apron row between the
|
||||||
|
-- camera and the near mon, which is the failure that keeps turning up. So
|
||||||
|
-- the loop is: spread, look, then re-shoot the survivors and the
|
||||||
|
-- replacements by hand.
|
||||||
|
local explicit = os.getenv("CAND_AT")
|
||||||
|
if explicit and explicit ~= "" then
|
||||||
|
local list = {}
|
||||||
|
for spot in explicit:gmatch("[^;]+") do
|
||||||
|
local x, y, s = spot:match("^%s*(%-?%d+)%s*,%s*(%-?%d+)%s*,%s*(%a+)%s*$")
|
||||||
|
if x then
|
||||||
|
list[#list + 1] = { x = tonumber(x), y = tonumber(y), shape = s }
|
||||||
|
else
|
||||||
|
U.log("BAD CAND_AT entry: " .. spot)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
cands = list
|
||||||
|
WANT = #list
|
||||||
|
U.log(("%d spots given explicitly"):format(#list))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function shapeOf(id)
|
||||||
|
for _, s in ipairs(Arena.SHAPES) do if s.id == id then return s end end
|
||||||
|
end
|
||||||
|
for _, c in ipairs(cands) do
|
||||||
|
local s = shapeOf(c.shape)
|
||||||
|
c.mx = c.x + ((s and s.w or 1) - 1) / 2
|
||||||
|
c.my = c.y + ((s and s.h or 1) - 1) / 2
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ------- thin them down to a shortlist that is actually a CHOICE
|
||||||
|
--
|
||||||
|
-- Adjacent corners are the same patch of ground shifted a cell, so a naive
|
||||||
|
-- top-N is five photographs of one place, and pure farthest-point selection
|
||||||
|
-- goes straight to the extremes -- which on a route means the ends, where
|
||||||
|
-- the ground is emptiest and the shots are least distinguishable.
|
||||||
|
--
|
||||||
|
-- So: spread along the route's LONG AXIS, one pick per band, and within a
|
||||||
|
-- band take the spot nearest the middle of the road. The road is measured
|
||||||
|
-- rather than assumed -- the median cross-axis position of everywhere a
|
||||||
|
-- fight fits IS the lane, on a map whose walkable ground is mostly lane.
|
||||||
|
local horizontal = map.widthCells > map.heightCells
|
||||||
|
local function along(c) return horizontal and c.mx or c.my end
|
||||||
|
local function across(c) return horizontal and c.my or c.mx end
|
||||||
|
|
||||||
|
local xs = {}
|
||||||
|
for _, c in ipairs(cands) do xs[#xs + 1] = across(c) end
|
||||||
|
table.sort(xs)
|
||||||
|
local road = xs[math.ceil(#xs / 2)]
|
||||||
|
U.log(("road runs %s, centre of the lane is %s = %.1f")
|
||||||
|
:format(horizontal and "east-west" or "north-south",
|
||||||
|
horizontal and "y" or "x", road))
|
||||||
|
|
||||||
|
local lo, hi = math.huge, -math.huge
|
||||||
|
for _, c in ipairs(cands) do
|
||||||
|
lo, hi = math.min(lo, along(c)), math.max(hi, along(c))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- an explicit shortlist is already the answer; the spread below would only
|
||||||
|
-- thin it, and its overlap rule would silently drop two spots deliberately
|
||||||
|
-- asked for a cell apart
|
||||||
|
local picked, taken = {}, {}
|
||||||
|
for band = 1, (explicit and explicit ~= "") and 0 or WANT do
|
||||||
|
-- band centres, not band edges: the first and last picks sit inside the
|
||||||
|
-- route rather than on its two connection mouths
|
||||||
|
local target = lo + (hi - lo) * (band - 0.5) / WANT
|
||||||
|
local best, bestScore
|
||||||
|
for _, c in ipairs(cands) do
|
||||||
|
if not taken[c] then
|
||||||
|
local da = along(c) - target
|
||||||
|
local dr = across(c) - road
|
||||||
|
-- distance from the band centre, plus a heavier penalty for being off
|
||||||
|
-- the lane; wide arenas are worth a detour, being the shot this mode
|
||||||
|
-- is framed for
|
||||||
|
local score = da * da + 4 * dr * dr - (c.shape == "wide" and 100 or 0)
|
||||||
|
if not bestScore or score < bestScore then best, bestScore = c, score end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if best then
|
||||||
|
picked[#picked + 1] = best
|
||||||
|
-- everything overlapping the pick is off the table, so two bands whose
|
||||||
|
-- best spots touch cannot return the same patch of ground twice
|
||||||
|
for _, c in ipairs(cands) do
|
||||||
|
if math.abs(along(c) - along(best)) < 3
|
||||||
|
and math.abs(across(c) - across(best)) < 3 then
|
||||||
|
taken[c] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if #picked == 0 then picked = cands end
|
||||||
|
|
||||||
|
-- north to south (or west to east), so the filenames read along the route
|
||||||
|
table.sort(picked, function(a, b)
|
||||||
|
if along(a) ~= along(b) then return along(a) < along(b) end
|
||||||
|
return across(a) < across(b)
|
||||||
|
end)
|
||||||
|
|
||||||
|
for i, c in ipairs(picked) do
|
||||||
|
U.log(("CAND %d [%q] = { x = %d, y = %d, shape = %q },")
|
||||||
|
:format(i, MAP, c.x, c.y, c.shape))
|
||||||
|
-- forced through the authored-entry seam, so what gets staged is exactly
|
||||||
|
-- this spot rather than whatever the search would pick from the player's
|
||||||
|
-- cell
|
||||||
|
Arena.setOverride(MAP, { x = c.x, y = c.y, shape = c.shape })
|
||||||
|
local staged = Arena.find(map, 0, 0, false)
|
||||||
|
if not staged then
|
||||||
|
U.log(("SKIP %d -- override did not stage"):format(i))
|
||||||
|
else
|
||||||
|
game.overworld.player.cellX = staged.playerCell[1]
|
||||||
|
game.overworld.player.cellY = staged.playerCell[2]
|
||||||
|
U.wait(90) -- let the meshes land
|
||||||
|
local battle = BattleState.newWild(game, "NIDORINO", 20)
|
||||||
|
battle.onFinish = function() end
|
||||||
|
game.overworld:pushBattle(battle)
|
||||||
|
U.wait(70)
|
||||||
|
for _ = 1, 14 do U.tap(game, "a"); U.wait(8) end
|
||||||
|
local got = Battles.arena()
|
||||||
|
U.log(("SHOT %d staged at %s,%s"):format(i, tostring(got and got.x),
|
||||||
|
tostring(got and got.y)))
|
||||||
|
U.shot(game, ("%s/%d_%s_x%d_y%d_%s.png")
|
||||||
|
:format(DIR, i, MAP:lower(), c.x, c.y, c.shape))
|
||||||
|
while game.stack:top() and game.stack:top() ~= game.overworld do
|
||||||
|
game.stack:pop()
|
||||||
|
end
|
||||||
|
U.wait(6)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
Arena.setOverride(MAP, nil)
|
||||||
|
|
||||||
|
U.log("done -- " .. DIR)
|
||||||
|
end
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
-- Driver: prove the banded sky still paints from the ramp texture.
|
||||||
|
--
|
||||||
|
-- The bands used to reach the shader as `uniform vec3 bands[8]`, which on
|
||||||
|
-- Android delivered only its first few slots and painted the rest of the sky
|
||||||
|
-- black (see lib/Sky.lua, rampFor). They are a texture now. This checks the
|
||||||
|
-- three things that swap could have broken, on the machine it CAN be checked
|
||||||
|
-- on: that the shader still compiles, that the ramp is built and is one texel
|
||||||
|
-- per band, and that what lands on screen is still a gradient that pales
|
||||||
|
-- downward rather than a flat plate or a black one.
|
||||||
|
--
|
||||||
|
-- SHOT_DIR=.scratchpad/skyramp POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/sky_ramp_probe.lua love .
|
||||||
|
return function(game)
|
||||||
|
local U = dofile("tests/drivers/util.lua")
|
||||||
|
local DIR = os.getenv("SHOT_DIR") or ".scratchpad/skyramp"
|
||||||
|
|
||||||
|
local exports = game.mods and game.mods.exports
|
||||||
|
local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
|
||||||
|
if not lib then
|
||||||
|
U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local Sky = lib.require("Sky")
|
||||||
|
local DayNight = lib.require("DayNight")
|
||||||
|
|
||||||
|
-- which copy of the mod is actually live: only the ramp build has this
|
||||||
|
U.log("live copy has _rampFor:", tostring(Sky._rampFor ~= nil))
|
||||||
|
U.log("shader compiled:", tostring(Sky._getShader() ~= nil))
|
||||||
|
|
||||||
|
U.teleport(game, "PALLET_TOWN", 12, 10, "up")
|
||||||
|
require("src.render.Pipelines").setLevel("voxel", 5)
|
||||||
|
U.wait(150)
|
||||||
|
|
||||||
|
for _, phase in ipairs({ "day", "dusk", "night" }) do
|
||||||
|
DayNight.setting:sync(phase)
|
||||||
|
DayNight.update(0)
|
||||||
|
U.wait(30)
|
||||||
|
|
||||||
|
local bands = Sky.bands()
|
||||||
|
local ramp = Sky._rampFor and Sky._rampFor(bands)
|
||||||
|
local w = ramp and ramp:getWidth() or -1
|
||||||
|
U.log(("%s: %d bands, ramp %dx%d"):format(
|
||||||
|
phase, #bands, w, ramp and ramp:getHeight() or -1))
|
||||||
|
-- one texel per band is the whole contract: the shader divides by `count`
|
||||||
|
-- and samples texel centres, so a ramp of any other width samples between
|
||||||
|
-- bands or off the end
|
||||||
|
if w ~= #bands then U.log("FAIL ramp width does not match band count") end
|
||||||
|
-- and the ramp is the ramp: the same table gives the same image back
|
||||||
|
if ramp ~= Sky._rampFor(bands) then U.log("FAIL ramp rebuilt per call") end
|
||||||
|
|
||||||
|
U.shot(game, ("%s/%s.png"):format(DIR, phase))
|
||||||
|
end
|
||||||
|
|
||||||
|
DayNight.setting:sync("day")
|
||||||
|
U.log("done -- " .. DIR)
|
||||||
|
end
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
-- Driver: the A/B performance benchmark.
|
||||||
|
--
|
||||||
|
-- One scripted, deterministic session that exercises the three things the
|
||||||
|
-- mod is slow at, and writes the numbers to a JSON file so two runs can be
|
||||||
|
-- diffed:
|
||||||
|
--
|
||||||
|
-- 1. LOADING IN -- engaging the mode from flat: shader compiles,
|
||||||
|
-- canvas allocations, the first map's mesh, its
|
||||||
|
-- atlas bakes and its glass mask, all at once.
|
||||||
|
-- 2. A NEW AREA -- walking Pallet -> Route 1 -> Viridian with cold
|
||||||
|
-- caches, then walking the SAME route again with
|
||||||
|
-- them warm. The gap between the two is the
|
||||||
|
-- complaint; closing it is the fix.
|
||||||
|
-- 3. A LOW CAMERA -- standing still at each pitch rung. The 75 degree
|
||||||
|
-- rung puts the horizon in frame, which triples the
|
||||||
|
-- sun frustum and hands the sky its disc to draw.
|
||||||
|
--
|
||||||
|
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/voxel_bench.lua \
|
||||||
|
-- DS_PERF=1 BENCH_TAG=baseline lovec.exe .
|
||||||
|
--
|
||||||
|
-- knobs (env):
|
||||||
|
-- DS_PERF must be set, or lib/Perf.lua stays dark and measures nothing
|
||||||
|
-- BENCH_TAG output name, ds_bench/<tag>.json (default "run")
|
||||||
|
-- BENCH_HOLD frames to walk per leg (default 900)
|
||||||
|
--
|
||||||
|
-- THREE THINGS THIS RUN CONTROLS FOR, because a benchmark that does not is
|
||||||
|
-- measuring the weather:
|
||||||
|
--
|
||||||
|
-- * VSYNC OFF. With it on every frame costs exactly one refresh interval
|
||||||
|
-- and the whole exercise reads as 16.7ms flat, saving or no saving.
|
||||||
|
-- * THE CLOCK PINNED to day. The day/night cycle changes the sky, the sun
|
||||||
|
-- angle, the shadow frustum and whether windows are lit -- so an
|
||||||
|
-- unpinned run compares two different scenes.
|
||||||
|
-- * ENCOUNTERS OFF. A wild battle mid-walk derails the route and charges
|
||||||
|
-- its frames to whichever map the script thought it was on. Stubbed on
|
||||||
|
-- the state class for this process only; nothing is written to disk.
|
||||||
|
return function(game)
|
||||||
|
local U = dofile("tests/drivers/util.lua")
|
||||||
|
local Pipelines = require("src.render.Pipelines")
|
||||||
|
local OverworldState = require("src.world.OverworldController")
|
||||||
|
|
||||||
|
local TAG = os.getenv("BENCH_TAG") or "run"
|
||||||
|
-- Route 1 is 36 cells tall and a walk step is 16 frames, so a leg that
|
||||||
|
-- means to reach Viridian needs about 1200 -- short of that the "new
|
||||||
|
-- area" the benchmark is named for never gets entered.
|
||||||
|
local HOLD = math.floor(tonumber(os.getenv("BENCH_HOLD")) or 1300)
|
||||||
|
|
||||||
|
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||||
|
if not (handle and handle.lib) then
|
||||||
|
print("[bench] DRAMATIC_SHAPE mod not loaded -- nothing to measure")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local V = handle.lib
|
||||||
|
local Perf = V.require("Perf")
|
||||||
|
if not Perf.enabled then
|
||||||
|
print("[bench] DS_PERF is not set -- run with DS_PERF=1 or this measures nothing")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local loadBytes = V.loadBytes
|
||||||
|
local Structures = V.require("Structures")
|
||||||
|
local ChunkMesher = V.require("ChunkMesher")
|
||||||
|
local Buildings = V.require("Buildings")
|
||||||
|
local DayNight = V.require("DayNight")
|
||||||
|
local VoxelScene = V.require("VoxelScene")
|
||||||
|
|
||||||
|
-- module internals worth naming that the mod does not time itself
|
||||||
|
Perf.wrap(Structures, "forMap", "Structures.forMap")
|
||||||
|
Perf.wrap(Buildings, "build", "Buildings.build")
|
||||||
|
Perf.wrap(ChunkMesher, "pump", "ChunkMesher.pump")
|
||||||
|
Perf.wrap(VoxelScene, "render", "VoxelScene.render")
|
||||||
|
|
||||||
|
if love.window and love.window.setVSync then
|
||||||
|
pcall(love.window.setVSync, 0)
|
||||||
|
end
|
||||||
|
DayNight.setting:sync("day")
|
||||||
|
OverworldState.rollEncounter = function() return nil end
|
||||||
|
|
||||||
|
-- ---- the run ------------------------------------------------------
|
||||||
|
|
||||||
|
local function seg(name)
|
||||||
|
Perf.setSegment(name)
|
||||||
|
-- the frame that STRADDLES a segment boundary belongs to neither: it
|
||||||
|
-- carries the teleport, the level change or the report print that
|
||||||
|
-- opened it, and charging that to the new segment libels it
|
||||||
|
Perf.resync()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function settle(frames)
|
||||||
|
Perf.setSegment(nil)
|
||||||
|
U.wait(frames or 60)
|
||||||
|
Perf.resync()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Hold a direction, attributing each frame to the map the player is
|
||||||
|
-- STANDING ON as it lands. Crossing a seam mid-leg is the whole point of
|
||||||
|
-- the walk, so the segment has to follow the player rather than the
|
||||||
|
-- script's idea of where they are.
|
||||||
|
local function walk(dir, frames, prefix)
|
||||||
|
local last = nil
|
||||||
|
for _ = 1, frames do
|
||||||
|
local o = game.overworld
|
||||||
|
local id = o and o.map and o.map.id
|
||||||
|
if id ~= last then
|
||||||
|
last = id
|
||||||
|
seg(prefix .. ":" .. tostring(id))
|
||||||
|
print(("[bench] %s entered %s at frame %d"):format(prefix, tostring(id),
|
||||||
|
U.frame()))
|
||||||
|
end
|
||||||
|
-- press and RELEASE each frame, the way tests/voxel_perf_probe's seam
|
||||||
|
-- crossing does: a direction left held accumulates in pressQueue and
|
||||||
|
-- the walk stalls where a single tap would have stepped
|
||||||
|
table.insert(game.input.pressQueue, dir)
|
||||||
|
game.input.state[dir] = true
|
||||||
|
coroutine.yield()
|
||||||
|
game.input.state[dir] = false
|
||||||
|
end
|
||||||
|
local o = game.overworld
|
||||||
|
print(("[bench] %s ended on %s at cell (%d,%d)"):format(
|
||||||
|
prefix, tostring(o and o.map and o.map.id),
|
||||||
|
o and o.player and o.player.cellX or -1,
|
||||||
|
o and o.player and o.player.cellY or -1))
|
||||||
|
Perf.setSegment(nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
print("[bench] tag=" .. TAG .. " loadBytes=" .. tostring(loadBytes))
|
||||||
|
|
||||||
|
-- ORDER MATTERS. The cold walk has to be the first time this session
|
||||||
|
-- draws Route 1 and Viridian, so everything before it stays in Pallet
|
||||||
|
-- Town -- a map whose caches the walk does not depend on. Measuring a
|
||||||
|
-- "first entry" into a map an earlier segment already warmed is the one
|
||||||
|
-- way to make this whole benchmark lie.
|
||||||
|
|
||||||
|
-- 1. the flat reference: the game with this mod present but not
|
||||||
|
-- drawing. Every later number is only interesting against this one.
|
||||||
|
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
|
||||||
|
Pipelines.setLevel("voxel", 0)
|
||||||
|
Pipelines.setLevel("tiltshift", 0)
|
||||||
|
settle(90)
|
||||||
|
seg("flat")
|
||||||
|
U.wait(180)
|
||||||
|
|
||||||
|
-- 2. loading in: FULL is what a player picks first and it is the most
|
||||||
|
-- expensive configuration there is (tilt-shift to maximum, 3D
|
||||||
|
-- battles on). Measured from the frame the level changes, so it
|
||||||
|
-- carries the shader compiles, the canvas allocations, the first
|
||||||
|
-- mesh, the first atlas bake and the first glass scan together.
|
||||||
|
settle(60)
|
||||||
|
seg("engage.full")
|
||||||
|
Pipelines.setLevel("voxel", 1) -- FULL
|
||||||
|
U.wait(240)
|
||||||
|
Perf.setSegment(nil)
|
||||||
|
|
||||||
|
-- 3. the low camera. 75 degrees puts the horizon in frame; the rungs
|
||||||
|
-- below it are the control. Standing still, so what is measured is
|
||||||
|
-- the frame's own cost and not the walk's mesh streaming.
|
||||||
|
for _, rung in ipairs({ 2, 3, 4, 5 }) do -- 15, 35, 50, 75 degrees
|
||||||
|
Pipelines.setLevel("voxel", rung)
|
||||||
|
settle(60) -- let the tween finish
|
||||||
|
seg("pitch:" .. tostring(V.require("VoxelState").ANGLE_LABELS[rung + 1]))
|
||||||
|
U.wait(180)
|
||||||
|
Perf.setSegment(nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 3b. the same low camera at DUSK, which is where the sky costs most:
|
||||||
|
-- the horizon is in frame, so the banded region is at its tallest,
|
||||||
|
-- and the sun is low enough to be in it -- the disc only draws at
|
||||||
|
-- all when it is above the horizon point, so a midday run never
|
||||||
|
-- touches that code and would report it as free.
|
||||||
|
for _, when in ipairs({ "dusk", "night" }) do
|
||||||
|
DayNight.setting:sync(when)
|
||||||
|
settle(60)
|
||||||
|
seg("pitch:75:" .. when)
|
||||||
|
U.wait(180)
|
||||||
|
Perf.setSegment(nil)
|
||||||
|
end
|
||||||
|
DayNight.setting:sync("day")
|
||||||
|
settle(30)
|
||||||
|
|
||||||
|
-- 4/5. arriving somewhere new, at the rung that hurts.
|
||||||
|
--
|
||||||
|
-- Arrival is measured by LOADING each map rather than by walking into
|
||||||
|
-- it. Walking would be more lifelike, but Route 1's ledges make a held
|
||||||
|
-- direction stall against geometry, so a fixed frame count buys a
|
||||||
|
-- different amount of travel on every run -- and a benchmark whose
|
||||||
|
-- route drifts cannot compare two runs at all. A load is the same
|
||||||
|
-- arrival stripped of the travel: the map swaps, and the next frames
|
||||||
|
-- pay for its mesh, its structure analysis, its atlas bake and its
|
||||||
|
-- glass mask exactly as they do behind a door fade.
|
||||||
|
--
|
||||||
|
-- Then the identical list a second time. Every cost in the gap between
|
||||||
|
-- the two passes is a cache that was cold, and that gap IS the
|
||||||
|
-- complaint.
|
||||||
|
Pipelines.setLevel("voxel", 5) -- 75 degrees
|
||||||
|
local TOUR = { "ROUTE_1", "VIRIDIAN_CITY", "ROUTE_2", "ROUTE_22",
|
||||||
|
"VIRIDIAN_FOREST", "PEWTER_CITY" }
|
||||||
|
local DWELL = math.max(60, math.floor(HOLD / #TOUR))
|
||||||
|
|
||||||
|
-- Stand in the middle of each map, derived from its own def rather than
|
||||||
|
-- written down: a hardcoded cell that falls outside a map teleports the
|
||||||
|
-- player nowhere and the segment silently measures the previous map.
|
||||||
|
local function centreOf(id)
|
||||||
|
local def = game.data.maps and game.data.maps[id]
|
||||||
|
if not def then return nil end
|
||||||
|
return math.floor(def.width), math.floor(def.height)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function tour(prefix)
|
||||||
|
for _, id in ipairs(TOUR) do
|
||||||
|
local cx, cy = centreOf(id)
|
||||||
|
if cx then
|
||||||
|
U.teleport(game, id, cx, cy, "up")
|
||||||
|
-- the segment opens on the frame AFTER the teleport, so the load
|
||||||
|
-- itself is not charged to the arrival it caused
|
||||||
|
seg(prefix .. ":" .. id)
|
||||||
|
U.wait(DWELL)
|
||||||
|
Perf.setSegment(nil)
|
||||||
|
else
|
||||||
|
print("[bench] skipping unknown map " .. id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
tour("first")
|
||||||
|
tour("revisit")
|
||||||
|
|
||||||
|
-- 6. streaming while walking: the one crossing that is reliably
|
||||||
|
-- walkable (tests/voxel_perf_probe crosses the same seam) -- Route 1
|
||||||
|
-- south into Pallet, which pulls a neighbour's meshes in mid-stride.
|
||||||
|
U.teleport(game, "ROUTE_1", 10, 34, "down")
|
||||||
|
settle(120)
|
||||||
|
walk("down", 240, "walk")
|
||||||
|
|
||||||
|
-- ---- the report ---------------------------------------------------
|
||||||
|
|
||||||
|
Perf.setSegment(nil)
|
||||||
|
Perf.printReport("bench " .. TAG)
|
||||||
|
Perf.write(TAG, {
|
||||||
|
tag = TAG,
|
||||||
|
loadBytes = loadBytes,
|
||||||
|
hold = HOLD,
|
||||||
|
texturememory = Perf.texturememory or 0,
|
||||||
|
canvases = Perf.canvases or 0,
|
||||||
|
images = Perf.images or 0,
|
||||||
|
})
|
||||||
|
print("[bench] done")
|
||||||
|
end
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
-- Driver: the pixel-identity gate for performance work.
|
||||||
|
--
|
||||||
|
-- Every optimization in this mod's performance pass claims the frame comes
|
||||||
|
-- out the same. This driver is what makes that claim checkable rather than
|
||||||
|
-- asserted: it renders a fixed set of scenes -- several maps, indoors and
|
||||||
|
-- out, at every camera rung, in every display mode -- and writes one PNG
|
||||||
|
-- per scene. Run it before a change and after it, hash the two directories,
|
||||||
|
-- and any file whose hash moved is a scene the change altered.
|
||||||
|
--
|
||||||
|
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/voxel_shots_ab.lua \
|
||||||
|
-- SHOT_DIR=<dir> AB_TAG=before lovec.exe .
|
||||||
|
--
|
||||||
|
-- knobs (env):
|
||||||
|
-- SHOT_DIR output directory (created if missing) (default "shots/ab")
|
||||||
|
-- AB_TAG subdirectory under SHOT_DIR (default "before")
|
||||||
|
-- AB_MODES display modes to sweep, comma list (default all four)
|
||||||
|
--
|
||||||
|
-- DETERMINISM is the whole game here, because a shot that differs for a
|
||||||
|
-- reason other than the change under test makes the gate useless:
|
||||||
|
--
|
||||||
|
-- * the day/night clock is PINNED (an unpinned sky is a different sky
|
||||||
|
-- every second, and it drives the sun angle and the shadow frustum);
|
||||||
|
-- * the animated tile slots ride the engine's 60Hz counter, so every
|
||||||
|
-- scene is reached after the SAME number of frames from the same
|
||||||
|
-- starting state, and the water is at the same point in its roll;
|
||||||
|
-- * levels are set through Pipelines.setLevel, never the hotkey, so the
|
||||||
|
-- run cannot write the player's options;
|
||||||
|
-- * encounters are stubbed off -- a wild battle would replace the scene
|
||||||
|
-- the shot is named for.
|
||||||
|
return function(game)
|
||||||
|
local U = dofile("tests/drivers/util.lua")
|
||||||
|
local Pipelines = require("src.render.Pipelines")
|
||||||
|
local OverworldState = require("src.world.OverworldController")
|
||||||
|
|
||||||
|
local ROOT = (os.getenv("SHOT_DIR") or "shots/ab")
|
||||||
|
.. "/" .. (os.getenv("AB_TAG") or "before")
|
||||||
|
|
||||||
|
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||||
|
if not (handle and handle.lib) then
|
||||||
|
print("[ab] DRAMATIC_SHAPE mod not loaded -- nothing to compare")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local V = handle.lib
|
||||||
|
local DayNight = V.require("DayNight")
|
||||||
|
|
||||||
|
OverworldState.rollEncounter = function() return nil end
|
||||||
|
|
||||||
|
-- NPCs roam on a random timer (src/world/NPC.lua), and LOVE's RNG is
|
||||||
|
-- seeded differently every launch -- so two runs of this driver put the
|
||||||
|
-- same townsfolk in different places and every shot with a person in it
|
||||||
|
-- differs for a reason that has nothing to do with the change under
|
||||||
|
-- test. Freeze them: `frozen` is the flag the NPC's own update already
|
||||||
|
-- honours, and an NPC mid-step still finishes it, so the settle below
|
||||||
|
-- lands on a still scene. They are still POSED and still drawn, so the
|
||||||
|
-- billboard, its lean and its shadow are all still under test.
|
||||||
|
local NPC = require("src.world.NPC")
|
||||||
|
if not NPC.dramaticShapeAbFreeze then
|
||||||
|
local inner = NPC.update
|
||||||
|
function NPC:update(...)
|
||||||
|
self.frozen = true
|
||||||
|
return inner(self, ...)
|
||||||
|
end
|
||||||
|
NPC.dramaticShapeAbFreeze = true
|
||||||
|
end
|
||||||
|
pcall(love.math.setRandomSeed, 20260730)
|
||||||
|
|
||||||
|
-- Freeze the tile-animation clock, on BOTH routes to it.
|
||||||
|
--
|
||||||
|
-- TileRenderer.tick consumes WALL-CLOCK dt (so the water rolls at the
|
||||||
|
-- same speed on a 60Hz and a 144Hz panel), which means the step a shot
|
||||||
|
-- catches depends on how fast the machine got there rather than on
|
||||||
|
-- anything the run did. Stubbing tick pins the counter the flat tile
|
||||||
|
-- layer reads.
|
||||||
|
--
|
||||||
|
-- The mod reads the SAME counter but through its own chain
|
||||||
|
-- (TerrainAtlas.animFrame): TileRenderer.animFrame if the build exports
|
||||||
|
-- one, else the local off tick's upvalues, else -- and this is the trap
|
||||||
|
-- -- wall-clock time. A stubbed tick has no upvalues, so stubbing it
|
||||||
|
-- ALONE knocks the mod onto the wall-clock fallback and makes the
|
||||||
|
-- flowers drift between two otherwise identical runs. Exporting a
|
||||||
|
-- constant animFrame takes the first branch and pins that route too.
|
||||||
|
local TileRenderer = require("src.render.TileRenderer")
|
||||||
|
TileRenderer.tick = function() end
|
||||||
|
TileRenderer.animFrame = function() return 0 end
|
||||||
|
|
||||||
|
-- The scenes. Chosen for what each one can BREAK, not for looks:
|
||||||
|
-- ROUTE_1 open ground, grass billboards, a long view north --
|
||||||
|
-- the case a shadow-frustum or culling change moves
|
||||||
|
-- VIRIDIAN_CITY buildings, window panes (the glass mask), signs
|
||||||
|
-- PALLET_TOWN the seam with Route 1: neighbour meshes and ring
|
||||||
|
-- VIRIDIAN_FOREST dense round-tree hulls, heavy occlusion
|
||||||
|
-- REDS_HOUSE_1F indoors: no sky, no sun, authored figures
|
||||||
|
-- PEWTER_CITY a second tileset with its own atlas bake
|
||||||
|
local SCENES = {
|
||||||
|
{ id = "ROUTE_1", x = 10, y = 20, face = "up", label = "open" },
|
||||||
|
{ id = "VIRIDIAN_CITY", x = 20, y = 26, face = "up", label = "town" },
|
||||||
|
{ id = "PALLET_TOWN", x = 10, y = 2, face = "up", label = "seam" },
|
||||||
|
{ id = "VIRIDIAN_FOREST", x = 16, y = 24, face = "up", label = "trees" },
|
||||||
|
{ id = "REDS_HOUSE_1F", x = 4, y = 4, face = "up", label = "indoor" },
|
||||||
|
{ id = "PEWTER_CITY", x = 16, y = 20, face = "down", label = "pewter" },
|
||||||
|
}
|
||||||
|
|
||||||
|
-- OFF is in the list deliberately: a change that speeds the 3D path up
|
||||||
|
-- must not have touched the flat one either. Then the three camera
|
||||||
|
-- rungs, 75 last because it is the low camera this performance work is
|
||||||
|
-- aimed at.
|
||||||
|
--
|
||||||
|
-- FULL (rung 1) is NOT here, and cannot usefully be. It is a settings
|
||||||
|
-- PRESET, not a render path: it sets tilt-shift to maximum, flattens the
|
||||||
|
-- world curve, fits the zoom, switches 3D battles on -- and pins DAYTIME
|
||||||
|
-- to SYNC and HOLDS it there (main.lua's applyFull / DayNight.forceSync),
|
||||||
|
-- which overrides this driver's pinned clock and makes every shot after
|
||||||
|
-- it depend on the wall clock. It also persists all of that, so one run's
|
||||||
|
-- FULL changes the options the NEXT run starts from. What FULL renders is
|
||||||
|
-- 35 degrees with the blur at 3, which rung 3 plus AB_TSHIFT=3 covers
|
||||||
|
-- exactly.
|
||||||
|
local RUNGS = {}
|
||||||
|
for n in (os.getenv("AB_RUNGS") or "0,2,3,5"):gmatch("%d+") do
|
||||||
|
RUNGS[#RUNGS + 1] = tonumber(n)
|
||||||
|
end
|
||||||
|
|
||||||
|
local TSHIFT = math.floor(tonumber(os.getenv("AB_TSHIFT")) or 0)
|
||||||
|
|
||||||
|
-- AB_SHADOW=0 renders with the sun pass's contribution turned off
|
||||||
|
-- (SHADOW_ALPHA 0 short-circuits the lookup in the scene shader). A
|
||||||
|
-- bisection tool: when a set of shots will not reproduce, this says
|
||||||
|
-- whether what is moving is in the shadow map or somewhere else.
|
||||||
|
if os.getenv("AB_SHADOW") == "0" then
|
||||||
|
V.require("Voxel3D").SHADOW_ALPHA = 0
|
||||||
|
end
|
||||||
|
|
||||||
|
-- PaletteFX.MODES, minus the inverted novelties: `ogred` and `classic`
|
||||||
|
-- are the SGB paths this mod bakes an atlas for, `gbc` is the shared
|
||||||
|
-- default, and `redpp` is the one that rebakes an atlas PER MAP -- four
|
||||||
|
-- genuinely different routes through TerrainAtlas.
|
||||||
|
local MODES = {}
|
||||||
|
for m in (os.getenv("AB_MODES") or "ogred,classic,gbc,redpp"):gmatch("[^,]+") do
|
||||||
|
MODES[#MODES + 1] = m
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Two times of day, because half the shader only runs in one of them:
|
||||||
|
-- the window lamps, the moon disc and the night tint are all dark-only,
|
||||||
|
-- and the glint sweep and the sun disc are day-only.
|
||||||
|
local TIMES = { "day", "night" }
|
||||||
|
|
||||||
|
local shots, missed = 0, 0
|
||||||
|
|
||||||
|
-- U.shot's own mkdir is the POSIX one, which cmd.exe does not
|
||||||
|
-- understand, and a missing directory makes every capture vanish
|
||||||
|
-- silently. Try both spellings once, up front.
|
||||||
|
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
|
||||||
|
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
|
||||||
|
|
||||||
|
-- A capture that always costs the SAME number of frames.
|
||||||
|
--
|
||||||
|
-- U.shot spins up to 120 frames waiting for the capture to land, which
|
||||||
|
-- is right for a screenshot and wrong for this: the animated tile slots
|
||||||
|
-- (water rolling, flowers opening) ride the engine's frames-since-boot
|
||||||
|
-- counter, so a scene reached after a different number of frames renders
|
||||||
|
-- its water at a different point in the roll and the shot differs for a
|
||||||
|
-- reason no change caused. A driver resume and a rendered frame are 1:1
|
||||||
|
-- here, so the capture lands on the next draw and a fixed budget is both
|
||||||
|
-- enough and constant.
|
||||||
|
local CAPTURE_FRAMES = 4
|
||||||
|
|
||||||
|
local ChunkMesher = V.require("ChunkMesher")
|
||||||
|
local Voxel = V.require("VoxelState")
|
||||||
|
local ShadowMap = V.require("ShadowMap")
|
||||||
|
|
||||||
|
-- Wait for the scene to actually BE the scene the shot is named for.
|
||||||
|
-- Two things are still in motion after a teleport, and both are timed in
|
||||||
|
-- wall-clock seconds rather than frames, so "wait N frames" settles them
|
||||||
|
-- by a different amount on every machine and every run:
|
||||||
|
--
|
||||||
|
-- the build queue -- meshes are built on a per-frame time budget, so a
|
||||||
|
-- slower run captures a half-built neighbour;
|
||||||
|
-- the camera tween -- Voxel.t runs on dt over TWEEN_TIME, so a shot
|
||||||
|
-- taken before it lands is at some arbitrary intermediate pitch.
|
||||||
|
--
|
||||||
|
-- Both are waited on by their own completion flag, then a short fixed
|
||||||
|
-- settle. The variable wait is harmless now that the animation clock is
|
||||||
|
-- frozen above -- otherwise it would move the water instead.
|
||||||
|
-- and the CAMERA, which is the subtle one. It eases toward the player
|
||||||
|
-- over wall-clock dt, so after a fixed wait it has covered a distance
|
||||||
|
-- that depends on how fast the machine ran -- and the sun pass is only
|
||||||
|
-- redrawn when the camera crosses a quarter-world-pixel (VoxelScene's
|
||||||
|
-- shadow signature), so a frame caught mid-ease carries a shadow map
|
||||||
|
-- fitted for a slightly different camera than the one it is drawn with.
|
||||||
|
-- That is a real and deliberate tolerance in the mod, but it makes the
|
||||||
|
-- gate compare two arbitrary points inside it. Waiting for the camera
|
||||||
|
-- to stop moving entirely puts every shot at the same steady state.
|
||||||
|
local function cameraStill()
|
||||||
|
local o = game.overworld
|
||||||
|
local c = o and o.camera
|
||||||
|
if not c then return true end
|
||||||
|
local lx, ly, held = nil, nil, 0
|
||||||
|
for _ = 1, 300 do
|
||||||
|
if c.x == lx and c.y == ly then
|
||||||
|
held = held + 1
|
||||||
|
if held >= 10 then return true end
|
||||||
|
else
|
||||||
|
held = 0
|
||||||
|
lx, ly = c.x, c.y
|
||||||
|
end
|
||||||
|
U.wait(1)
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function settleBuild()
|
||||||
|
for _ = 1, 900 do
|
||||||
|
if ChunkMesher.pending() == 0 then break end
|
||||||
|
U.wait(1)
|
||||||
|
end
|
||||||
|
for _ = 1, 300 do
|
||||||
|
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
|
||||||
|
U.wait(1)
|
||||||
|
end
|
||||||
|
cameraStill()
|
||||||
|
-- and then force one final sun pass at the settled camera. The map is
|
||||||
|
-- only redrawn when the camera crosses a quarter world pixel, so a
|
||||||
|
-- still camera holds whatever was drawn at the moment it last did --
|
||||||
|
-- correct to within that tolerance, but fitted from a position that
|
||||||
|
-- depends on where the easing happened to be, which differs by a few
|
||||||
|
-- hundredths of a pixel between runs and moves every shadow edge by a
|
||||||
|
-- shade or two. Forgetting the stamp redraws from the state the shot
|
||||||
|
-- is actually taken in, and two runs then agree exactly.
|
||||||
|
-- guarded so this driver can also be pointed at a build that predates
|
||||||
|
-- the seam, which is exactly what capturing a "before" reference means
|
||||||
|
if ShadowMap.forget then ShadowMap.forget() end
|
||||||
|
U.wait(20)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- AB_TRACE=1 prints the state each shot was taken in. When two runs of
|
||||||
|
-- this driver disagree, this is what says which input moved.
|
||||||
|
local TRACE = os.getenv("AB_TRACE") == "1"
|
||||||
|
|
||||||
|
local function trace(name)
|
||||||
|
if not TRACE then return end
|
||||||
|
local o = game.overworld
|
||||||
|
local e = ShadowMap.extent or {}
|
||||||
|
print(("[ab] %-28s cam=(%.4f,%.4f) player=(%.3f,%.3f) res=%d extent=(%.3f,%.3f,%.3f) KX=%.6f KZ=%.6f angle=%.6f pend=%d")
|
||||||
|
:format(name,
|
||||||
|
o and o.camera and o.camera.x or -1,
|
||||||
|
o and o.camera and o.camera.y or -1,
|
||||||
|
o and o.player and o.player.px or -1,
|
||||||
|
o and o.player and o.player.py or -1,
|
||||||
|
ShadowMap.res or 0,
|
||||||
|
e[1] or 0, e[2] or 0, e[3] or 0,
|
||||||
|
ShadowMap.KX or 0, ShadowMap.KZ or 0,
|
||||||
|
Voxel.angle or 0,
|
||||||
|
ChunkMesher.pending()))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- AB_SHADOWDUMP=1 also writes the packed depth map itself, into the save
|
||||||
|
-- directory. When the scene differs but every input to the sun pass is
|
||||||
|
-- identical, the map is the only place left to look.
|
||||||
|
local DUMP = os.getenv("AB_SHADOWDUMP") == "1"
|
||||||
|
|
||||||
|
local function dumpShadow(name)
|
||||||
|
if not DUMP then return end
|
||||||
|
local tex = ShadowMap.texture()
|
||||||
|
if not (tex and tex.newImageData) then return end
|
||||||
|
pcall(function()
|
||||||
|
love.filesystem.createDirectory("ab_shadow")
|
||||||
|
tex:newImageData():encode("png", "ab_shadow/" .. name .. ".png")
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function capture(name)
|
||||||
|
trace(name)
|
||||||
|
dumpShadow(name)
|
||||||
|
local path = ("%s/%s.png"):format(ROOT, name)
|
||||||
|
game.capturePath = path
|
||||||
|
U.wait(CAPTURE_FRAMES)
|
||||||
|
local f = io.open(path, "rb")
|
||||||
|
if f then
|
||||||
|
f:close()
|
||||||
|
shots = shots + 1
|
||||||
|
else
|
||||||
|
missed = missed + 1
|
||||||
|
print("[ab] capture did not reach disk: " .. path)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local PaletteFX = require("src.render.PaletteFX")
|
||||||
|
|
||||||
|
local function setMode(mode)
|
||||||
|
local known = false
|
||||||
|
for _, m in ipairs(PaletteFX.MODES) do
|
||||||
|
if m == mode then known = true break end
|
||||||
|
end
|
||||||
|
if not known then return false end
|
||||||
|
return (pcall(PaletteFX.setMode, mode))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A fixed zoom, so the view size every shot is composed at is the same
|
||||||
|
-- one. Zoom is persisted, so without this a session that ever ran the
|
||||||
|
-- FULL preset (which fits the zoom to the window) leaves a different
|
||||||
|
-- view size behind for every later run.
|
||||||
|
local Zoom = require("src.render.Zoom")
|
||||||
|
pcall(function()
|
||||||
|
game.save.options.zoom = 1
|
||||||
|
Zoom.applyOptions(game.save.options)
|
||||||
|
end)
|
||||||
|
|
||||||
|
for _, mode in ipairs(MODES) do
|
||||||
|
if setMode(mode) then
|
||||||
|
for _, when in ipairs(TIMES) do
|
||||||
|
DayNight.setting:sync(when)
|
||||||
|
for _, s in ipairs(SCENES) do
|
||||||
|
for _, rung in ipairs(RUNGS) do
|
||||||
|
U.teleport(game, s.id, s.x, s.y, s.face)
|
||||||
|
Pipelines.setLevel("voxel", rung)
|
||||||
|
-- pinned AFTER the voxel rung, because FULL is a preset that
|
||||||
|
-- reaches over and sets this row itself (main.lua's applyFull)
|
||||||
|
-- and persists it -- so without this, one run's FULL leaks a
|
||||||
|
-- blur level into the NEXT run's options.lua and every shot
|
||||||
|
-- differs for a reason no code change caused. Sharp by
|
||||||
|
-- default: a gaussian smears a one-pixel geometry difference
|
||||||
|
-- across the whole frame, which is exactly what a gate meant
|
||||||
|
-- to localise differences must not do. AB_TSHIFT=3 runs the
|
||||||
|
-- blur path deliberately.
|
||||||
|
Pipelines.setLevel("tiltshift", TSHIFT)
|
||||||
|
-- Wait for the build queue to drain rather than for a fixed
|
||||||
|
-- number of frames. Meshes are built on a per-frame time
|
||||||
|
-- budget, so how much of a map exists after N frames is a
|
||||||
|
-- property of the MACHINE -- a slower run captures a
|
||||||
|
-- half-built neighbour and the shot differs for no reason the
|
||||||
|
-- change caused. Draining first, then settling a fixed 40
|
||||||
|
-- frames for the camera tween, makes the scene the same
|
||||||
|
-- scene everywhere. (Safe now that the animation clock above
|
||||||
|
-- is frozen: a variable wait no longer moves the water.)
|
||||||
|
settleBuild()
|
||||||
|
capture(("%s_%s_%s_v%d"):format(mode, when, s.label, rung))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
print("[ab] display mode " .. mode .. " unavailable, skipped")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
print(("[ab] %d shots into %s (%d failed to reach disk)")
|
||||||
|
:format(shots, ROOT, missed))
|
||||||
|
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
|
||||||
+304
-11
@@ -547,6 +547,98 @@ TEMPLATES = {
|
|||||||
roof_rows=17, roof_back=5, roof_front=3, roof_cycle=(5, 9),
|
roof_rows=17, roof_back=5, roof_front=3, roof_cycle=(5, 9),
|
||||||
slab=4, front_eave=4, ledge=None, tileset="forest",
|
slab=4, front_eave=4, ledge=None, tileset="forest",
|
||||||
),
|
),
|
||||||
|
# F01: the starter-ball table in Oak's lab -- the first FURNITURE
|
||||||
|
# through the pipeline, and the first drawing whose plot is smaller
|
||||||
|
# than its grid. Rows 0-15 are the tabletop seen from above; 16-18
|
||||||
|
# are the top slab's own front edge (black/#555/black -- exactly
|
||||||
|
# what the rim treatment paints, so slab=3 and those rows fold into
|
||||||
|
# the roof band instead of extruding); 19-21 are the base: corner
|
||||||
|
# feet and the inset dark panel between them. The legs stand on
|
||||||
|
# open FLOOR, so the measured ground line lands two rows short of
|
||||||
|
# the grid; `depth` keeps the plot to the blocked cell row -- the
|
||||||
|
# legs row of the grid is the walkable cell the player faces the
|
||||||
|
# table from, and D = len(tiles) would stand the model in their
|
||||||
|
# path. 16 top rows onto a 16px plot map 1:1: no cycling, and
|
||||||
|
# roof_cycle is unreachable behind roof_back=16.
|
||||||
|
"lab_table": dict(
|
||||||
|
tiles=[
|
||||||
|
[41, 59, 59, 59, 59, 42],
|
||||||
|
[78, 57, 57, 57, 57, 79],
|
||||||
|
[88, 89, 89, 89, 89, 90],
|
||||||
|
],
|
||||||
|
roof_rows=19, roof_back=16, roof_front=0, roof_cycle=(2, 13),
|
||||||
|
slab=3, front_eave=0, ledge=None, tileset="gym", depth=2,
|
||||||
|
),
|
||||||
|
# F02: the computer desk in Oak's lab -- and the Hall of Fame's
|
||||||
|
# recording machine, the same drawing on the GYM atlas (one
|
||||||
|
# placement each; both registered in voxel_heights). The one
|
||||||
|
# DESK-SET template: the methodology's region classification at
|
||||||
|
# part granularity. The desk is the sibling lab table (fascia rows
|
||||||
|
# 16-18, base 19-21); on it stand a monitor over its keyboard
|
||||||
|
# (left), a computer tower over a keyboard and mouse (middle), and
|
||||||
|
# a sheet of paper LYING FLAT (right). Upright parts anchor their
|
||||||
|
# drawn bottom row to the desk's top plane and wear their own drawn
|
||||||
|
# tops as lids; flat parts lie one voxel proud with drawn row =
|
||||||
|
# depth row -- the same 1:1 the tabletop itself is drawn with, so
|
||||||
|
# an object's height ON the drawing is its position ON the desk.
|
||||||
|
# The desk's own top is the one synthesized surface (the objects
|
||||||
|
# cover every pixel of it), continued from the sibling tables'
|
||||||
|
# pattern in the drawing's own shades.
|
||||||
|
"lab_computers": dict(
|
||||||
|
tiles=[
|
||||||
|
[91, 92, 93, 94],
|
||||||
|
[54, 55, 85, 95],
|
||||||
|
[88, 89, 89, 90],
|
||||||
|
],
|
||||||
|
roof_rows=0, roof_back=0, roof_front=0, roof_cycle=(0, 0),
|
||||||
|
slab=0, front_eave=0, ledge=None, tileset="gym", depth=2,
|
||||||
|
desk=dict(fascia=(16, 18), base=(19, 21)),
|
||||||
|
parts=[
|
||||||
|
dict(kind="upright", x=(2, 13), top=(0, 2), facade=(3, 10),
|
||||||
|
depth=4), # the monitor
|
||||||
|
dict(kind="flat", x=(1, 13), rows=(11, 14)), # its keyboard
|
||||||
|
dict(kind="upright", x=(14, 21), top=(0, 3), facade=(4, 10),
|
||||||
|
depth=6), # the tower
|
||||||
|
dict(kind="flat", x=(14, 21), rows=(11, 14)), # keys + mouse
|
||||||
|
dict(kind="flat", x=(22, 30), rows=(1, 14)), # the paper
|
||||||
|
],
|
||||||
|
),
|
||||||
|
# F03: the empty north table beside it -- the starter table's band
|
||||||
|
# table verbatim on a grid two tiles narrower (one placement).
|
||||||
|
"lab_table_small": dict(
|
||||||
|
tiles=[
|
||||||
|
[41, 59, 59, 42],
|
||||||
|
[78, 57, 57, 79],
|
||||||
|
[88, 89, 89, 90],
|
||||||
|
],
|
||||||
|
roof_rows=19, roof_back=16, roof_front=0, roof_cycle=(2, 13),
|
||||||
|
slab=3, front_eave=0, ledge=None, tileset="gym", depth=2,
|
||||||
|
),
|
||||||
|
# F04: the Pokemon Center's PC -- every Center's northeast corner
|
||||||
|
# (11 placements) plus the Indigo Plateau lobby, whose MART tileset
|
||||||
|
# shares this atlas. The lab desk-set read again: a
|
||||||
|
# Mac-style unit drawn face-on -- white top band (rows 0-3), bezel,
|
||||||
|
# screen and drive slot (4-14) -- standing at the back of a low
|
||||||
|
# desk whose front face is rows 20-23 and whose drawn top (the
|
||||||
|
# white sliver of row 15 and the margins beside the unit) names the
|
||||||
|
# lid shade. The keyboard rows 17-19 lie BELOW the desk's 16px top
|
||||||
|
# span, so the flat part carries an authored z origin: it lies at
|
||||||
|
# the desk's front edge, in front of the unit.
|
||||||
|
"center_pc": dict(
|
||||||
|
tiles=[
|
||||||
|
[66, 70],
|
||||||
|
[82, 86],
|
||||||
|
[9, 88],
|
||||||
|
],
|
||||||
|
roof_rows=0, roof_back=0, roof_front=0, roof_cycle=(0, 0),
|
||||||
|
slab=0, front_eave=0, ledge=None, tileset="pokecenter", depth=2,
|
||||||
|
desk=dict(fascia=(20, 21), base=(22, 23), lid="white"),
|
||||||
|
parts=[
|
||||||
|
dict(kind="upright", x=(2, 13), top=(0, 3), facade=(4, 14),
|
||||||
|
depth=6), # the unit
|
||||||
|
dict(kind="flat", x=(2, 13), rows=(17, 19), z=13), # keyboard
|
||||||
|
],
|
||||||
|
),
|
||||||
# B23: the Victory Road entrance on Route 23: a rock face with two
|
# B23: the Victory Road entrance on Route 23: a rock face with two
|
||||||
# barred doors. The roof band is the pale cliff top seen from above.
|
# barred doors. The roof band is the pale cliff top seen from above.
|
||||||
"victory_road_gate": dict(
|
"victory_road_gate": dict(
|
||||||
@@ -642,7 +734,14 @@ def profile(sp, t):
|
|||||||
r = next((y for y in range(H) if inside(x, y)), t["roof_rows"])
|
r = next((y for y in range(H) if inside(x, y)), t["roof_rows"])
|
||||||
top.append(min(r, t["roof_rows"]))
|
top.append(min(r, t["roof_rows"]))
|
||||||
|
|
||||||
wall_h = H - t["roof_rows"]
|
# The drawing's own ground line: the row after the last drawn one. A
|
||||||
|
# building ends on the black threshold row it stands on (ground == H),
|
||||||
|
# but furniture is drawn standing on open floor -- the lab table's
|
||||||
|
# legs stop two rows short of its grid -- and extruding against H
|
||||||
|
# would float it that far above its own plot.
|
||||||
|
ground = max((y for y in range(H) for x in range(W) if inside(x, y)),
|
||||||
|
default=H - 1) + 1
|
||||||
|
wall_h = ground - t["roof_rows"]
|
||||||
ytop = wall_h - 1 + t["slab"]
|
ytop = wall_h - 1 + t["slab"]
|
||||||
|
|
||||||
# Recesses: the panes the art seals behind a black frame. Non-black
|
# Recesses: the panes the art seals behind a black frame. Non-black
|
||||||
@@ -674,15 +773,136 @@ def profile(sp, t):
|
|||||||
if x1 - x0 + 1 <= RECESS_MAX and y1 - y0 + 1 <= RECESS_MAX:
|
if x1 - x0 + 1 <= RECESS_MAX and y1 - y0 + 1 <= RECESS_MAX:
|
||||||
recess.update(cells)
|
recess.update(cells)
|
||||||
|
|
||||||
|
# Depth is the PLOT. For a whole-drawing building that is the grid
|
||||||
|
# itself; `depth` (in tile rows) names it when the grid runs past the
|
||||||
|
# plot onto ground the drawing merely stands its legs on.
|
||||||
return dict(top=top, wall_h=wall_h, ytop=ytop, recess=recess,
|
return dict(top=top, wall_h=wall_h, ytop=ytop, recess=recess,
|
||||||
inside=inside, D=H, W=W, H=H)
|
inside=inside, D=t.get("depth", len(t["tiles"])) * 8,
|
||||||
|
ground=ground, W=W, H=H)
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------- stage 3 --
|
# --------------------------------------------------------------- stage 3 --
|
||||||
|
def build_desk_set(sp, pr, t):
|
||||||
|
"""A desk with separately-classified objects on it (the `parts` list):
|
||||||
|
upright parts stand on the desk's top plane wearing their own drawn
|
||||||
|
tops as lids, flat parts lie one voxel proud at drawn row = depth row,
|
||||||
|
and the desk itself is the lab-table slab + base with a synthesized
|
||||||
|
lid (the objects cover every drawn pixel of the tabletop)."""
|
||||||
|
W, H, D = pr["W"], pr["H"], pr["D"]
|
||||||
|
inside = pr["inside"]
|
||||||
|
col, src = sp["col"], sp["src"]
|
||||||
|
ground = pr["ground"]
|
||||||
|
vox = {}
|
||||||
|
|
||||||
|
def put(x, y, z, sx, sy):
|
||||||
|
vox[(x, y, z)] = (col[sy][sx], src[sy][sx])
|
||||||
|
|
||||||
|
shade_px = {}
|
||||||
|
for sy in range(H):
|
||||||
|
for sx in range(W):
|
||||||
|
if inside(sx, sy):
|
||||||
|
shade_px.setdefault(col[sy][sx], (sx, sy))
|
||||||
|
|
||||||
|
def interior(sx, sy, lo, hi):
|
||||||
|
if col[sy][sx] != BLACK:
|
||||||
|
return sx
|
||||||
|
step = 1 if sx < (lo + hi) // 2 else -1
|
||||||
|
for d in range(1, 4):
|
||||||
|
nx = sx + step * d
|
||||||
|
if lo <= nx <= hi and inside(nx, sy) and col[sy][nx] != BLACK:
|
||||||
|
return nx
|
||||||
|
return sx
|
||||||
|
|
||||||
|
f0, f1 = t["desk"]["fascia"]
|
||||||
|
b0, b1 = t["desk"]["base"]
|
||||||
|
plane = (b1 - b0 + 1) + (f1 - f0 + 1) # the desk's top plane
|
||||||
|
|
||||||
|
# the base band, extruded exactly like every lab table's
|
||||||
|
for sy in range(b0, b1 + 1):
|
||||||
|
y = ground - 1 - sy
|
||||||
|
for sx in range(W):
|
||||||
|
if not inside(sx, sy):
|
||||||
|
continue
|
||||||
|
ix = interior(sx, sy, 0, W - 1)
|
||||||
|
for z in range(D):
|
||||||
|
put(sx, y, z, sx if z in (0, D - 1) else ix, sy)
|
||||||
|
for sx, sy in pr["recess"]:
|
||||||
|
if b0 <= sy <= b1:
|
||||||
|
vox.pop((sx, ground - 1 - sy, D - 1), None)
|
||||||
|
|
||||||
|
# the slab: the fascia rows wrap every side; the lid is the one
|
||||||
|
# synthesized surface in the model -- the drawing never paints the
|
||||||
|
# tabletop (its objects cover it), so the lid continues the sibling
|
||||||
|
# tables' pattern in the drawing's own shades: black rim, white
|
||||||
|
# highlight courses along the north and west, grey field
|
||||||
|
for i, sy in enumerate(range(f0, f1 + 1)):
|
||||||
|
y = plane - 1 - i
|
||||||
|
for sx in range(W):
|
||||||
|
for z in range(D):
|
||||||
|
put(sx, y, z, sx, sy)
|
||||||
|
field = WHITE if t["desk"].get("lid") == "white" else GREY
|
||||||
|
for sx in range(W):
|
||||||
|
for z in range(D):
|
||||||
|
if sx in (0, W - 1) or z in (0, D - 1):
|
||||||
|
shade = BLACK
|
||||||
|
elif sx == 1 or z == 1:
|
||||||
|
shade = WHITE
|
||||||
|
else:
|
||||||
|
shade = field
|
||||||
|
px = shade_px.get(shade) or shade_px[BLACK]
|
||||||
|
put(sx, plane - 1, z, px[0], px[1])
|
||||||
|
|
||||||
|
for p in t["parts"]:
|
||||||
|
x0, x1 = p["x"]
|
||||||
|
if p["kind"] == "flat":
|
||||||
|
r0, r1 = p["rows"]
|
||||||
|
for sy in range(r0, r1 + 1):
|
||||||
|
# drawn row = depth row by default; `z` renames the
|
||||||
|
# origin when the flat sits below the desk's own drawn
|
||||||
|
# top span (the Center PC's keyboard)
|
||||||
|
z = p.get("z", r0) + (sy - r0)
|
||||||
|
if not 0 <= z < D:
|
||||||
|
continue
|
||||||
|
for sx in range(x0, x1 + 1):
|
||||||
|
if inside(sx, sy):
|
||||||
|
put(sx, plane, z, sx, sy)
|
||||||
|
continue
|
||||||
|
tr0, tr1 = p["top"]
|
||||||
|
fr0, fr1 = p["facade"]
|
||||||
|
pd = p["depth"]
|
||||||
|
ytp = plane + (fr1 - fr0)
|
||||||
|
for sx in range(x0, x1 + 1):
|
||||||
|
# the lid: the part's drawn top laid across its depth from
|
||||||
|
# the back, last row continuing forward; the front lid row
|
||||||
|
# is the facade's own top row -- the drawn front-top edge
|
||||||
|
for z in range(pd):
|
||||||
|
sy = fr0 if z == pd - 1 else min(tr0 + z, tr1)
|
||||||
|
while sy <= tr1 and not inside(sx, sy):
|
||||||
|
sy += 1
|
||||||
|
if sy > tr1 and not (z == pd - 1 and inside(sx, fr0)):
|
||||||
|
continue
|
||||||
|
put(sx, ytp, z, sx, fr0 if z == pd - 1 else sy)
|
||||||
|
# the body: facade rows anchored to the desk's top plane
|
||||||
|
for sy in range(fr0 + 1, fr1 + 1):
|
||||||
|
y = plane + (fr1 - sy)
|
||||||
|
if not inside(sx, sy):
|
||||||
|
continue
|
||||||
|
ix = interior(sx, sy, x0, x1)
|
||||||
|
for z in range(pd):
|
||||||
|
if z == pd - 1:
|
||||||
|
if (sx, sy) not in pr["recess"]:
|
||||||
|
put(sx, y, z, sx, sy)
|
||||||
|
else:
|
||||||
|
put(sx, y, z, sx if z == 0 else ix, sy)
|
||||||
|
return vox
|
||||||
|
|
||||||
|
|
||||||
def build(sp, pr, t):
|
def build(sp, pr, t):
|
||||||
"""The voxel model. Order is load-bearing: walls, ledge, recesses, then
|
"""The voxel model. Order is load-bearing: walls, ledge, recesses, then
|
||||||
the roof solid overwrites what it intersects and the walls are trimmed
|
the roof solid overwrites what it intersects and the walls are trimmed
|
||||||
to the roof's underside."""
|
to the roof's underside."""
|
||||||
|
if t.get("parts"):
|
||||||
|
return build_desk_set(sp, pr, t)
|
||||||
W, H, D = pr["W"], pr["H"], pr["D"]
|
W, H, D = pr["W"], pr["H"], pr["D"]
|
||||||
inside, top, ytop = pr["inside"], pr["top"], pr["ytop"]
|
inside, top, ytop = pr["inside"], pr["top"], pr["ytop"]
|
||||||
col, src = sp["col"], sp["src"]
|
col, src = sp["col"], sp["src"]
|
||||||
@@ -720,7 +940,9 @@ def build(sp, pr, t):
|
|||||||
|
|
||||||
# ---- walls: the facade rows extruded straight back, trimmed under the roof
|
# ---- walls: the facade rows extruded straight back, trimmed under the roof
|
||||||
for sy in range(t["roof_rows"], H):
|
for sy in range(t["roof_rows"], H):
|
||||||
y = H - 1 - sy
|
y = pr["ground"] - 1 - sy # rows below the ground line are floor
|
||||||
|
if y < 0:
|
||||||
|
continue
|
||||||
for sx in range(W):
|
for sx in range(W):
|
||||||
if not inside(sx, sy) or trimmed(sx, y):
|
if not inside(sx, sy) or trimmed(sx, y):
|
||||||
continue
|
continue
|
||||||
@@ -742,7 +964,7 @@ def build(sp, pr, t):
|
|||||||
if t["ledge"]:
|
if t["ledge"]:
|
||||||
l0, l1 = t["ledge"]
|
l0, l1 = t["ledge"]
|
||||||
for sy in range(l0, l1 + 1):
|
for sy in range(l0, l1 + 1):
|
||||||
y = H - 1 - sy
|
y = pr["ground"] - 1 - sy
|
||||||
for sx in range(W):
|
for sx in range(W):
|
||||||
if inside(sx, sy) and not trimmed(sx, y):
|
if inside(sx, sy) and not trimmed(sx, y):
|
||||||
for z in (-2, -1, D, D + 1):
|
for z in (-2, -1, D, D + 1):
|
||||||
@@ -750,7 +972,7 @@ def build(sp, pr, t):
|
|||||||
|
|
||||||
# ---- recesses: the front voxel of every pane sinks, its frame stays proud
|
# ---- recesses: the front voxel of every pane sinks, its frame stays proud
|
||||||
for sx, sy in pr["recess"]:
|
for sx, sy in pr["recess"]:
|
||||||
vox.pop((sx, H - 1 - sy, D - 1), None)
|
vox.pop((sx, pr["ground"] - 1 - sy, D - 1), None)
|
||||||
|
|
||||||
# ---- roof: flat top over the plateau, stepped diagonal ends
|
# ---- roof: flat top over the plateau, stepped diagonal ends
|
||||||
z0, z1 = 0, D - 1 + t["front_eave"]
|
z0, z1 = 0, D - 1 + t["front_eave"]
|
||||||
@@ -805,6 +1027,83 @@ def verify(vox, sp, pr, t):
|
|||||||
for (x, y, z), _ in vox.items():
|
for (x, y, z), _ in vox.items():
|
||||||
assert y <= T(x), f"voxel pokes through the roof at {x},{y},{z}"
|
assert y <= T(x), f"voxel pokes through the roof at {x},{y},{z}"
|
||||||
|
|
||||||
|
# A desk set answers its own asserts; a facade-only template (roof_rows
|
||||||
|
# == 0) has no roof band, so there is no surface to hold level or slope
|
||||||
|
# and no coverage to demand of it; everything with a roof band answers
|
||||||
|
# the full set.
|
||||||
|
if t.get("parts"):
|
||||||
|
verify_desk_set(vox, pr, t)
|
||||||
|
elif t["roof_rows"] == 0:
|
||||||
|
# its one geometric intent: a straight extrusion, so each column
|
||||||
|
# of the drawing is solid from its lowest voxel to its top. NOT
|
||||||
|
# from the ground -- the drawing's base band is inset like every
|
||||||
|
# lab table's, and the body's outer columns legitimately
|
||||||
|
# overhang it -- and not on the recess layer, which sinks panes.
|
||||||
|
lo, hi = {}, {}
|
||||||
|
for (x, y, z) in vox:
|
||||||
|
lo[(x, z)] = min(lo.get((x, z), y), y)
|
||||||
|
hi[(x, z)] = max(hi.get((x, z), y), y)
|
||||||
|
for (x, z), h in hi.items():
|
||||||
|
if z == D - 1:
|
||||||
|
continue
|
||||||
|
assert all((x, y, z) in vox for y in range(lo[(x, z)], h + 1)), \
|
||||||
|
f"facade column has a hole at {x},{z}"
|
||||||
|
else:
|
||||||
|
verify_roof(vox, pr, t)
|
||||||
|
|
||||||
|
shell = [k for k in vox if not all(
|
||||||
|
(k[0] + d[0], k[1] + d[1], k[2] + d[2]) in vox
|
||||||
|
for d in ((1, 0, 0), (-1, 0, 0), (0, 1, 0),
|
||||||
|
(0, -1, 0), (0, 0, 1), (0, 0, -1)))]
|
||||||
|
return shell
|
||||||
|
|
||||||
|
|
||||||
|
def verify_desk_set(vox, pr, t):
|
||||||
|
W, D = pr["W"], pr["D"]
|
||||||
|
f0, f1 = t["desk"]["fascia"]
|
||||||
|
b0, b1 = t["desk"]["base"]
|
||||||
|
plane = (b1 - b0 + 1) + (f1 - f0 + 1)
|
||||||
|
|
||||||
|
# the slab is a full solid under everything on it
|
||||||
|
for x in range(W):
|
||||||
|
for z in range(D):
|
||||||
|
for y in range(b1 - b0 + 1, plane):
|
||||||
|
assert (x, y, z) in vox, f"slab hole at {x},{y},{z}"
|
||||||
|
|
||||||
|
# each part stands where authored -- upright bodies solid from the
|
||||||
|
# desk to their own drawn top (bar the front layer, whose panes
|
||||||
|
# sink) -- and nothing stands anywhere else
|
||||||
|
tops = {}
|
||||||
|
for p in t["parts"]:
|
||||||
|
x0, x1 = p["x"]
|
||||||
|
if p["kind"] == "flat":
|
||||||
|
r0, r1 = p["rows"]
|
||||||
|
z0 = p.get("z", r0)
|
||||||
|
for x in range(x0, x1 + 1):
|
||||||
|
for z in range(max(z0, 0), min(z0 + r1 - r0, D - 1) + 1):
|
||||||
|
tops[(x, z)] = max(tops.get((x, z), 0), plane)
|
||||||
|
else:
|
||||||
|
fr0, fr1 = p["facade"]
|
||||||
|
ytp = plane + (fr1 - fr0)
|
||||||
|
for x in range(x0, x1 + 1):
|
||||||
|
for z in range(p["depth"]):
|
||||||
|
tops[(x, z)] = max(tops.get((x, z), 0), ytp)
|
||||||
|
if z == p["depth"] - 1:
|
||||||
|
continue
|
||||||
|
ys = [y for y in range(plane, ytp + 1)
|
||||||
|
if (x, y, z) in vox]
|
||||||
|
assert ys == list(range(ys[0], ys[0] + len(ys))) \
|
||||||
|
if ys else True, f"part column hole at {x},{z}"
|
||||||
|
for (x, y, z) in vox:
|
||||||
|
assert y <= tops.get((x, z), plane - 1), \
|
||||||
|
f"voxel above its part at {x},{y},{z}"
|
||||||
|
|
||||||
|
|
||||||
|
def verify_roof(vox, pr, t):
|
||||||
|
W, H, D = pr["W"], pr["H"], pr["D"]
|
||||||
|
ytop, top, slab = pr["ytop"], pr["top"], t["slab"]
|
||||||
|
T = lambda x: ytop - top[max(0, min(W - 1, x))]
|
||||||
|
|
||||||
# The roof surface reads over the columns the drawing actually paints:
|
# The roof surface reads over the columns the drawing actually paints:
|
||||||
# a sprite inset from its box says nothing about the rest.
|
# a sprite inset from its box says nothing about the rest.
|
||||||
roofed = [x for x in range(W) if top[x] < t["roof_rows"]]
|
roofed = [x for x in range(W) if top[x] < t["roof_rows"]]
|
||||||
@@ -845,12 +1144,6 @@ def verify(vox, sp, pr, t):
|
|||||||
elif any((x, y, z) in vox for y in range(0, T(x) - slab + 1)):
|
elif any((x, y, z) in vox for y in range(0, T(x) - slab + 1)):
|
||||||
assert (x, T(x), z) in vox, f"wall uncovered at {x},{z}"
|
assert (x, T(x), z) in vox, f"wall uncovered at {x},{z}"
|
||||||
|
|
||||||
shell = [k for k in vox if not all(
|
|
||||||
(k[0] + d[0], k[1] + d[1], k[2] + d[2]) in vox
|
|
||||||
for d in ((1, 0, 0), (-1, 0, 0), (0, 1, 0),
|
|
||||||
(0, -1, 0), (0, 0, 1), (0, 0, -1)))]
|
|
||||||
return shell
|
|
||||||
|
|
||||||
|
|
||||||
def preview(shell, vox, sp, name, out, flip, canvas=(1700, 900), pad=20):
|
def preview(shell, vox, sp, name, out, flip, canvas=(1700, 900), pad=20):
|
||||||
# Mirroring depth swings the camera round to the other side. Mirror
|
# Mirroring depth swings the camera round to the other side. Mirror
|
||||||
|
|||||||
Reference in New Issue
Block a user