The Rendering Engine
tile57 contains a full native S-52 rendering engine: it draws a finished chart — raster PNG or vector PDF — straight from ENC charts, with no MapLibre, browser, or GPU involved. This page explains how it works, how to use it, and how to extend it.
The one-paragraph version
Charts are turned into draw calls. The scene generator reads charts, runs
the official S-101 portrayal rules, and calls methods like fillArea("DEPVS", rings) or drawSymbol("BOYLAT13", point) on a Surface. What happens next
depends on which Surface is listening: the tile surface serializes those
calls into MVT/MLT tiles for a MapLibre client to draw later; the pixel
surface resolves them (color tokens → RGB, symbol names → vector outlines)
and paints them onto a Canvas — a raster canvas for PNG, a PDF canvas for
print. One engine, pluggable outputs.
The architecture
┌──────────────────────────────────────────────┐
ENC chart ─►│ scene generator (src/scene/) │
│ parse → S-101 portrayal (embedded Lua) → │
│ project → clip → draw calls, per tile/view │
└───────────────────┬──────────────────────────┘
│ the Surface interface (src/render/surface.zig)
│ fillArea · fillPattern · strokeLine ·
│ drawSymbol · drawSounding · drawText
┌──────────────────┼─────────────────────┐
▼ ▼ ▼
TileSurface NoopSurface PixelSurface (src/render/pixel.zig)
serialize the discard (bench) resolve + layout + declutter
semantics │
│ │ the Canvas interface
▼ │ fillPath · strokePath ·
MVT / MLT tiles │ fillPattern · drawGlyphRun
+ MapLibre style ┌─────────┴─────────┐
(client draws them) ▼ ▼
RasterCanvas PdfCanvas
→ PNG → PDF (real text objects)
Two interfaces do all the work:
-
Surface (
src/render/surface.zig) — the semantic interface. Calls carry S-52 meaning: color tokens likeDEPVS, symbol names likeBOYLAT13, raw sounding depths, S-52 metadata (drawing priority, display category, SCAMIN). Nothing is resolved yet. This is what lets the tile surface emit re-styleable tiles — a MapLibre client can switch day/night palettes or the safety contour without re-baking, because the tiles still contain the names, not the pixels. -
Canvas (
src/render/canvas.zig) — the drawing interface. By the time a call reaches a Canvas, everything is resolved: RGB colors, flattened polygon outlines, positioned glyphs. A Canvas knows nothing about charts. This is deliberately the same shape as a classic 2-D drawing API, so a new output format is one small file.
Between them sits the PixelSurface, written once and shared by every pixel format. It does the genuinely chart-aware work:
- the resolver (
src/render/resolve.zig) turns color tokens into RGB at the chosen palette and evaluates the mariner's display gates — display category, SCAMIN scale gating, viewing groups, text groups — live, at render time, from real settings; - symbols replay the official catalogue SVGs as vector outlines
(
src/render/symbols.zig+ the nanosvg-backed store insrc/sprite/) — no bitmap blitting, crisp at any scale; - soundings are composed digit-by-digit from the same SNDFRM04 routine the tile path uses, at the mariner's actual safety depth and display unit;
- text is shaped with a from-scratch TrueType reader
(
src/render/font.zig, embedded Noto Sans) and decluttered over the whole scene — higher drawing priority wins, exactly like a real ECDIS; - everything is buffered, sorted into S-52 paint order (areas → patterns → lines → symbols → soundings → text), and painted through the Canvas.
The PDF canvas deserves a note: labels become real PDF text objects (the font is embedded, with a ToUnicode map), so the output is selectable, searchable, and print-sharp — not a picture of text. Both outputs are byte-deterministic: the same scene renders the same file, every time.
Why the mariner settings matter more here
The tile path has to freeze the portrayal context at bake time (a tile
archive can't re-run Lua per user), and papers over it with swappable
properties the style toggles at runtime. The pixel path evaluates the
mariner's display gates — palette, display category, SCAMIN, viewing groups,
text groups, size scale — live at render time for any source. Rendering a
live chart (the CLI on a .000, or Chart.openBytes in Zig) goes further:
the S-101 rules themselves run with the mariner's actual safety contour,
boundary style, and point-symbol style — what you see is what the rules
decided for your settings, the ECDIS-faithful path. Rendering a baked
archive replays its tiles, so the rule outcomes are the bake's; the
swappable parts re-evaluate.
Using it
From the command line
# One tile of a chart, as a 512px PNG
tile57 png US5MD1MC.000 14 4712 6280 -o tile.png --size 512
# A view (any centre, fractional zoom, any size) from a single chart
tile57 png US5MD1MC.000 --view -76.48,38.974,15.1 --size 1600x1200 -o annapolis.png
# The same view as a vector PDF with selectable text
tile57 pdf US5MD1MC.000 --view -76.48,38.974,15.1 --size 1600x1200 -o annapolis.pdf
# From a baked PMTiles bundle instead of the source chart (tile replay)
tile57 png chart.pmtiles --view -76.48,38.974,15.1 --size 1024x768 -o out.png
# Mariner settings
tile57 png ... --safety 5 --safety-depth 5 --feet --palette night \
--no-names --plain --simplified --dq --scale 1.5
From C (and therefore Go, Python, C++, …)
Everything is bake, then render: open a baked archive as a chart and ask it
for a view (same allocate-*out / free-with-tile57_free convention as the
rest of the ABI):
tile57_chart *c = NULL;
tile57_chart_open("US5MD1MC.pmtiles", &c, NULL); /* a baked archive */
tile57_mariner m;
tile57_mariner_defaults(&m);
m.safety_contour = 5.0;
m.scheme = TILE57_SCHEME_NIGHT;
uint8_t *png; size_t len;
tile57_chart_png(c, -76.48, 38.974, 15.1, 1600, 1200, &m, &png, &len, NULL);
/* ... write/display png ... */
tile57_free(png);
uint8_t *pdf; size_t plen;
tile57_chart_pdf(c, -76.48, 38.974, 15.1, 1600, 1200, &m, &pdf, &plen, NULL);
That renders ONE chart, no composition. A view across a whole chart library
is the same call on the compositor — tile57_compose_png / _pdf — which
composes every covering tile through the ownership partition first. See
Compose.
m.size_scale calibrates physical size (so 1 S-52 millimetre is a true
millimetre on your display). Every field of tile57_mariner — categories, text
groups, contours, units — evaluates live.
HiDPI is a separate input, m.device_scale: the device pixels per reference
pixel your framebuffer has. size_scale is the mariner's size preference,
device_scale is the display's density, and the two multiply. The pixel outputs
ignore it — there the engine rasterizes at the width and height you asked for, so
the density is already stated. It matters on the surface paths, where the
engine sizes text and symbols in reference px and you draw them: a host that
draws at 2x without saying so gets collision boxes sized for glyphs half the size
it paints, and a view the engine decluttered cleanly arrives on screen
overlapping.
From Zig
Both interfaces are directly available: build a PixelSurface, drive it with
scene.generateTile / scene.generateView, or replay a decoded tile with
scene.replayTile. See tools/bake.zig's runRender for a complete worked
example. For the higher-level Chart entry points — renderView (PNG / PDF /
canvas), renderSurfaceView, and renderGpuScene — see the
Zig API render page.
Extending it
A new output format = one Canvas implementation. Implement four methods
(fillPath, strokePath, fillPattern, drawGlyphRun) over your target —
an SVG writer, a framebuffer blitter, a plotter driver — and every chart
feature, symbol, and label arrives already resolved and positioned.
src/render/pdf.zig (~350 lines) is the model to copy.
A new tile/serialization format = one Surface implementation. Implement
the ten Surface methods and you receive the full semantic stream — this is
how MVT and MLT are done (TileSurface in src/scene/scene.zig), and how a
GeoJSON debug dump or a GPU display list would be done.
From the C ABI: the same engine is exposed three ways, all in S-52 paint order — see Render surfaces for the full contract:
tile57_chart_canvasdrives atile57_canvas_cb— resolved, flattened paths, patterns, and glyph outlines in pixel space (the Canvas seat).tile57_chart_surfacedrives atile57_surface_cb— the world-space, semantically tagged stream (per-feature class + SCAMIN, world anchors, reference-pixel outlines) a GPU host tessellates once and transforms per frame (the Surface seat).tile57_chart_gpu_scenehands back draw-ready GPU buffers — already triangulated, already in paint order, already batched into one-pipeline ranges — so a GPU host does no tessellation and owns no copy of the S-52 ordering rules.
The two callback seats arrive already sorted (areas → patterns → lines → symbols →
soundings → text, by draw priority within each class), so drawing in callback order
is correct and no host needs its own sort — but only an order you preserve
survives. A GPU host that batches by draw type to cut pipeline switches reorders the
stream and breaks paint order; the fix is to batch by display_priority band
(not by pipeline, and not by sorting each per-type batch), drawing the bands in
ascending order. That rule is documented in full on the C API page:
Paint order. The
draw-ready GPU scene
sidesteps it entirely — its ranges are the order, so drawing them in sequence is
all a host does.
The two text callbacks also carry the label's S-52 text group, so a host can draw
group 11 (important text) larger or bold and ordinary names normally — that group
belongs to the label, not the feature. All three seats have composed twins on the
compositor (tile57_compose_canvas / _surface / _gpu_scene). A custom output
format in Zig is still one small file in src/render/.
For a tile-renderer host that caches geometry per tile (via the per-tile
tile57_chart_tile_surface), a companion tile57_chart_labels
(tile57_compose_labels on the compositor) emits only the view's text —
resolved against one collision pool across every covering tile, so labels no
longer collide or repeat at tile seams the way the per-tile pass leaves them.
It reuses the same tile57_surface_cb text callbacks and world anchors and draws
no geometry, so the host paints cached tiles then overlays this decluttered text
last. Each covering tile's label candidates memoize on the chart (or compositor),
so only the first view of a region portrays: a pan, zoom or rotation over tiles
already seen re-resolves the cached candidates against the new view instead.
What's deliberately not here (yet)
- Contour labels render horizontal (not rotated along the line).
- No kerning (chart labels are short; Noto's advances read fine).
- Translucent fills print opaque in PDF.
- Archive replay doesn't overzoom past the baked range (the compositor serves one fill-up zoom past it).
- The PDF embeds the whole label font (~600 KB) rather than a subset.