Skip to content

Render MapLibre-style at the drawn scale: tile57/3 tiles, runtime sounding runs, exchange-set bakes - #58

Merged
beetlebugorg merged 11 commits into
mainfrom
fix/maplibre-rendering
Aug 24, 2026
Merged

Render MapLibre-style at the drawn scale: tile57/3 tiles, runtime sounding runs, exchange-set bakes#58
beetlebugorg merged 11 commits into
mainfrom
fix/maplibre-rendering

Conversation

@beetlebugorg

Copy link
Copy Markdown
Owner

This branch fixes how charts render through the MapLibre path and the GPU scene, and adds the bake and compose pieces those fixes needed.

Rendering

  • Bake the sprite atlas at the scale it is drawn at, and hand the GPU atlas the ppm its cells were baked at, so symbols come out the right size on
    screen.
  • Render sounding digit runs at runtime (tile57_render_symbol_run) instead of prebaking every combination.
  • Walk complex linestyles into the composed tile, so line symbology survives composition.
  • Bake a FullLightLines variant and drop sector legs below a minimum length.

Tile format

  • Move to tile57/3: one line source-layer per style and style precomputes baked into the tiles. Charts baked with an earlier version need to be
    rebaked.

Bake

  • Prepare a whole exchange set straight from its archive (tile57_bake_zip).
  • Compile the portrayal rules once per thread rather than once per chart, drop the VM reuse, and size the bytecode cache for the real catalogue.

Build

  • Strip the static library for visionOS.
  • Keep the Lua shim's accessors in the test binaries that link it.
  • Count the boolean walk diagnostics in usize: a 32-bit target has no 64-bit atomic add, which broke the x86 build the OpenCPN plugin needs.

ABI additions: tile57_bake_zip, tile57_compose_set_px_per_tile, tile57_render_symbol_run, tile57_bake_sprite_mln takes a pixel ratio and scheme, and the GPU scene vertex and quad carry per-vertex color, paint-order depth, and the text run flip/tangent fields.

Every symbol in a baked library draws at the engine's SYMBOL_SCALE, but the
MapLibre sheet rasterized at the 0.08 catalogue scale — 8x the area anything
displayed. Bake at the drawn scale (mln_drawn_scale) and rebase the style's
icon-size so everything samples 1:1: the day sheet falls 105 MB -> 19 MB RGBA
and every entry fits under MapLibre's 1024 px image cap.

Sounding depths are comma-joined glyph runs no prebaked sheet can enumerate.
tile57_render_symbol_run renders one run to RGBA on demand (exportAlloc
convention, so tile57_free stays the one deallocator); the host answers
MapLibre's missing-image events with it. renderSymbolRun applies the same
drawn scale so runtime digits match the sheet.

glyphpbf wrote fields 5 and 6 unsigned; they are zigzag in the spec, so any
negative glyph bearing corrupted the whole PBF walk downstream (the
'Channel' -> 'anne' text). A TestReader regression walks the encoded ranges.
MapLibre evaluates every style layer's filter against every feature of that
layer's source-layer at each tile layout. With all decorated lines in one
'lines' source-layer, 85 of 100 layers scanned every line feature of every
tile — about a thousand expression nodes per feature, the largest single
cost in a zoom profile, and why content trailed the camera on a library
crossing.

The bake now routes each complex-linestyle feature into its own
'lines-ls-<STYLE>' source-layer, so a style's two or three layers see only
their own features and a tile without the style doesn't carry the
source-layer at all. The name keeps the 'lines' prefix: replay and query
dispatch on startsWith and portray them unchanged. The compositor buckets
the split layers by name through seam composes, and the style drops the
merged spacing-bucket machinery — symbol spacing is exact per style again.

endScene also folds, per feature, everything the style otherwise re-derives
per feature per layer (augmentV3): vz/oz, the display zooms the SCAMIN and
overscale gates compare against ['zoom'] in one node; ep, the effective
DrawingPriority with the danger-over-sounding deviation baked in, which
collapses the point partition's class-membership clause; lsk, the line
z-order sort; and mq/iso/lt flags replacing the M_QUAL/ISODGR01/LIGHTS
string compares.

Found wiring it: the category gate read a 'cat' property no tile has ever
carried (tiles bake display_category), so every feature coalesced to
STANDARD and the base/other toggles and data-quality overlay were silently
inert on the MapLibre path. The gate reads the real properties now.

Schema tile57/3: a /2 archive still renders under the new style but gates
and sorts nothing, so a library needs a rebake. Note for zip sources: the
bake preserves the archive's inner directory prefix, so 'ENC_ROOT/' nests —
worth flattening some day.
tile57_bake_zip is the archive twin of tile57_bake_tree: find the cells,
name every output, bake. Nothing is unpacked — each cell inflates as its
turn comes, so importing an 800 MB set never costs the disk a second copy.

Naming was the reason for adding it. Working out where each chart goes had
been reinvented three times (this CLI, the macOS shell, an Android path)
and two of the three carried the archive's own wrapper directory into the
output: NOAA's All_ENCs.zip puts every cell under ENC_ROOT/, so -o
~/Charts/ENC_ROOT wrote ~/Charts/ENC_ROOT/ENC_ROOT/ — a second library
beside the real one, which a host that opens the parent then composes
together with it, one vintage silently winning per tile. The prefix is
computed from what the cells actually share rather than assumed to be one
level, so an archive holding two districts keeps them apart. The CLI now
takes the rule from the engine (archiveRootPrefix) instead of its own copy.

Incremental, as bakeTree already was: an archive newer than the zip is
skipped. A national set is hours of work on a tablet and will be
interrupted — backgrounded, battery, or the mariner stopping to sail — and
without this every resume started from the first cell and never finished.

The count is reported before the first chart bakes rather than when one
finishes. The first cell out of a national set is a huge overview cell, so
a host had no denominator for minutes and could only show a spinner; it
looked stuck while working. fileModNs uses statFile instead of
open+fstat+close, which over Android's FUSE storage was three round trips
per chart before any progress was reported at all.

The CLI's own tests are collected now. Nothing but the executable
referenced tools/*.zig, and an executable does not collect tests, so the
test already written there had never run.
tg_portray_run stood up a whole VM per call: a fresh lua_State, the
standard libraries, thirty-odd host bindings, then require of the five
framework modules — which COMPILES them. A bake runs it up to three times
per chart (base, plain boundaries, simplified symbols), so a 7,224-cell
exchange set compiled the framework about 21,000 times. Lua's code
generator was the largest single item in an Android bake profile, ahead of
every piece of chart work.

Two steps, because the obvious one is wrong on its own. Caching the
compiled bytecode per thread and undumping it is safe and needs no other
change. Keeping the VM as well is what removes newstate/openlibs/register,
and that alone broke six golden Part-9 streams: PortrayalAPI holds
featureCache/informationCache/spatialCache at module level keyed by ID, and
our IDs are feature indices, which repeat from one chart to the next — the
second chart read the first chart's features back out of the cache.
Dropping the modules from package.loaded and requiring them again per pass
rebuilds those caches, and with the bytecode cached it costs no
compilation. Thread-local throughout: a lua_State is not thread-safe and
the bake runs a worker per core.

douglasPeucker counts its kept points and allocates once instead of
appending one at a time, which regrew the list log2(n) times per line —
over every line in every tile of every cell, with Android's hardened
allocator charging for each.

Measured over a 1,200-chart bake on an 8-core tablet these are worth
1.00-1.06x, which is not what the profile shares suggested: memset fell
20% -> 12% and libc 5.7% -> 2.6%, but shares redistribute when the
denominator shrinks. Kept because they cut real work — a phone with fewer
cores and a battery pays for all of it.
…logue

The cache was built for five framework modules and the catalogue is not
five modules: the framework requires a rule module per feature class on
demand, and sixty charts touch 98 of them. At sixteen entries it cached the
framework, recompiled every rule, and missed 3,741 times against 2,031
hits. Sized properly that inverts to 329 misses — one compile per module
per thread, which is the floor.

It makes no difference. Measured over identical bakes: 60 charts on macOS
0.96x, 1,200 charts on an 8-core tablet 378s against 372s. Both inside
run-to-run noise, and the properly-sized cache is no faster than the broken
one. The compile cost is real and visible in a profile — Lua's code
generator was the largest single item on Android — but it is not on the
critical path, and freeing those cycles buys no throughput.

So the VM reuse goes. It was the half that carried risk: PortrayalAPI keeps
ID-keyed caches at module level and our IDs repeat between charts, so
keeping a state read one chart's features back for the next — six golden
Part-9 streams changed. Reloading the framework per pass fixed that, and
paid for it with a full GC and five module re-executions per pass. None of
that is worth carrying for an effect no measurement can find. The bytecode
cache stays: it is twenty lines, cannot leak state, and costs one dump per
module per thread.

The measured speedup on Android is the worker count, not this.
…can build

A 32-bit x86 target has no 64-bit atomic add, which the OpenCPN plugin build hits.
@beetlebugorg
beetlebugorg merged commit 8c3f631 into main Aug 24, 2026
5 checks passed
@beetlebugorg
beetlebugorg deleted the fix/maplibre-rendering branch August 24, 2026 03:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant