EncDotNet.S100.Renderers.Mapsui
0.25.1
dotnet add package EncDotNet.S100.Renderers.Mapsui --version 0.25.1
NuGet\Install-Package EncDotNet.S100.Renderers.Mapsui -Version 0.25.1
<PackageReference Include="EncDotNet.S100.Renderers.Mapsui" Version="0.25.1" />
<PackageVersion Include="EncDotNet.S100.Renderers.Mapsui" Version="0.25.1" />
<PackageReference Include="EncDotNet.S100.Renderers.Mapsui" />
paket add EncDotNet.S100.Renderers.Mapsui --version 0.25.1
#r "nuget: EncDotNet.S100.Renderers.Mapsui, 0.25.1"
#:package EncDotNet.S100.Renderers.Mapsui@0.25.1
#addin nuget:?package=EncDotNet.S100.Renderers.Mapsui&version=0.25.1
#tool nuget:?package=EncDotNet.S100.Renderers.Mapsui&version=0.25.1
EncDotNet.S100.Renderers.Mapsui
EncDotNet.S100.Renderers.Mapsui draws S-100 datasets as layers on a
Mapsui map and manages them as a map session: loading,
paint order, S-98 interoperability, time, presentation and picking. Reference it
when you show S-100 data on an interactive Mapsui map. The package doesn't
depend on a UI framework; for Avalonia, add
EncDotNet.S100.Renderers.Mapsui.Avalonia.
For a step-by-step introduction, see
Add S-100 data to a Mapsui app.
The package targets net8.0 and net10.0. It draws vector data through the
scene IR in EncDotNet.S100.Rendering.Scene
and EncDotNet.S100.Renderers.Skia.
It doesn't include a CRS implementation: supply one, such as
ProjNetCrsTransformFactory from EncDotNet.S100.Crs.ProjNet.
Install
dotnet add package EncDotNet.S100.Renderers.Mapsui
dotnet add package EncDotNet.S100.Crs.ProjNet
dotnet add package EncDotNet.S100
EncDotNet.S100 supplies BundledDatasetProcessorFactory, which lets the
session load datasets from a path. If you build dataset processors yourself, you
don't need it.
Add S-100 data to a map
map.AddS100(options) attaches an S-100 session to a Mapsui.Map and returns
it as an IS100MapSession. This example loads one cell, frames it, picks at its
centre and disposes the session. It's based on the
MapHost sample's
headless smoke test.
using EncDotNet.S100;
using EncDotNet.S100.Crs.ProjNet;
using EncDotNet.S100.Renderers.Mapsui;
using Mapsui;
using Mapsui.Projections;
using var processorFactory = BundledDatasetProcessorFactory.Create();
var map = new Map { CRS = "EPSG:3857" };
await using var session = map.AddS100(new S100MapsuiOptions
{
CrsTransformFactory = new ProjNetCrsTransformFactory(),
DatasetPipelineFactory = processorFactory,
});
// A live map control sets the viewport size; a headless host sets it itself.
map.Navigator.SetSize(1000, 800);
var id = await session.Datasets.LoadAsync("path/to/cell.000");
session.ZoomToDataset(id);
var extent = session.GetDataset(id)!.Extent!;
var (longitude, latitude) = SphericalMercator.ToLonLat(extent.Centroid.X, extent.Centroid.Y);
var picks = await session.Query.PickAsync(new GeographicPickQuery
{
Latitude = latitude,
Longitude = longitude,
Resolution = map.Navigator.Viewport.Resolution,
});
AddS100 registers the S-100 Mapsui renderers and builds the layer bands,
processor owner, dataset renderer, dataset-layer session and navigator. The
returned session owns them; nothing is stored in a static table or Map.Tag.
Disposing the session disposes the processor owner, and every processor it
holds, only if AddS100 created that owner. An owner you pass in is left to you.
Pan, zoom and rotation stay with Map.Navigator. ZoomToDataset is a
convenience.
Options
S100MapsuiOptions carries every collaborator the session uses:
| Property | Purpose |
|---|---|
CrsTransformFactory |
Projects coverage grids to EPSG:3857. Required unless you supply DatasetRenderer. |
DatasetPipelineFactory |
Builds processors for Datasets.LoadAsync. Not needed if you only call AddDatasetAsync. |
DatasetRenderer |
A prebuilt MapsuiDatasetRenderer, which already carries a CRS factory. |
ProcessorOwner |
A shared DatasetProcessorOwner, for example one owned by a DI container. The session doesn't dispose it. |
InteroperabilityAuthorityProvider |
The S-98 interoperability authority. |
PatternClipCache |
A pattern-clip cache, such as a DiskPatternClipCache; see Reuse pattern-fill clips. |
SceneMode |
VectorSceneMode.Tiled (default) or VectorSceneMode.Single. |
RedrawMarshal |
Runs redraw requests on your UI thread. |
DynamicSourceMarshal, DynamicFeatureRendererResolver, DynamicSourceCoalesceWindow |
Configure dynamic feature sources. |
Redraw on the UI thread
The session renders off the UI thread. When a finished image is ready, each
dataset layer asks for a repaint through a redraw sink that belongs to the
session. By default the sink calls Map.RefreshGraphics(), which every Mapsui
control repaints from, so a headless host needs nothing. If your control must be
invalidated on its dispatcher thread, set RedrawMarshal to an
Action<Action> that posts to that thread. On Avalonia,
mapControl.AddS100(...) sets it for you.
Load datasets
session.Datasets.LoadAsync(path) detects the product, builds a processor with
DatasetPipelineFactory, registers the dataset and renders it. It returns the
dataset's MapDatasetId. It loads one standalone dataset file. An ENC base cell
(.000) also picks up its sequential updates (.001, .002, …). Exchange-set
folders and archives aren't supported yet.
To add a processor you built yourself, call session.AddDatasetAsync(dataset, processor) with a MapDataset that describes it.
Load policy, such as suppressing duplicate cells, choosing which products are visible by default, and showing notifications, is up to your application.
Use dependency injection
Dependency injection is optional. For a Microsoft DI host,
services.AddS100Mapsui() registers an IS100MapSessionFactory that creates a
session for each Map:
services.AddSingleton<ICrsTransformFactory>(new ProjNetCrsTransformFactory());
services.AddS100Mapsui(_ => new S100MapsuiOptions { DatasetPipelineFactory = factory });
// Later, once a Map exists:
using var session = provider.GetRequiredService<IS100MapSessionFactory>().Create(map);
Create resolves the ICrsTransformFactory you registered and any
S100MapsuiOptions, then calls AddS100. Register a CRS implementation; the
package doesn't include one. The caller owns the returned session and disposes
it when the map goes away; the container doesn't.
Main types
IS100MapSession: the sessionAddS100returns. It exposesDatasets(loading),Query(picking),Layers(your own layers),DynamicSources,Navigatorand the underlyingSession, plus dataset, presentation and time methods.S100MapsuiOptions: session configuration; see Options.S100PickHighlightLayer,S100DatasetExtentIndicatorLayer,S100OverscaleCurtainLayer,S100ValidationFindingLayer: optional overlays; see Add optional overlays.MapsuiMapNavigator: viewport framing and recentring; see Navigate the viewport.S100DynamicSourceHost: draws live features such as own ship and AIS targets; see Show dynamic features.MapsuiCoverageRenderer: renders S-102, S-104 and S-111 coverage data as a georeferenced raster layer (ICoverageRenderer<ILayer>).MapsuiCoverageArrowRendererandThinnedSymbolLayer: draw current arrows, for example from S-111, as one SVG point symbol per grid cell. Each frame draws only the arrows that S-98 Appendix G grid thinning (SymbolThinning) keeps at that resolution, so arrow density stays readable at every zoom. Regular grids use lattice thinning; station series and meshes use point-by-point thinning.MapsuiDisplayListRenderer: turns aDrawingInstructiondisplay list and anIFeatureGeometryProviderinto a map layer, for any S-100 vector product (S-101, S-124, S-129, S-421 and others).- Lower-level components for hosts that compose the session themselves:
S100MapsuiRendering,MapsuiDatasetRenderer,MapsuiLayerBandsandMapsuiDatasetLayerSession; see Compose the session yourself.
Present datasets
The session applies your presentation, order, visibility and time to every dataset it holds:
await session.SetPresentationAsync(presentation); // MapPresentationState
await session.SetTimeAsync(time);
session.SetOrder(bottomToTopDatasetIds);
session.SetVisible(id, isVisible: false);
session.SetOpacity(id, 0.5);
- Presentation.
MapPresentationStateis an immutable snapshot of palette, symbol and text scale, ECDIS and mariner settings. The session combines it with each dataset's processor and selected time to build that product's render context. - Visible and active.
SetVisiblecontrols whether a dataset draws.SetActivecontrols whether it takes part in cross-product composition and picking. - Paint order and S-98. The session follows the S-98 interoperability authority you supply. It rebuilds the cross-product layer stack, applies the S-98 rules with the current mariner settings, and updates the dataset layers in one step (S-98 Ed 2.0.0 Main §9.2.1 and Annex A §8.4.1).
- Overlapping cells. Where a finer cell covers part of a coarser one, the coarser cell stops drawing there. Each cell also drops out once you zoom out past its catalogue scale window. See Portrayal conventions for the rules.
Time
SetTime moves the map clock without rendering, so a timeline can follow a
drag. SetTimeAsync moves it and re-renders the time-aware datasets.
GetTimeSnapshot returns the clock, the sample times, the overall range, the
covered segments, and each timed dataset with the span it covers (Datasets,
each with its own Coverage windows and Covers(time)).
The clock isn't clamped to the loaded data: it can sit in a gap or past the end, where each dataset follows its own rule. It starts at the first sample and stays put as datasets come and go.
A dataset never shows data from outside its tolerance as if it were current:
| Product | Shows |
|---|---|
| S-111 | The nearest sample within one step (the dataset's median sample interval). |
| S-104 | The latest sample at or before the clock, for up to one step. |
| S-411 | The latest snapshot at or before the clock, for up to 14 days. |
Outside its tolerance, including in gaps within a dataset, the dataset is hidden, and its coverage segments split around those gaps.
Session events
The session reports what it's doing through events, not notifications or localized strings, so you can drive your own UI:
DatasetRenderStartedandDatasetRenderCompleted(MapSessionDatasetRenderEventArgs) bracket each dataset render.Kind(MapSessionRenderKind:Render,TimeRefreshorPresentationRefresh) says what triggered it.Startedfires once the session holds the processor;Completedfires only when the render installs layers. A render that throws, is cancelled, or is superseded or removed after it starts raisesStartedwithoutCompleted.DatasetRenderFailed(MapSessionDatasetRenderFailedEventArgs) reports a failure the session caught during a refresh of several datasets, so the others keep rendering. A single dataset render (RenderAsync) throws to the caller that awaits it instead, so it raises no failed event.LayersChanged,TimeRangeChangedandCurrentTimeChanged(MapSessionCurrentTimeEventArgs) report changes to the dataset layers, the time range and the clock.
Pick features at a location
session.Query.PickAsync finds the features and coverage samples at a
geographic point, without a UI control:
var picks = await session.Query.PickAsync(
new GeographicPickQuery { Latitude = 50.75, Longitude = -1.45 });
// picks[0] is the topmost feature or coverage sample at that point.
It hit-tests each dataset that's currently shown (active, visible, and in time),
resolves each hit to a full FeatureInfo, and samples coverage datasets that
have no vector hit at the session's current time. Results are ordered topmost
first by the S-98 paint order, then within a dataset by geometry (point, then
curve, then area) and distance.
RadiusMeters(default 50 m) sets the tolerance for points and curves.MaxResultslimits the number of picks returned.Resolution, in metres per pixel like Mapsui'sNavigator.Viewport.Resolution, leaves out datasets drawn out of scale at that zoom: a cell is dropped once you zoom out past its catalogue scale window. Per-feature scale limits within a cell that's still drawn aren't applied. Omit it to skip scale filtering.
The query only works in geographic coordinates. Converting a pointer position
to latitude and longitude is the UI adapter's job; on Avalonia, use
PickAtScreenAsync in the Avalonia package.
Each vector S100Pick carries the feature's Geometry (S100FeatureGeometry:
rings, curves and points in WGS-84) when the processor provides it, so you can
outline the hit. It's null for a coverage pick or when the processor doesn't
provide geometry.
Highlight a pick
S100PickHighlightLayer is an optional overlay that outlines pick geometry. Add
its Layer to the map once, then call Show as the picks change:
var highlight = new S100PickHighlightLayer(); // Optionally pass an S100PickHighlightStyle.
map.Layers.Add(highlight.Layer);
var picks = await session.Query.PickAsync(query);
highlight.Show(picks.FirstOrDefault()); // Outline the topmost hit.
// highlight.Show(picks); // Or outline every hit.
// highlight.Clear(); // Remove the highlight.
The overlay draws in map coordinates, so the outline scales and moves with the
map. Areas get a faint fill and an accent outline on the exterior ring and
holes; curves get an accent stroke, split at the antimeridian; points get an
accent ring. A null pick, or a coverage pick with no geometry, clears the
layer. S100PickHighlightStyle sets the accent colour and the stroke and fill
weights; the default matches SoundCharts.
The overlay draws only the feature outline. A marker at the click point, or dimming the chart, is up to your application.
Add your own layers
session.Layers (IS100MapLayerHost) holds your layers in bands that keep
their order as datasets come and go: basemap, then datasets, then overlays, then
tools.
session.Layers.SetBasemapLayer(basemap);
session.Layers.AddOverlayLayer(validationLayer);
session.Layers.AddToolLayer(measureLayer);
RemoveOverlayLayer and RemoveToolLayer remove them. The dataset band isn't
on this interface; the session manages it through AddDatasetAsync,
RemoveDataset and SetOrder.
Add optional overlays
These overlays depend only on Mapsui. They don't use the session, a catalogue, an application palette, a view model or Avalonia. Deciding what to show, and when, is up to your application.
Dataset extent indicators
S100DatasetExtentIndicatorLayer outlines datasets that have dropped out of
scale as you zoom out, so you can still see where they are. Add its Layer
once, then call Show when datasets, visibility or zoom cutoffs change:
var extents = new S100DatasetExtentIndicatorLayer(); // Optionally pass an S100DatasetExtentIndicatorStyle.
map.Layers.Add(extents.Layer);
// One indicator per dataset with a mercator extent and a zoom-out cutoff.
// Extent is EPSG:3857 (Mapsui map units) and ContentMaxVisibleResolution is in
// metres per pixel. Both are nullable, hence the filter.
extents.Show(session.GetDatasets()
.Where(d => d.Extent is not null && d.ContentMaxVisibleResolution is not null)
.Select(d => new S100DatasetExtentIndicator(d.Extent!, d.ContentMaxVisibleResolution!.Value)));
// extents.Show(indicators, accent); // Change the accent without rebuilding the layer.
// extents.Clear(); // Remove all indicators.
Each indicator is a thin, dashed, unfilled rectangle. Its MinVisible is the
dataset's cutoff, so Mapsui shows it exactly when the dataset stops drawing and
hides it again when you zoom back in. Pass 0 to always show it, for example
for the footprint of a catalogue entry that isn't loaded.
S100DatasetExtentIndicatorStyle sets the accent, stroke weight, opacity and
dash.
The layer takes rectangles already projected to EPSG:3857. If you hold geographic bounds, project them yourself and split any footprint that crosses the antimeridian into two boxes.
Overscale curtain
S100OverscaleCurtainLayer draws the S-52 and S-101 overscale pattern
(AP(OVERSC01) Form A), evenly spaced vertical lines, over cells shown beyond
their compilation scale:
var curtain = new S100OverscaleCurtainLayer(); // Optionally pass an OverscaleCurtainStyle.
map.Layers.Add(curtain.Layer);
// The regions depend only on the loaded cells and the resolution (metres per
// pixel), not on pan or rotation, so recompute them only when those change.
var regions = OverscaleCurtain.ComputeRegions(overscaleCells, viewportResolution);
curtain.Show(regions);
// curtain.Clear(); // Remove the curtain.
OverscaleCurtain.ComputeRegions works in EPSG:3857. It returns one region per
overscaled cell: the cell's coverage minus every finer overlapping cell, so the
curtain marks only area that's really overscaled. The style draws lines anchored
to the map and clipped to each region every frame, so the pattern stays sharp at
any zoom and on HiDPI displays and moves with the chart. OverscaleCurtainStyle
sets the line spacing, width and colour.
Validation findings
S100ValidationFindingLayer plots validation findings that have a location: a
marker for a point finding and a translucent box for a bounding-box finding,
coloured by severity:
var findings = new S100ValidationFindingLayer(); // Optionally pass an S100ValidationFindingStyle.
map.Layers.Add(findings.Layer);
// A finding can have a Point, a BoundingBox, both (two features) or neither
// (skipped).
findings.Show(report.Findings
.Where(f => f.Point is not null || f.BoundingBox is not null)
.Select(f => new S100ValidationFinding(f.Severity, f.Point, f.BoundingBox)));
// findings.Clear(); // Remove the findings.
Pass WGS-84 locations; the layer projects them to EPSG:3857. Each Show
replaces the overlay's contents. S100ValidationFindingStyle sets the severity
colours, the point marker and halo, and the box outline and fill alpha; the
default uses red for errors, amber for warnings and blue for information.
Navigate the viewport
MapsuiMapNavigator frames and recentres an existing Mapsui.Map:
var navigation = new MapsuiMapNavigator(map);
navigation.ZoomToExtent(datasetExtent);
navigation.CenterOn(new GeoPosition(latitude, longitude));
It supports padded framing (ZoomToExtent), exact extent or centre and
resolution changes (SetViewportToExtent, SetViewportToCenterAndResolution),
rotation, recentring on a WGS-84 position, and reporting the viewport centre and
resolution. Exact changes take effect immediately; framing and recentring take
an optional animation duration. The navigator doesn't own the map, handle
gestures, switch threads, invalidate a control or zoom after a load.
Show dynamic features
The EncDotNet.S100.Renderers.Mapsui.DynamicSources namespace draws live
features from an IDynamicFeatureSource, such as own ship or AIS targets, on
the overlay band. Renderers turn each DynamicFeature into Mapsui features and
styles. For the source model, see the
dynamic feature source design
note.
S100DynamicSourceHostregisters sources as overlay layers, picks a renderer for each source, rebuilds layers when a source changes (coalescing frequent updates), and hit-tests features geographically. It implementsIS100DynamicSourceRegistry(registration, per-source visibility and hit-testing) and depends only on Mapsui. It takes:- an
IMapsuiOverlayLayerHostto add layers to (MapsuiLayerBandsimplements it); - an
Action<Action>to run updates on the UI thread (default: run inline); - a
Func<string?, IDynamicFeatureRenderer?>to resolve renderers (default: always the fallback renderer).
The session exposes one as
IS100MapSession.DynamicSources. You can also construct one over your own layer host.- an
IDynamicFeatureRenderer:CanRenderandRenderfor one feature. Implementations are stateless; the host owns layer state and threading.DefaultDynamicFeatureRenderer: the fallback renderer. It draws a coloured disc with an optional heading line scaled by speed (a six-minute predictor, capped at 10 nm) for a point, a line for a curve, and a translucent fill with an outline for a surface. It's also used when a source'sRendererKeyisnullor not registered.OwnShipRenderer: own-ship symbols under the key"ownship". When the vessel is longer thanMinVesselPixelson screen (22 px, about 6 mm at 96 dpi), it draws a true-scale hull outline fromDynamicFeature.VesselGeometrywith a cross at the GPS antenna; otherwise it draws a disc. Both modes draw a heading vector with an arrowhead. WithoutVesselGeometry, for example for an AIS target of unknown size, it always draws the disc. See the own-ship symbology design note.KindMatchingRenderer: chooses a renderer byDynamicFeature.Kind, by exact match or dotted prefix ("vessel"matches"vessel.cargo"). The longest key wins.CompositeDynamicFeatureRenderer: tries an ordered list of renderers and uses the first whoseCanRenderreturnstrue. Put specialised renderers first andDefaultDynamicFeatureRendererlast.DynamicFeatureRendererServiceCollectionExtensions: registers renderers in DI under the key a source declares inDynamicSourceMetadata.RendererKey:// Register a source and its renderer: services.AddDynamicFeatureSource<MyAisFeed, MyVesselRenderer>("vessel"); // Or only a renderer, to share across sources: services.AddDynamicFeatureRenderer<MyVesselRenderer>("vessel");With
AddS100Mapsui, the session resolves renderers withGetKeyedService<IDynamicFeatureRenderer>(source.Metadata.RendererKey)by default, so keyed registrations work without more setup.
Compose the session yourself
AddS100 builds the session from the components below. Use them directly only
when you need a different composition.
Register the renderers
Call S100MapsuiRendering.Register() once at startup, before you install any
diagnostics that wrap Mapsui's renderer registry. It registers the S-100 style
and layer renderers in dependency order and is safe to call more than once.
Rendering doesn't register them for you; AddS100 calls it.
Render a dataset's layers
MapsuiDatasetRenderer turns a processor's portrayal output into a
MapsuiDatasetResult (layers and extent). It reads the processor through the
IVectorPortrayalSource and ICoveragePortrayalSource interfaces, which are in
EncDotNet.S100.Core, so dataset processors don't depend on Mapsui. The
renderer owns everything specific to Mapsui: the pattern-clip cache,
feature-type tagging, the out-of-scale-band cap, the S-101 area and line
layers, the S-111 arrows, and the S-98 layer stack.
var options = new S100MapsuiOptions
{
SceneMode = VectorSceneMode.Tiled,
};
var renderer = new MapsuiDatasetRenderer(
crsTransformFactory,
patternClipCache,
options);
S100MapsuiOptions takes its SceneMode default from RenderingOptimizations,
the process-wide settings that environment variables seed. Setting a value on the
options configures one renderer without changing the process-wide value. Other
settings are still only on RenderingOptimizations. If you omit options, the
renderer uses the process-wide settings.
If you omit patternClipCache, the renderer keeps a single-entry in-memory
cache for its lifetime. Pass a DiskPatternClipCache to share clips across
renderers and process restarts.
Order layers in bands
MapsuiLayerBands keeps a map's layers in band order (basemap, datasets,
overlays, tools) without a UI control:
var bands = new MapsuiLayerBands(map);
bands.SetBasemapLayer(basemap);
bands.AddDatasetLayer(datasetLayer);
bands.AddOverlayLayer(validationLayer);
bands.AddToolLayer(measureLayer);
ReplaceDatasetLayers replaces or reorders only the dataset band. Each remove
method removes only layers in its own band. Calls change Map.Layers
immediately; switching threads and invalidating the control are up to the host.
Manage the dataset band
MapsuiDatasetLayerSession manages the dataset band on top of
MapsuiLayerBands. It's the component IS100MapSession.Session exposes.
using var layerSession = new MapsuiDatasetLayerSession(
bands,
processorOwner,
renderer,
interoperabilityAuthorityProvider);
layerSession.SetDataset(dataset, minimumDisplayScale, maximumDisplayScale);
await layerSession.RenderAsync(dataset.Id, presentation);
layerSession.SetCurrentTime(clock);
await layerSession.RefreshTimeAsync(presentation);
layerSession.SetOrder(bottomToTopDatasetIds);
layerSession.SetMarinerSettings(presentation.Mariner);
- Register the processor with the
DatasetProcessorOwnerfirst. Rendering takes a lease on it. - Replacing layers is transactional. Cancellation, removal, a changed processor or an S-98 projection failure leaves the previous layers in place. Concurrent renders are latest-started-wins, so an older render can't replace newer output or bring back a removed dataset.
SetDatasetregisters the dataset as time-aware when its processor implementsITimeAwareDatasetProcessor.SetCurrentTimeupdates the clock immediately.RefreshTimeAsyncwaits 100 ms for further changes and cancels the previous time refresh.RefreshAsyncre-renders everything for a new presentation; the latest request wins.- All render methods share one lock. Call and await them from the synchronization context that owns the map.
GetLayerStackEntriesreturns the full S-98 stack, including inactive datasets, andGetStackedLayersreturns the layers in the dataset band.
Reuse symbol work across renders
MapsuiDisplayListRenderer processes SVG symbols and rasterises pattern tiles
the first time it needs them. Processed SVGs depend on the palette, and pattern
rasterisation is relatively expensive. When you re-render the same dataset
repeatedly, for example to switch palettes, step through time or change mariner
settings, give every render the same MapsuiRenderAssetCache:
private readonly MapsuiRenderAssetCache _renderAssetCache = new();
// For each render:
var renderer = new MapsuiDisplayListRenderer
{
Palette = palette,
AssetCache = _renderAssetCache,
SymbolProvider = symbolProvider,
AreaFillProvider = areaFillProvider,
};
The cache keeps entries for each palette (Day, Dusk, Night), so switching
back and forth keeps all of them warm. Without AssetCache, each renderer
instance uses its own cache. MapsuiDatasetRenderer keeps one cache per
processor.
Reuse pattern-fill clips
Clipping pattern fills against each other and against land can take seconds on
dense cells. The clip runs once per layer build, not per frame, and the clipped
geometry doesn't depend on the palette. IPatternClipCache lets a render reuse
the clip when its inputs haven't changed, most importantly after a palette
switch. Set both PatternClipCache and a PatternClipCacheKey that fully
identifies the clip inputs:
// One cache per processor:
private readonly InMemoryPatternClipCache _patternClipCache = new();
// Or one disk cache for the whole process:
var sharedClipCache = new DiskPatternClipCache(cacheDir, maxBytes: 256L * 1024 * 1024);
var renderer = new MapsuiDisplayListRenderer
{
// Palette, providers and asset cache as above.
PatternClipCache = sharedClipCache,
PatternClipCacheKey = $"{datasetScope}|{portrayalCacheKey}",
};
With both set, a palette switch with the same key reuses the clip. On a dense test cell, a first Day render took about 6 s and the following Night switch about 0.2 s. If either is unset, the clip is computed every time.
InMemoryPatternClipCacheholds one entry, so its memory is bounded to one cell. It only helps re-renders of a dataset that's already open, and it's lost when the dataset closes.DiskPatternClipCache(cacheDirectory,maxBytes) is shared by the process and stored on disk, so even the first open of a cell seen before skips the clip, after a restart too. It stores each clip as WKB in a file named after the SHA-256 of the key. Writes are atomic, and the least recently used entries are removed abovemaxBytes. Any read error orFormatVersionmismatch counts as a miss and never throws. Because the cache is shared, the key must be fully qualified. The S-101 processor uses{datasetScope}|{portrayalKey}, wheredatasetScopeincludes the dataset content hash, the clip parameters (PatternPriorityClipper.SimplifyToleranceMetresandPatternPriorityClipper.MinPointsToSimplify), the CRS andDiskPatternClipCache.FormatVersion, so stored clips become invalid when any of them changes.
Reuse the coverage projection
MapsuiCoverageRenderer projects every grid node from the coverage's native CRS
to Web Mercator and maps nodes to output pixels. That mapping depends only on
the grid geometry (CRS, dimensions, origin and spacing), not on the palette,
display mode or values. The renderer caches it, with the output size and extent,
and reuses it when the next render has the same geometry, such as after a palette
switch or a new time step. Only classification, pixel fill and PNG encoding run
again.
The cache belongs to the renderer instance and holds one entry, so keep the instance between renders to benefit. A different geometry rebuilds the entry. It caches only the node-to-pixel index array, about 4 MB per megapixel grid.
Performance instrumentation
SoundCharts records OpenTelemetry instruments that break paint time down by style renderer, layer, source feature class and vertex count. They cost well under a millisecond per paint when no listener is attached.
| Instrument | Unit | Tags | Purpose |
|---|---|---|---|
s100.map.paint.duration |
ms | none | Paint time per frame on the compositor thread. |
s100.map.paint.interval |
ms | none | Time between paints. Gaps over 500 ms are dropped. |
s100.map.paint.style.calls |
count | style, layer, points, featureClass |
Style-renderer Draw calls per paint. |
s100.map.paint.style.duration |
ms | style, layer, points, featureClass |
Total Draw time per paint. |
The points tag is bucketed (n/a, 0, 1-9, 10-99, 100-999, 1k-10k,
10k-100k, 100k+) to keep the number of series small while still showing
whether a layer's cost comes from many cheap draws or a few expensive ones.
featureClass is the source feature catalogue type of S-101 and S-57 features,
such as DepthContour; other features use (unclassified).
To record a session, run SoundCharts with the OpenTelemetry console exporter:
ENC_DOTNET_OTEL_CONSOLE=1 OTEL_METRIC_EXPORT_INTERVAL=2000 \
dotnet run -c Release --project src/EncDotNet.S100.Viewer
Histograms are written every 2 s. Group them by layer, featureClass and
points to see which geometry dominates paint time. On real S-101 datasets,
about 93% of paint time went to geometries with 100 or more vertices, at about
1 µs per vertex. For the investigation, see the
Mapsui performance design note.
This package's own instruments, named s100.render.*, are described under
Base-plane rendering.
Internals
The rest of this page describes how the package renders, for contributors and
for anyone tuning performance. Environment variables named here seed
RenderingOptimizations; SoundCharts also exposes most of them under
Settings > Advanced > Base-plane rendering. The design reference is
the S-100 render subsystem
design note, cited below by appendix.
Portrayal conventions
MapsuiDisplayListRenderer lowers the display list through
VectorSceneBuilder in EncDotNet.S100.Rendering.Scene, the same lowering the
headless SkiaDisplayListRenderer uses. Draw order, colour, symbol and
line-style resolution, millimetre-to-pixel conversion, text anchors, pattern
fills and the latitude/longitude-to-EPSG:3857 projection are all done there. The
tiled renderer draws that scene; the Mapsui features built from it only carry
pick identity, so every painted op, pattern fills included, can be picked.
It follows these S-100 Part 9 conventions:
- Pen widths and text and symbol offsets given in millimetres convert to pixels at 1 px = 0.32 mm (S-100 Part 9 §3.10.4).
<foreground>and<background>colours accept a palette token or a literal#RRGGBBorRRGGBBAAvalue. The optionaltransparencyattribute reduces alpha.- Text alignment, millimetre offsets, and
textLinestart and end offsets (relative or absolute) follow S-100 Part 9 §11.4. LineStyleProvider,SymbolProviderandAreaFillProviderlet the host plug in a portrayal catalogue without tying the renderer to a dataset library.- Scale limits are latitude-corrected. S-100 Part 9 §11.1 scale
denominators (per-feature
ScaleMinimumandScaleMaximum, and the cell-wide out-of-band cap fromDataCoverage.minimumDisplayScale) are true scales, but a Mapsui resolution is metres per pixel at the equator. Web Mercator stretches ground distance by1/cos φ, so the resolution for a denominator isdenom × 0.00028 / cos φ. Per-feature limits use the feature's extent-centre latitude; the cell-wide cap uses the layer's. This matches the headless Skia path. Without the correction, at φ ≈ 50.8° detail would disappear about two-thirds of a zoom level early. - Cell zoom-out window.
MapsuiDatasetRenderer.ApplyCellScaleWindow(layers, minimumDisplayScale)caps each layer'sMaxVisibleat the resolution forminimumDisplayScale, at the layer's extent-centre latitude. The scale is the coarsestminimumDisplayScaleacross the cell'sDataCoverageentries inCATALOG.XML. It only ever tightensMaxVisible. Unlike the per-feature cap, which applies to line work only, this window hides the whole cell, area fills included, once you zoom out past it, so the coarser cell underneath shows through. Finer cells drop out first as you zoom out, so there's always a coarser cell beneath.MapsuiDatasetLayerSessionskips the window whenIgnoreScaleMinimumis set, prefers the host's catalogue scale, and falls back toMapsuiDatasetResult.CellMinimumDisplayScalefor standalone cells. For S-57, that's the larger of CSCL and the cell's largestSCAMIN. - Overlapping cells.
OverlapSuppressionandCoverageClipstop a coarser cell drawing where a finer, overlapping cell has coverage, so depth areas and fills don't bleed under the finer cell. This is a geometry clip in screen space, not a scale cap, and it depends on zoom: a finer cell suppresses a coarser one only while the finer cell is itself drawn, so zooming out never leaves a hole.MapsuiDatasetLayerSessionrecomputes the clips after render, replacement, removal, reorder, and changes to visibility, opacity, active state and sub-layers. It applies them after the host's S-98 projection. Hidden, transparent, inactive and unloaded cells never suppress other cells. Cells are ranked byMapsuiDatasetResult.CellCompilationScalewhen it's set (S-57 CSCL), otherwise by their zoom-out window. A finer cell's cutoff always follows its zoom-out window (OverlapSuppressionCell.CutoffScaleDenominator). The same ranking setsSubLayerStackItem.SourceScaleDenominatorfor paint order.
Pattern-fill clip simplification
When it builds the scene, the renderer simplifies the polygons used to clip
pattern fills against each other and against solid fills such as land. S-101
quality and coverage areas (for example M_QUAL) can follow the coastline with
tens of thousands of vertices, most of them smaller than a pixel at chart
scales. The NetTopologySuite Difference and Union operations get much slower
as vertex counts grow, so one such area could dominate a frame: about 10 s of an
11 s frame on one 64,000-vertex pattern area in a 2.35 MB cell.
Before the overlay, each merged pattern geometry and the land mask go through
NTS TopologyPreservingSimplifier with a 1 m (EPSG:3857) tolerance
(PatternPriorityClipper.SimplifyToleranceMetres). The result stays valid for
overlay; if it doesn't validate, it's repaired with buffer(0), and on any
failure the original geometry is used. The clipped boundary only bounds a tiled
pattern, so the change isn't visible; the S-101 visual-regression snapshot
didn't change. An envelope test also skips Difference when the mask doesn't
touch the entry. Together these took the clip on that cell from about 11 s to
well under 1 s. The clip's remaining cost is why the
pattern-clip caches exist.
Base-plane rendering
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 a Mapsui custom layer renderer that 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,
RenderingOptimizations.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 for a repaint through the layer's redraw sink.
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 over a rendered gutter (S100_VECTOR_TILE_GUTTER, default 64 DIP), so
strokes stay continuous across tile edges and no hole shows. 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 a thread-safe LRU TileCache
bounded by a native-byte budget (S100_VECTOR_TILE_BUDGET_MB, default set by
the performance profile). Visible tiles are kept most
recently used, so they're never evicted mid-frame. All cache access goes through
the layer lock, so a worker can't dispose an image the compositor is drawing.
Each layer has a pool of coalescing workers that drains the visible misses,
which are replaced every frame. S100_VECTOR_TILE_WORKERS sets the pool's
minimum size (default set by the performance profile). A process-wide cap, the
logical core count, stops many layers from oversubscribing the CPU and starving
the UI thread. A layer with visible work waiting can borrow idle capacity up to
that cap, but only for visible work: prediction never borrows, and a borrowed
worker stops when the visible work is done. Each other layer with visible work
keeps its own minimum, so a dense layer low in the stack can't starve the layers
above it. On a LowEnd host, with one worker, there's nothing to borrow.
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 Mapsui's WorldToScreenXY. 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 384 px at
1x and 768 px at 2x. 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 per layer (S100_VECTOR_TILE_BUDGET_MB) |
192 MB | 256 MB | 256 MB |
GPU tile cache per layer (S100_VECTOR_TILE_GPU_MB) |
192 MB | 256 MB | 256 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 |
Tile workers per layer (S100_VECTOR_TILE_WORKERS) |
1 | 2 | One per 4 cores, 3 to 8 |
Cross-band pre-warm (S100_VECTOR_TILE_XBAND) |
Off | On | On |
SoundCharts shows the profile, the detected tier and the worker count under Settings > Advanced.
Skia keeps an uploaded texture for every raster tile it blits in the host GPU
context's resource cache (RenderingOptimizations.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, RenderingOptimizations.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, dimension or scale. The scale fallback keeps single-tile
rendering when a fractional device scale can't give the core and gutter whole
pixel sizes. Leave it off unless A/B runs on real cells show lower total raster
time without worse cold latency, paint time, memory or pixels.
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.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. 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.25.1)
- EncDotNet.S100.Portrayals (>= 0.25.1)
- EncDotNet.S100.Renderers.Skia (>= 0.25.1)
- EncDotNet.S100.Rendering.Scene (>= 0.25.1)
- Mapsui (>= 5.1.0)
- Mapsui.Nts (>= 5.1.0)
- Mapsui.Rendering.Skia (>= 5.1.0)
- Mapsui.Tiling (>= 5.1.0)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.9)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.9)
- SkiaSharp (>= 3.119.4)
-
net8.0
- EncDotNet.S100.Core (>= 0.25.1)
- EncDotNet.S100.Portrayals (>= 0.25.1)
- EncDotNet.S100.Renderers.Skia (>= 0.25.1)
- EncDotNet.S100.Rendering.Scene (>= 0.25.1)
- Mapsui (>= 5.1.0)
- Mapsui.Nts (>= 5.1.0)
- Mapsui.Rendering.Skia (>= 5.1.0)
- Mapsui.Tiling (>= 5.1.0)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.9)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.9)
- SkiaSharp (>= 3.119.4)
NuGet packages (1)
Showing the top 1 NuGet packages that depend on EncDotNet.S100.Renderers.Mapsui:
| Package | Downloads |
|---|---|
|
EncDotNet.S100.Renderers.Mapsui.Avalonia
Libraries for manipulating S-100 based nautical charts. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.25.1 | 101 | 10/12/2026 |
| 0.25.0 | 97 | 10/12/2026 |
| 0.24.0 | 120 | 10/4/2026 |
| 0.23.0 | 143 | 9/17/2026 |
| 0.22.0 | 129 | 7/19/2026 |
| 0.21.0 | 132 | 7/10/2026 |
| 0.20.0 | 138 | 6/29/2026 |
| 0.19.0 | 130 | 6/27/2026 |
| 0.18.0 | 149 | 6/17/2026 |
| 0.17.1 | 130 | 6/16/2026 |
| 0.17.0 | 120 | 6/15/2026 |
| 0.16.0 | 134 | 6/8/2026 |
| 0.15.0 | 131 | 6/6/2026 |
| 0.14.0 | 135 | 6/6/2026 |
| 0.13.0 | 120 | 6/3/2026 |
| 0.12.0 | 123 | 5/29/2026 |
| 0.11.0 | 129 | 5/19/2026 |
| 0.10.0 | 117 | 5/16/2026 |
| 0.9.0 | 120 | 5/15/2026 |
| 0.8.0 | 122 | 5/13/2026 |