EncDotNet.S100.Rendering.Engine
0.28.0
dotnet add package EncDotNet.S100.Rendering.Engine --version 0.28.0
NuGet\Install-Package EncDotNet.S100.Rendering.Engine -Version 0.28.0
<PackageReference Include="EncDotNet.S100.Rendering.Engine" Version="0.28.0" />
<PackageVersion Include="EncDotNet.S100.Rendering.Engine" Version="0.28.0" />
<PackageReference Include="EncDotNet.S100.Rendering.Engine" />
paket add EncDotNet.S100.Rendering.Engine --version 0.28.0
#r "nuget: EncDotNet.S100.Rendering.Engine, 0.28.0"
#:package EncDotNet.S100.Rendering.Engine@0.28.0
#addin nuget:?package=EncDotNet.S100.Rendering.Engine&version=0.28.0
#tool nuget:?package=EncDotNet.S100.Rendering.Engine&version=0.28.0
EncDotNet.S100.Rendering.Engine
EncDotNet.S100.Rendering.Engine composites S-100 vector scenes into a map
view. It holds the tile compositor: the EPSG:3857 tile grid, raster tile jobs on
the work scheduler, the process-wide tile memory pool, the warm disk tile cache,
GPU texture residency, prediction and pre-warm, the overscale curtain, and the
coverage clipping and overlap suppression between chart cells. It also holds the
single-surface compositor and the rendering options, ChartSessionOptions.
The engine doesn't depend on Mapsui or a UI framework. It draws with SkiaSharp
through EncDotNet.S100.Renderers.Skia
and consumes scenes from
EncDotNet.S100.Rendering.Scene.
To show charts on a Mapsui map, use
EncDotNet.S100.Renderers.Mapsui,
which adapts the engine to Mapsui; you don't need to reference this package
yourself. Reference it directly when you write an adapter for another host.
The engine is the first step of the staged migration in the
map rendering architecture
design note (slice 7). A host creates a ChartSession per map (slice 8);
the tile compositor's entry points are still process-wide static classes.
Install
dotnet add package EncDotNet.S100.Rendering.Engine
Drive the engine from a host
A host identifies each layer to the engine by an owner token: any object, held weakly, that the engine never calls into. The Mapsui adapter uses the Mapsui layer itself. A host:
- Installs a repaint callback once at startup with
LayerRepaint.SetHandler. The engine calls it, from any thread, with the owner token of a layer whose tiles finished rasterising or whose view is out of date. - Binds each layer's scene with
S100VectorTileRenderer.BindScene(owner, scene)(orS100VectorSceneRenderer.BindScenefor the single-surface mode). Pass a product layer set and style hash to share tiles through the warm disk cache. - On every frame, calls
S100VectorTileRenderer.Render(canvas, view, frame)with the frame'sViewState(centre, resolution and rotation in EPSG:3857, size in device-independent pixels) and aLayerFrame(owner, name, opacity and data extent). The canvas's scale is the device scale.
using EncDotNet.S100.Rendering.Engine;
using EncDotNet.S100.Rendering.Scene;
using SkiaSharp;
LayerRepaint.SetHandler(owner => ((MyLayer)owner).Invalidate());
S100VectorTileRenderer.BindScene(layer, scene);
void Paint(SKCanvas canvas, MyLayer layer, MyView v) =>
S100VectorTileRenderer.Render(
canvas,
new ViewState(v.CenterX, v.CenterY, v.Resolution, v.Rotation, v.Width, v.Height),
new LayerFrame(layer, layer.Name, layer.Opacity, layer.Extent));
Call S100VectorTileRenderer.ShutdownAndDrain before the process exits, so no
tile job is still in native Skia code when libSkiaSharp unloads. To measure how
long a view takes to settle, turn on S100VectorTileRenderer.SettleTrackingEnabled
and call EvaluateSettle with a SettleLayer per host layer.
Predict what the view needs next
Each ChartSession has a DemandPlanner (map rendering slice 9). A host
reports every view with Observe, and, when it starts one, a fling or an
animation with BeginFling or BeginAnimation. The planner's
PredictionModel weighs a known destination first, then the pan velocity and
zoom rate, the route ahead of own ship (SetInputs), and the places the view
rested at. Chart layers add the views a second ahead to their predicted tiles.
A host's cell loader reads Plan(): its CellViews reach a few seconds ahead.
It reports the cells the view waits for to Arrivals, so the chart stack can
hold a compose until they land (ChartSessionOptions.StackHoldMs). Stats
counts slot and cell hits, and predicted tiles and cells used or wasted.
using EncDotNet.S100.Rendering.Engine.Prediction;
var planner = session.Planner;
planner.Observe(view, Stopwatch.GetTimestamp());
planner.BeginAnimation(target, TimeSpan.FromMilliseconds(600), Stopwatch.GetTimestamp());
foreach (var predicted in planner.Plan().CellViews)
{
// Load the cells under predicted.View, likeliest and soonest first.
}
The Mapsui adapter does all of this for a map: MapsuiMapNavigator reports
views and the animations it starts, and the Avalonia adapter reports flings.
Render images and tile sets
Hosts that draw a whole image or a tile set, rather than a live map, use
ChartRenderer. The s100 render, s100 tiles and s100 mcp serve commands
and the library's PngS100DatasetRenderer all go through it.
ChartRenderer.PortrayAsyncportrays each dataset as its ownPortrayjob on the work scheduler, in theVisibleclass, so several cells portray in parallel.ChartCompositor.Prepareputs the portrayed datasets in S-98 order, applies the inter-product rules, and lowers them to aChartComposition. A finer chart cell hides an overlapping coarser one with the viewer's NTS coverage model: each cell's footprint is its coverage areas unioned and normalised, and a finer cell takes part only where its footprint meets the coarser one.ChartComposition.Render(viewport, deviceScale)draws one view.ChartCompositeOptions.DeviceScale(or thedeviceScaleargument) makes a HiDPI image of the same device-independent view.ChartRenderer.RenderTilesAsyncrenders XYZ tile blocks with aChartTileRenderer, asRasterjobs in theVisibleclass, queued in the order they're written. Per-tile work such as encoding runs on the job, and the results reach the writer in order. A layer that one finer cell's coverage hides across a whole tile isn't drawn for that tile.
var compositor = new ChartCompositor(
new ProjNetCrsTransformFactory(),
new InteroperabilityAuthority(ProductRegistry.Default.DisplayPlanes));
using SKBitmap image = await ChartRenderer.RenderAsync(
[new ChartPortrayalRequest(processorA, contextA), new ChartPortrayalRequest(processorB, contextB)],
compositor,
new ChartCompositeOptions { Width = 1024, Height = 768, DeviceScale = 2f });
Options
ChartSessionOptions holds the rendering options. The engine reads the
process-wide instance, ChartEngine.Options, which
ChartSessionOptions.FromEnvironment() seeds from the environment variables
below on first use. A variable that is set pins its option: later writes are
ignored and the matching …EnvExplicit property is true, so a perf run keeps
the value it started with. SoundCharts shows most options under Settings >
Advanced > Base-plane rendering.
| Option | Environment variable | Default | Takes effect |
|---|---|---|---|
Profile |
S100_PERF_PROFILE |
Auto |
Re-derives the budgets that aren't pinned |
SceneMode |
S100_VECTOR_SCENE_MODE (single) |
Tiled |
On the next layer build |
TileGutterDip |
S100_VECTOR_TILE_GUTTER |
64 DIP | On newly rasterised tiles |
TileBudgetMb |
S100_VECTOR_TILE_BUDGET_MB |
By tier | On the next tile insert |
TilePredictionEnabled |
S100_VECTOR_TILE_PREDICT |
On | Next frame |
TileCrossBandPrewarmEnabled |
S100_VECTOR_TILE_XBAND |
On, off on LowEnd |
Next frame |
TileMetatileEnabled |
S100_VECTOR_TILE_METATILE |
Off | On the next tile job |
TileDiskCacheEnabled |
S100_VECTOR_TILE_DISK |
On | Restart |
TileDiskMb |
S100_VECTOR_TILE_DISK_MB |
By tier | Restart |
TileDiskDirectory |
S100_VECTOR_TILE_DISK_DIR |
OS temp folder | Restart |
TileGpuResidencyEnabled |
S100_VECTOR_TILE_GPU |
Off | Next frame |
TileGpuBudgetMb |
S100_VECTOR_TILE_GPU_MB |
By tier | On the next upload |
SkiaGpuResourceMb |
S100_SKIA_GPU_RESOURCE_MB |
By tier | When the host creates its GPU context (read-only) |
LiveBaseEnabled |
S100_VECTOR_TILE_LIVE_BASE |
On | On the next scene bind |
SceneMarginPx |
S100_VECTOR_SCENE_MARGIN |
256 px | Next frame |
Two diagnostic switches stay environment-only: S100_VECTOR_TILE_DIAG=1 and
S100_VECTOR_SCENE_DIAG=1 write compositor summaries to stderr. The tile job
threads come from the work scheduler in EncDotNet.S100.Core
(S100_WORK_THREADS).
Internals
The base plane is the chart's area fills, contours and lines. The scene path is
the only base-plane path: the VectorScene is drawn by
S100VectorTileRenderer (default) or S100VectorSceneRenderer. The earlier
Mapsui feature and style path, its caches, line simplification and line
level-of-detail pyramid have been removed; see the repository history for them.
Single-surface renderer
S100VectorSceneRenderer is the single-surface compositor. It rasterises the
scene with SkiaDisplayListRenderer on a worker thread, then swaps in and blits
the finished SKImage on the UI thread (Appendix B). It renders the viewport
plus a margin (S100_VECTOR_SCENE_MARGIN, default 256 DIP) at device scale, so
a pan within that margin is only a translated blit.
Select it with S100_VECTOR_SCENE_MODE=single,
ChartEngine.Options.SceneMode = VectorSceneMode.Single, or in SoundCharts
Settings > Advanced > Base-plane rendering > Scene mode >
Single surface. MapsuiDisplayListRenderer then tags the layer with
S100VectorSceneRenderer.RendererName and binds the scene with BindScene.
The worker is latest-wins: a superseded request is dropped, never published. It
applies scale visibility, converting the EPSG:3857 resolution back to an S-100
denominator (ScaleDenominatorFor), so the same SCAMIN detail shows and hides
as on the live frame. Rotated viewports draw nothing; this renderer is north-up
only. On publish it asks the host for a repaint through LayerRepaint.
SceneRasterizeDuration (worker) and SceneCompositeDuration (UI blit)
measure the two halves. In an 18-step gesture script on the PDB01 cell, the
worst on-screen frame went from about 409 ms on the retired Mapsui path to about
5 ms (Appendix B).
Tiled base plane
S100VectorTileRenderer, the default, renders the scene into a pyramid of
cached tiles (Appendix C). It divides the world into a power-of-two EPSG:3857
grid anchored at the world origin (TileGrid, 256-DIP tiles, XYZ convention)
and rasterises each visible tile on a worker. Because the grid doesn't move with
the viewport, a pan at constant zoom reuses every interior tile and rasterises
only the newly exposed edge.
Antimeridian. Tile enumeration (TileGrid.VisibleTileRange and
PredictedTiles) clamps only the Y (latitude) index at the poles and leaves the
X (longitude) index unclamped, apart from a 4096-column guard. A dataset kept in
continuous longitudes across the antimeridian, such as the US NWS S-411 sea-ice
product (about 175°E to 225°E), tiles into columns past the last world column,
whose TileWorldBounds map east of +180°. RasterizeTile sets EnableSeamWrap
to false on its SkiaDisplayListRenderer, so large continuous polygons aren't
wrapped back across the world.
Compositing. Each frame, the UI thread snaps the resolution to the nearest
band and blits the best available tile for every visible slot, each clipped to
its core. Tiles are rasterised with a gutter (S100_VECTOR_TILE_GUTTER, default
64 DIP), so strokes stay continuous across tile edges and no hole shows, but a
cached tile keeps only its core plus a 2-pixel apron for bilinear sampling
(ApronPx). Storing the whole gutter took 2.25 times the core's pixels; the
apron takes about 1.03 times. The target band is
drawn on top. While it's incomplete, cached tiles from the single nearest other
band are drawn underneath, within MaxFallbackBandDistance (2) bands, so zoom
transitions don't show symbols at several sizes.
Cache and workers. Finished tiles go into the layer's TileCache. Every
layer's cache charges one process-wide TileMemoryPool with one LRU order and
one native-byte budget (S100_VECTOR_TILE_BUDGET_MB, default set by the
performance profile). When the pool is full, the tiles
drawn least recently go first, whichever layer holds them, so a culled, hidden
or off-screen layer's tiles go before any visible layer's. Each layer pins its
visible tiles every frame, so they're never evicted mid-frame. A pin lapses
once other layers have drawn for 2 seconds without this one, so a layer that
stops drawing can't hold its tiles. GPU textures have a second pool
(S100_VECTOR_TILE_GPU_MB). All cache access goes through
the layer lock, so a worker can't dispose an image the compositor is drawing.
Each pending tile is a job on the process-wide work scheduler
(EncDotNet.S100.Core.Scheduling.WorkScheduler.Shared): visible tiles run
before predicted ones, and those before cross-band pre-warm, and within a class
the tiles nearest the view centre run first, whichever layer they belong to.
The scheduler owns one worker thread per logical core but one
(S100_WORK_THREADS pins the count), so the UI thread keeps a core, and no
layer has workers of its own. The pending sets are rebuilt every frame, and a
tile that leaves them drops its job before it starts.
TileRasterizeDuration (worker) and TileCompositeDuration (UI) measure the
two halves; TileColdLatency measures how long a cold tile takes to appear,
queue wait included.
Rotation. A rotated viewport, such as one left by a trackpad pinch, is
composited north-up to an off-screen surface, and that image is rotated about
the screen centre by an angle taken from ViewState.WorldToScreenXY, which follows Mapsui's rotation convention. Tile
selection grows to the rotated viewport's bounding box
(TileGrid.RotatedCoverSize) so the corners are covered. Compositing north-up
first keeps tile joins and the edge between fallback and target bands
axis-aligned, so zooming while rotated doesn't show seams (Appendix F.8).
Device scale. Tiles are rasterised at the frame's device scale
(canvas.TotalMatrix.ScaleX): a 256-DIP tile plus its two gutters is
rasterised at 384 px at 1x and 768 px at 2x, and stored at 260 px and 516 px. A cached tile's pixel size records its scale. A tile of
another size is drawn only as a placeholder while a replacement rasterises, and
never counts as a hit. Moving a window between a Retina and a standard display
re-rasterises once. The disk cache is split by pixel size too.
Off-screen renders. A host that renders the live layers to an image at a
different scale, such as an Avalonia RenderTargetBitmap capture at 96 dpi or a
print preview, wraps the call in S100VectorTileRenderer.BeginOffscreenRender().
Inside that per-thread scope, Render composites only cached tiles, scaled to
the target, and the live overlay. It doesn't schedule tiles, change the device
scale, viewport or velocity the live workers use, change which tiles are
protected from eviction, or touch GPU textures. AvaloniaControlCapture does
this, so captures don't leave 1x tiles behind for a 2x window to keep drawing.
Empty tiles. A tile whose bounds and gutter touch no base op of the cell is
never scheduled, cached, stored or drawn (BaseSpatialIndex.Intersects). It
would be fully transparent, so the output is the same. A cell's tile work is
proportional to the part of the viewport it covers.
Tiles hidden by a finer cell. A coarser cell's tile that one finer, drawn
cell covers completely is never scheduled, cached, stored or drawn; the coverage
clip would erase all of it. CoverageClip.GetHiddenCoverage returns the finer
coverages active at the current resolution. A tile is skipped when its core,
padded by 2 DIP at the coarsest resolution its band is shown at, lies inside one
of them; the padding keeps the clip's anti-aliased edge. The test uses one
coverage at a time, not their union: each finer cell is clipped separately, so
a faint line of the coarser cell remains where two finer cells meet, and
skipping across that join would change the picture. When one finer coverage
hides the whole viewport, the layer's live overlay is skipped too. Invalid
coverages never hide anything. Counters: s100.render.tile.hidden.skipped and
s100.render.tile.layer.hidden.skipped.
In the 18-step gesture script on PDB01, on-screen frameDurationMs stayed
bounded: p50 about 7.7 ms, p90 about 34 ms, maximum about 37 ms (the worst
frames are zoom-out fallback blits), against about 409 ms on the retired Mapsui
path. Pans held at about 3–8 ms with no visible seams (Appendix C).
Performance profile
MachineProfile sizes the tile caches and worker pools for the host. A
PerformanceProfile tier seeds the hot, GPU and disk budgets. Auto, the
default, picks the tier from logical cores and available memory: LowEnd for
4 or fewer cores or 8 GB or less, Balanced for 8 or fewer cores or 16 GB or
less, otherwise HighEnd. Set S100_PERF_PROFILE (Auto, LowEnd,
Balanced or HighEnd) to choose a tier. The individual budget variables
still override each budget.
| Setting | LowEnd |
Balanced |
HighEnd |
|---|---|---|---|
Hot tile cache, all layers (S100_VECTOR_TILE_BUDGET_MB) |
512 MB | 1 GB | 1.5 GB |
GPU tile cache, all layers (S100_VECTOR_TILE_GPU_MB) |
256 MB | 512 MB | 768 MB |
Disk tile cache (S100_VECTOR_TILE_DISK_MB) |
256 MB | 384 MB | 512 MB |
Skia GPU resource cache (S100_SKIA_GPU_RESOURCE_MB) |
128 MB | 256 MB | 512 MB |
Cross-band pre-warm (S100_VECTOR_TILE_XBAND) |
Off | On | On |
SoundCharts shows the profile and the detected tier under Settings > Advanced.
Skia keeps an uploaded texture for every raster tile it blits in the host GPU
context's resource cache (ChartEngine.Options.SkiaGpuResourceMb). That
cache must hold the visible tiles of every layer, or every tile is uploaded
again every frame. Avalonia's default, about 28 MB, holds only a couple of dozen
Retina tiles, so pass this budget to SkiaOptions.MaxGpuResourceSizeBytes.
SoundCharts does this at startup.
Symbol and sounding overlay
Base tiles contain only area fills, contours and lines.
S100VectorTileRenderer.PartitionScene sends PointPaintOp and TextPaintOp
to an overlay scene, which is drawn live every frame over the composited tiles
with SkiaDisplayListRenderer.RenderOnto. This is required for correctness. A
tile is rasterised once per band and drawn scaled to the current resolution, so
symbols baked into tiles would grow and shrink during a zoom instead of keeping
the constant screen size S-100 requires. Drawn against the live viewport, their
pixel sizes stay constant at any zoom. Under rotation, the overlay rotates with
the tile composite (Appendix F.11).
S100VectorTileRenderer.TryGetPartitionedScene(layer, out base, out overlay)
returns a layer's base and overlay scenes for checking without pixels.
MultiProductParityTests uses it to check that point symbols never suppress
labels.
Because the overlay redraws every symbol and sounding each frame, the costs
described in
How SkiaDisplayListRenderer draws a scene
matter here: symbol pictures are cached, off-screen point and text ops are
skipped first, and fonts and paints are reused. DrawOverlay passes a cull
rectangle widened to the rotated viewport's bounding box, so nothing visible is
dropped under rotation.
Prediction
To avoid showing cold tiles during a pan or zoom, the tiled renderer rasterises
tiles before they scroll into view (Appendix D). Each frame it estimates the
viewport centre's velocity as a moving average of frame-to-frame changes
(VelocityEstimator, EPSG:3857 m/s) and builds a warm set
(TileGrid.PredictedTiles): a one-tile ring around the visible range, a fan
along the direction of travel whose depth grows with speed (0.5 s ahead, up to 4
tiles), and the centre tiles of the bands above and below, so a zoom step finds
them ready.
The warm set is a separate, lower-priority queue (PendingPredicted). Workers
drain visible misses (PendingVisible) first, so prediction never delays a tile
on screen. The set is rebuilt, which cancels the old one, every frame.
A predicted tile doesn't request a repaint when it's published; only visible
tiles do (ShouldRequestRedraw). A repaint for an off-screen tile would change
nothing but would start a frame that predicts and publishes the next tile, a
repaint loop that never lets the map settle (Appendix F.7).
Prediction is on by default; S100_VECTOR_TILE_PREDICT=0 turns it off. In a
20-step pan on PDB01, frames showing cold tiles fell from 58% to 16% with
prediction on, at about a 32% prediction hit rate. The remainder was the cold
start; the steady pan itself had none.
Telemetry: s100.render.tile.prediction.hits and .rasterized count
predictions, and the s100.render.tile.cold.exposure histogram measures cold
tiles shown. s100.render.tile.cold.latency (ms) is the time from a visible
tile first being seen cold to its publication, queue wait included;
s100.render.tile.rasterize.duration is the raster time alone; and
s100.render.tile.visible.queue.depth is the number of cold misses a gesture
creates. A slow tiling worker shows high cold latency and a deep queue with
cheap paints; slow Mapsui paints show low cold latency and long paint times.
Cross-band pre-warm
Prediction warms only the centre tiles of the neighbouring bands, so a zoom
across a band boundary still waits for most of the new band. When a layer is
otherwise idle, cross-band pre-warm rasterises the whole viewport in both
neighbouring bands (TileGrid.CrossBandPrewarmTiles).
It runs in a third, lowest-priority queue (PendingCrossBand), behind visible
and predicted tiles. It's queued only on frames with no cold visible misses and
while the hot cache is below 75% of its budget, so it never evicts the current
tiles; visible target-band tiles are also protected with TileCache.Protect.
The set starts at the centre and is capped at 24 tiles per frame. Like
prediction, its tiles never request a repaint and are rebuilt every frame; a
later zoom that uses one counts as a prediction hit.
Cross-band pre-warm is on by default except on the LowEnd tier, where you can
still turn it on. S100_VECTOR_TILE_XBAND=0 (CrossBandPrewarmEnabled) turns
it off. To measure a zoom, read time-to-fill at the new band from
s100.render.tile.cold.latency and s100.render.tile.prediction.hits.
Metatile raster jobs
The tiled renderer can take pending tiles from one aligned 2×2 block in one priority tier, rasterise them together, and split the result back into ordinary tile cache and disk entries. SCAMIN visibility is checked for each row; a job splits by row when visibility differs. Per-tile cold latency, prediction accounting, eviction and repaint behaviour don't change.
Metatiling is experimental and off by default. Turn it on with
S100_VECTOR_TILE_METATILE=1, ChartEngine.Options.TileMetatileEnabled, or
SoundCharts' Batch adjacent tiles setting. Measure it with
s100.render.metatile.rasterize.duration, .slice.duration, .tiles, .jobs
and .fallbacks. The fallback counter has a reason tag: sparse, disk,
scamin or dimension. Leave it off unless A/B runs on real cells show lower total raster
time without worse cold latency, paint time, memory or pixels.
Idle memory compaction
Cell loads, portrayal pre-warm and tile rasterisation allocate gigabytes in
bursts, and Server GC keeps the memory it committed for them. A host can start
IdleMemoryCompactor (SoundCharts does) to give it back. Once at least 512 MB
has been allocated since the last compaction and the process has been quiet for
3 seconds (allocating under 32 MB/s, no visible tiles waiting, no view change),
it runs one blocking, compacting gen-2 collection, at most once a minute. On
.NET 10, Server GC decommits the freed memory only a step at a time during later
collections, so the compactor then runs a gen-0 collection (about 1 ms) once a
second while the process stays quiet, until the committed size is within
256 MB of the heap or a minute has passed. Set S100_IDLE_GC=0 to turn it off.
Disk tile cache
Below the in-memory cache, TileDiskCache keeps PNG-encoded tiles on disk
(Appendix E). They survive a layer rebuild, so switching back to a palette reuses
them, and a restart. A tile missing from memory is read from disk on the worker
before it's rasterised again. Visible raster results are published immediately,
then offered to a bounded, process-wide write queue; predicted results are
stored only once they become visible. One low-priority writer encodes and stores
tiles atomically. Duplicate or overflow work is dropped rather than blocking a
render worker.
The cache directory is namespaced by
SHA-256(productLayerSet | styleStateHash). MapsuiDisplayListRenderer
computes styleStateHash from the palette, the symbol and text scales, and the
drawing instructions, which already encode display category, safety contour and
every feature and portrayal selection. Any change to these gives a new
namespace, so a tile is never served for a different palette or mariner state.
Orphaned tiles are removed by the size-limited LRU sweep. The palette is hashed
by DescribePalette, its Name plus its ordered colours, not by
ColorPalette.ToString(), which has no override and would make every palette
look the same (Appendix F.9). The renderer also adds the tile's pixel size to
the namespace (TileDiskCache.NamespaceFor(ns, tilePixelSize), for example
…-768px), so a 1x tile is never served to a 2x frame. Bumping
TileDiskCache.FormatVersion makes older stored tiles misses.
The cache works like DiskPortrayalInstructionCache: atomic temp-file-and-move
writes, LRU eviction by modification time down to a byte budget, and any error
treated as a miss. Reads don't wait for writes. The queue drains on a normal
exit; if the process is killed, pending writes can be lost.
| Variable | Default |
|---|---|
S100_VECTOR_TILE_DISK |
On |
S100_VECTOR_TILE_DISK_DIR |
A subdirectory of the OS temp directory |
S100_VECTOR_TILE_DISK_MB |
Set by the performance profile |
Telemetry: s100.render.tile.disk.hits, .writes,
s100.render.tile.disk.write_queue.depth and .discarded. On PDB01, a Day,
Night, Day palette switch produced two separate namespaces; 163 tiles were
stored and 198 served from disk on the switch back instead of being
rasterised again. On a 360-step navigation route over 16 overlapping UK S-101
cells, moving writes off the render workers cut tile p95 from 781 ms to 49 ms,
frame p95 from 23 ms to 12 ms, and viewport-command p95 from 92 ms to 2.3 ms,
while completing 735 background writes. With the disk cache off, the same route
measured 30 ms, 8.8 ms and 3.1 ms.
GPU texture residency
GPU residency keeps composited tiles as GPU textures so a steady pan or zoom
doesn't upload the same pixels every frame. A profile of a steady pan without it
put 98% of render-thread native time in BlitTile → SKCanvas.DrawImage,
re-uploading unchanged tiles (Appendix F). With residency, the first time a
raster tile is composited it's promoted with SKImage.ToTextureImage(GRContext)
into a per-layer GPU TileCache, and later frames draw the resident texture.
s100.render.tile.gpu.uploads and .hits track reuse.
Residency only applies to GPU-backed surfaces. SKCanvas.Context is null on a
software surface, and the renderer then uses the raster path. On an Apple
silicon Metal surface, a steady pan went from about 38 ms to about 3 ms per frame
with a 96–99% GPU hit rate.
It's off by default (S100_VECTOR_TILE_GPU; budget S100_VECTOR_TILE_GPU_MB).
On a paced pan and zoom route over 13 UK S-101 cells, eager ToTextureImage
promotion and texture churn could block the compositor thread: the slowest frame
was 3,155 ms and p95 591 ms with residency, against 123–160 ms and 75–79 ms
without.
GPU resource lifetime and shutdown
- Thread confinement. GPU-backed
SKImages must be created and freed on the render thread, which owns the GPU context. Freeing one on the finalizer thread crashes Skia's native GPU backend. All GPU texture changes go throughManageGpuResidencyandBlitTile, on the render thread under the layer lock. A closed dataset, a palette change that swaps in a new layer, or a layer collected by the GC leaves aTileStatethat never renders again. So every GPU texture cache is held by a process-wide registry with a strong reference to the cache and a weak reference to its layer. When the layer is collected, the next render disposes the orphaned cache on the render thread. On PDB01, four close-all and reopen cycles ran with no crash, frames at 6–9 ms and a 96% GPU hit rate. - Deferred disposal.
SKCanvas.DrawImageis deferred: Skia reads the texture when it flushes, after the render method returns, so a texture must outlive the frame that drew it. The GPUTileCacheis built withdeferDisposal: true: evicted, replaced or cleared textures are freed at the start of the next frame'sComposite(DrainPendingDisposals()), after the earlier frame has flushed. Drawing fallback tiles only from the single nearest band also bounds the number of draws per frame, so zooming out to the whole world doesn't composite the entire cache at once. - Faults. The paint block and the rasterisation worker reset their state
through one guarded path, so an exception during paint drops one frame
(
s100.render.tile.faults) instead of leaving a blank chart. On PDB01, with GPU residency on and off, zooming in, out to the whole world and back in rendered correctly with no crash or blank frame. - Rotation surfaces. The off-screen composite of a rotated frame, a
GPU-backed
SKSurfaceand itsSKImagesnapshot, follows the same rule. Normally the nextCompositefrees the previous pair. When a layer is torn down with a rotated frame still set, for example when a palette change swaps in new layers, the pair is mirrored into the layer'sGpuRegistryEntry, soReconcileGpuCachesfrees it on the render thread. Only the small GPU pair is kept alive; the CPU tile cache stays on the weakly heldTileStateand can be collected. Software rotation surfaces can be finalized on any thread and aren't mirrored. - Shutdown. Workers call native Skia, so the process mustn't unload
libSkiaSharpwhile a worker is rasterising; that crashes withSIGSEGV.S100VectorTileRenderer.ShutdownAndDrain(timeout), backed byWorkerDrainGate, sets a permanent draining flag and waits for running workers to finish. Each worker registers before it starts, and a refused worker returns before calling Skia. SoundCharts calls it fromIClassicDesktopStyleApplicationLifetime.ShutdownRequested, which Avalonia raises on every exit path.WorkerDrainGateTestscover the synchronization. - Diagnostics. Set
S100_VECTOR_TILE_DIAG=1to write a compositor summary to stderr about once a second: target-band completeness, fallback bands drawn, and cache and GPU residency, plus a line whenever the layer draws nothing.
Stability and versioning
The supported surface of this package is ChartSessionOptions, ChartEngine,
ViewState, WorldRect, LayerFrame, LayerRepaint, SettleLayer and the
public members of S100VectorTileRenderer and S100VectorSceneRenderer.
Internal and undocumented types can change at any time. The package is a
preview: the static entry points are expected to move onto a per-map session.
All EncDotNet.S100.* packages share one version, taken from the release git
tag. Versioning follows Semantic Versioning: from 1.0.0
on, a breaking change to the supported surface comes only in a major version.
Below 1.0.0, a minor version can include breaking changes, and the release
notes list them.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- EncDotNet.S100.Core (>= 0.28.0)
- EncDotNet.S100.Portrayals (>= 0.28.0)
- EncDotNet.S100.Renderers.Skia (>= 0.28.0)
- EncDotNet.S100.Rendering.Scene (>= 0.28.0)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.14)
- NetTopologySuite (>= 2.6.0)
- SkiaSharp (>= 3.119.4)
- ZstdSharp.Port (>= 0.8.8)
NuGet packages (2)
Showing the top 2 NuGet packages that depend on EncDotNet.S100.Rendering.Engine:
| Package | Downloads |
|---|---|
|
EncDotNet.S100.Renderers.Mapsui
Libraries for manipulating S-100 based nautical charts. |
|
|
EncDotNet.S100
Open an IHO S-100 dataset, read its features and render it to an image using the bundled feature and portrayal catalogues. Wraps EncDotNet.S100.Specifications and EncDotNet.S100.Datasets.Pipelines behind a small, format-agnostic API; the underlying readers and catalogues remain available for custom wiring. |
GitHub repositories
This package is not used by any popular GitHub repositories.