ThinkGeo.com    |     Documentation    |     Premium Support

Feedback GPU Based Rendering Transition

We’re migrating an existing WPF desktop GIS (ThinkGeo.UI.Wpf, 15.0.0-beta136) to optional GPU
rendering via GpuBasemap + MapStyle + FeatureSourceVectorTileSource, following
HowDoISample.v15. The app’s cartography is stored as classic ThinkGeo styles
(AreaStyle, LineStyle, PointStyle, TextStyle) on ZoomLevelSet.ZoomLevel01, and we
translate those into code-built style layers (StyleLayer.Create*) at scene-build time.

That works well — polygons, lines, circular points, labels and GeoTIFFs all render. Thank you
for ClassicRasterTileSource in particular; serving an existing GdalRasterSource as tiles was
a one-liner.

Below are the places where we had to write adapter code or fall back to classic rendering, and
where a small addition on your side would remove that. Roughly in priority order for us.

1. Code-built fill layers can’t use fill-pattern

FillStyleLayer exposes HasFillPattern and FillPatternPixelRatio, and StyleImages
documents fill-pattern as reading from the registered images. But StyleLayer.CreateFill(...)
has no pattern parameter, and the ctor that takes the evaluators is internal. As far as we can
tell, a pattern fill is reachable only through a parsed style document, not through a code
layer.

This is what blocks polygon hatching for us: AreaStyle.CreateHatchStyle(GeoHatchStyle, ...)
layers fall back to classic for the whole scene. We can rasterize the hatch ourselves and
register it via MapStyle.Images — we just have no way to point a code-built fill layer at it.

Ask: a fillPattern (sprite name, or Func<Feature, double, string>) parameter on
StyleLayer.CreateFill.

2. Code-built symbol layers can’t use icon-image

Same shape. SymbolStyleLayer's internal constructor takes symbolIconIdEvaluator and
symbolIconEvaluator, and SymbolLayout carries the full icon half of the spec
(IconAnchor, IconOffsetX/Y, IconSize, IconAllowOverlap, IconTextFit, …). But
StyleLayer.CreateSymbol only takes text, paint, layout, filter, zoom bounds and
sortKey — no icon name.

We have nine non-circular PointSymbolType values (Square, Triangle, Cross, Diamond, Star, …).
Each is a small sprite we’d happily generate and register; without an icon selector on the code
path, every layer using one stays on the classic renderer.

Ask: expose the icon evaluator on StyleLayer.CreateSymbol, or make the
SymbolStyleLayer constructor public.

3. No bridge from classic styles to GPU paints

You own both sides of this conversion — AreaStyle/LineStyle/PointStyle/TextStyle and
FillPaint/LinePaint/CirclePaint/SymbolPaint/SymbolLayout — and every application
migrating an existing map will write the same translation we did. Ours is ~120 lines and it
already has judgement calls in it that we’d rather inherit than invent (see 4 and 5).

Ask, in rough order of usefulness:

  • A FeatureLayer-aware tile source, or a MapStyle.AddLayer(FeatureLayer), that reads the
    layer’s own ZoomLevelSet and emits the equivalent style layers.
  • Failing that, converters: FillPaint.FromAreaStyle(AreaStyle) and friends.

4. GeoPen.DashStyleline-dasharray

LinePaint.Dash and DashArray are exactly what we need, but the mapping from
LineDashStyle.Dash / Dot / DashDot / DashDotDot to dash/gap run lengths is ours to
guess. We currently hardcode the GDI+ pen patterns (3,1 / 1,1 / 3,1,1,1 / 3,1,1,1,1,1), since
DashArray takes the same line-width units GDI+ does. That matches classic closely but it’s
an assumption about your classic renderer, not a documented equivalence.

Ask: a DashArray.FromLineDashStyle(LineDashStyle), or just documenting the intended
mapping. LineDashStyle.Custom has no GPU equivalent at all as far as we can see.

5. SymbolLayout takes a font family string, not a GeoFont

Our labels are stored as GeoFont(family, size, FontStyle) where the style carries Bold /
Italic / Underline / Strikeout. SymbolLayout takes fontFamily and size only, so weight and
slant are dropped on the GPU path. We didn’t want to guess whether the local rasterizer resolves
a font-stack name like "Arial Bold" the way a fontnik stack would.

Ask: a SymbolLayout overload taking GeoFont, or documentation of how FontFamily
is resolved when no glyph server is configured (SetGlyphs unset) and which stack names the
embedded Noto Sans + LocalFontsDirectory path understands.

6. AddStyleLayers stamps SourceId onto the tile source object

MapStyle.AddStyleLayers(IVectorTileSource, sourceId, ...) assigns
FeatureSourceVectorTileSource.SourceId = sourceId. So calling it twice with the same source
object and different ids re-points the earlier registration.

We hit this interleaving rasters with vectors while preserving layer order: a raster in the
middle of the stack splits the vector layers into runs, and each run therefore needs its own
FeatureSourceVectorTileSource rather than one shared source keyed by source-layer.

Ask: treat the source id as per-registration rather than as state on the source object —
or confirm that one source per run is the intended usage, in which case we’ll stop worrying
about it.

7. Can SetLayerVisibility address a code-built layer?

MapStyle.SetLayerVisibility(layerId, visible) scans the composed document for a layer with
that id. Code layers are inserted as placeholder code-slot-N entries, so the id we passed to
StyleLayer.CreateFill doesn’t appear there — but the method still raises
LayerVisibilityChanged with our id unconditionally, which suggests an attached map might apply
it anyway.

Question: is toggling a code-built layer’s visibility supported? Right now we rebuild the
whole scene for a visibility change, which costs a re-encode and a visible blink as new tiles
arrive. The same question applies to changing a code layer’s paint — is there a live restyle
path, or is scene replacement the intended model?

8. Scale ↔ zoom

Our layers carry min/max visibility as classic scale denominators; StyleLayer.MinZoom/MaxZoom
are MapLibre zooms. We avoided the conversion entirely (we rebuild the scene when a layer
crosses its threshold, reusing our own scale rule) because we couldn’t find a published
constant for the zoom↔scale relation this renderer uses, and a near-miss would show up as
layers appearing one zoom early or late versus the classic map.

Ask: a documented ZoomFromScale / ScaleFromZoom on the GPU side, or scale-based
overloads for the layer zoom bounds.

9. ClassicRasterTileSource and non-GDAL sources

The docs are clear that the source must answer in Web Mercator, and GdalRasterSource.WarpToWebMercator
handles that for GDAL-backed files. For other RasterSource implementations there’s no
equivalent switch. Setting RasterSource.ProjectionConverter converts the requested extent but
doesn’t resample, so anything but an axis-aligned match comes back skewed.

Question: is warping inside ClassicRasterTileSource (using the source’s own Projection)
something you’d consider, so that any classic raster source can be served? Today our
non-GDAL image layers (PNG/JPG/BMP through WpfRasterLayer) stay on the classic renderer.

10. Overlays above the GPU basemap

Selection, highlighting and edit previews are classic overlays in our app, and GpuBasemap sits
below the overlay stack, so those still work. But it does mean we can’t mix a GPU-rendered layer
above a classic overlay.

Question: is a GPU-rendered overlay (or a supported way to interleave GPU content with
LayerOverlays) on the roadmap?

Additionally we found that GPU SymbolLayout does not support or expose text-decoration settings for Underline, Strikeout and Black weight. Could you add support / guidance for this?

Hi Julian,

Thank you for this. It is the most useful migration report we have had! Here is the detail for each one:

1. fill-pattern on code-built fills — supported in beta142

StyleLayer.CreateFill(..., fillPattern: "hatch-cross"). The name resolves against the same image set a document layer uses - the style’s sprite plus everything in MapStyle.Images - so your hatch is:

style.Images.Add("hatch-cross", AreaStyle.CreateHatchStyle(GeoHatchStyle.Cross, GeoColors.Black, GeoColors.Transparent));
StyleLayer.CreateFill("parcels", i, "src", "parcels", paint, fillPattern: "hatch-cross");

One name per layer, deliberately: a pattern is a texture bound per layer in the renderer, so a per-feature Func would have accepted an answer it could not honour. Document layers have the same limit (an expression-valued fill-pattern falls back to fill-color). Note that the JSON route already worked in beta136 - the Hatch Styles sample is that route - so hatched layers never had to fall back to classic as a whole.

Here is the HowDoI for it by the way:

2. icon-image on code-built symbols — supported in beta142


StyleLayer.CreateSymbol("cities", i, "src", "cities",
    text: (f, z) => f.ColumnValues["NAME"],          // may be null for icon-only
    paint: ..., layout: ...,
    icon: (f, z) => f.ColumnValues["KIND"] == "capital" ? "star" : "cross");

The icon is chosen per feature, text is now optional, and your nine PointSymbolTypes become sprites with style.Images.Add("star", new PointStyle(PointSymbolType.Star, 16, GeoBrushes.Blue)) - the overload that takes a Core Style draws it once into an image.

3. The bridge — supported in beta142

Both things you asked for:

  • MapStyle.AddFeatureLayer(featureLayer) (and InsertFeatureLayerAt(slot, featureLayer)) reads the layer’s ZoomLevelSet and emits the equivalent code-built layers over a FeatureSourceVectorTileSource cut from its feature source.

  • FeatureLayerTranslator.Translate(featureLayer, mapView.ZoomScales) returns the translation first - each layer with its id, kind, zoom range and the classic style it came from, plus a Warnings list of what did not translate - so you can read it, drop parts of it, or act on the warnings, then style.AddFeatureLayer(translation).

  • The converters underneath are public: FillPaint.FromAreaStyle, LinePaint.FromGeoPen, CirclePaint.FromPointStyle, SymbolPaint.FromTextStyle, SymbolLayout.FromTextStyle(style, placement), and ClassicPaints.AnchorOf / LineCapOf / LineJoinOf.

What translates: AreaStyle (fill; a hatch becomes the pattern; an outline wider than one solid pixel becomes its own line layer), LineStyle (one line per pen with ink, outer under inner under centre, dashes included), PointStyle (circle → circle layer; every other symbol, image or glyph → icon), TextStyle (text built the way PositionStyle builds it - column or TextContent template, Numeric/Date/TextFormat, LetterCase; TextPlacement → anchor; line features placed along the line), and ValueStyle / ClassBreakStyle / RegexStyle / CompositeStyle as their item styles with a per-feature filter each. What does not, and says so in Warnings: gradient/texture brushes, dot-density/cluster styles, text masks, rotated labels, direction points, underline/strikeout.

Zoom levels: a classic level applies while it is the nearest to the map’s scale, so each level’s layers get MinZoom/MaxZoom at the midpoints to its neighbours, from the layer’s own level scales read through the map’s ZoomScales ladder; ApplyUntilZoomLevel stretches the lower bound. The judgement calls you mentioned (4 and 5) are inside.

We added a sample in HowDoI, where you can see the 2 maps side by side; One is using the traditional LayerOverlay and the other is using FeatureLayerTranslator.Translate() to convert the traditional layers to the new GPU based stylelayers. Please be aware the API might be changed and the sample will be moved over to a new spot in the future.

4. GeoPen.DashStyleline-dasharray — works today; helper in beta142

Your numbers are right, and not by coincidence: GeoPen.DashPattern is the pen’s one store, in multiples of the pen width, and setting DashStyle to a preset writes 3,1 / 1,1 / 3,1,1,1 / 3,1,1,1,1,1 into it. A line-dasharray is that array in those units, so the conversion is a copy - which also answers Custom: it is not a sixth enum value with nothing behind it, it is whatever is in DashPattern. Convert the pen, not the enum:

new DashArray(pen.DashPattern.ToArray())    
DashArray.FromGeoPen(pen)                     // added in beta142

5. Fonts — GeoFont overload added in beta142; the resolution rule works today

SymbolLayout.FontFamily is a font stack name: the family, then weight and slant words - "Arial Bold Italic". When the style declares a glyph server the whole string goes to it; without one (your case) the engine keeps the family and reads the trailing words itself, then tries a font file from GpuDiagnostics.LocalFontsDirectory, then an installed family of that name matched at that weight, then the embedded Noto Sans (regular / bold / italic / bold-italic), and characters none of those cover fall through to a per-character system match. Nothing in that chain throws or leaves a gap, which is why "Arial Bold" was quietly working all along. beta140 adds:

  • new SymbolLayout(placement, anchor, geoFont, ...) - the family and the Bold / Italic / Black flags become the stack name, Size passes through unchanged (the classic renderer hands GeoFont.Size to Skia unscaled too).

  • GpuDiagnostics.FontResolved = (requested, resolved, stage) => ... - fires once per stack name and says which step answered.

  • Every weight word (ThinBlack) is honoured;

6. SourceId — one source per run is the design; beta142 makes it loud

Confirmed: a FeatureSourceVectorTileSource keys its tiles by its SourceId, so one object holds one name, and a second name for the same object - or a second object under a taken name - is refused with an InvalidOperationException in beta140 instead of silently re-pointing the earlier layers. For a raster in the middle of the stack, build one FeatureSourceVectorTileSource per run over the same feature sources; AddFeatureLayer does that numbering for you.

7. Visibility and restyling code layers — works today (bug fixed in beta142)

MapStyle.SetLayerVisibility(codeLayerId, visible) already worked - the id map is built from the compiled layers, and the Render Based on Code sample toggles code-built layers every frame. What we found while answering: the hidden state was kept by layer index and never cleared, so after a recompile that renumbered layers it could hide the wrong one. beta140 records it by id and re-derives the indices after every compile.

For paint changes, don’t rebuild the scene: await basemap.SetStyleAsync(style). Decoded tiles are style-independent and stay in memory; only buckets and labels rebuild, and the swap waits until the new frame is whole - no re-encode, no blink.

Check out this sample see how to dynamically update the style

8. Scale ↔ zoom — works today; inverse fixed in beta142

There is no constant because there is no constant: the camera’s zoom is MapUtil.GetZoom(scale, mapView.ZoomScales) - the number MapView.CurrentZoom reports - and StyleLayer.MinZoom/MaxZoom and a document’s minzoom compare against exactly that. Back is MapUtil.GetScale(zoom, ZoomScales); in beta140 it is the exact inverse (it interpolated linearly before, so 7.5 came back as 7.415). The one thing to know: it is an index into the ladder. The default ladder of a metre-unit map is MapLibre’s (512-pixel tiles, index 0 at 1:295,829,355), so a document’s zooms mean what they mean in MapLibre; if your app replaces ZoomScales with a 256-pixel ladder, every zoom in the style shifts by one.

9. Non-GDAL rasters — works today

Any RasterSource reaches ClassicRasterTileSource correctly with

rasterSource.ProjectionConverter = new GdalProjectionConverter(sourceSrid, 3857);

RasterSource.GetImage hands every image it reads to the converter’s raster overload and GetBoundingBox answers converted, so the tile grid lines up. The managed ProjectionConverter is what skewed your picture: its raster overload throws NotImplementedException - warping a raster is GDAL’s job in this SDK. We verified this with a PNG + world file in EPSG:4326 served as mercator tiles. The remarks on ClassicRasterTileSource now describe both ways.

Check out this one, we put 4326 image on 3857 map

10. Overlays above the GPU basemap — no GPU overlay is planned; the scene is the place

There will not be a GPU-rendered Overlay, and we are not going to interleave classic overlays with GPU layers. The direction is that everything renderable lives in the GPU scene, and classic overlays stay for the interactive tools - EditOverlay, TrackOverlay - which keep sitting on top exactly as they do now (the Edit Features sample runs EditOverlay over a GpuBasemap).

The ordering you describe - GPU content pinned under a classic selection overlay - goes away once the selection itself is in the scene:

var selection = new InMemoryGeometrySource();

style.AddGeometry(selection, new GeometryStyle { FillColor = new GeoColor(90, GeoColors.MidnightBlue), OutlineColor = GeoColors.MidnightBlue, OutlineWidthInPixels = 2 });
selection.UpdateAreas(selectedFeatures);   // UpdateLines / UpdatePoints / UpdateLabels likewise

Find Features by Spatial Relation highlights its query results this way. One thing to know about the order inside the scene: geometry added with AddGeometry draws above every style layer, which is what a selection usually wants. If what you need is the other way round - something of the map, labels say, drawn over the highlight - tell us what it is. Giving AddGeometry a position in the layer order is a small addition, and we would rather add it for a real case than guess at one.

Here is the sample you can see how to highlight a feature:

The Find Features by Spatial Relation sample highlights query results exactly this way.

11. Underline, strikeout, Black weight — Black in beta140, the lines in beta142

All three. Black works in beta142 through the GeoFont overload (the Black flag becomes “Family Black”, matched at weight 900 for installed fonts; the embedded Noto Sans has no black face and takes bold; a glyph server needs the stack).

Underline and strikeout are in beta142: SymbolLayout.TextDecoration, set from the GeoFont flags by the overload and by FeatureLayerTranslator, so nothing changes on your side. The lines are drawn per glyph - they follow a curved label and every line of a wrapped one - and sit, weigh and end exactly as the classic renderer’s do. The Migrate sample now underlines its park and school names and strikes out its street names, on both maps. (Found on the way and fixed too: the classic renderer dropped the underline when a font had Underline | Strikeout at once.)

Here this sample we put underline and strikeout to some of the labels.

Let me know if you see any issues, and thanks again for posting the questions.

Thanks,
Ben

1 Like

Thanks! That helps a lot, It seems I misunderstood an important aspect with the overlays, thanks for clearing that up!

Hi Ben -

Thank you for the additions in beta142. We’ve updated our adapter to use FeatureLayerTranslator. Two follow-up questions came up while reviewing the API:

  1. Layer opacity

The translator appears to ignore FeatureLayer.Opacity. For example, setting layer.Opacity = 0.5 before translation does not carry that transparency into the generated paints, and no warning is reported.

Could AddFeatureLayer preserve whole-layer opacity, or warn when it cannot?

  1. Keeping tiles when changing styles

Calling Translate again after changing a color creates a new FeatureSourceVectorTileSource. Passing that translation to SetStyleAsync therefore replaces the source and drops its cached tiles, even though the data has not changed.

Would the recommended approach be to register the original source once in GpuBasemap.TileSources, then keep the same source ID and source-layer when translating updated styles? A small example showing source reuse and cleanup would help.

Additionally, we noticed weird flickering with labels on GPU render.

flickering labels

Current implementation:

  RemoveCustomTextStyles(layer);
  var splitStyle = SplitStyle();

  if (splitStyle?.Length == 3 && ShowLabelingField && LabelingField != null)
  {
      float fontSize = float.Parse(splitStyle[1]);
      var style = new TextStyle(
          LabelingField,
          new GeoFont(splitStyle[0], fontSize, GetFontStyle()),
          new GeoSolidBrush(ParseGeoColor(ColorText)));

      if (GeoType == GeoType.Point)
          style.TextPlacement = TextPlacement.AutoPlacement;
      else
          style.OffsetFromLine =
              (float)Math.Max(OutlineWidth + 3 + fontSize / 3, 7);

      if (HasNonTextCustomStyles(layer))
      {
          layer.ZoomLevelSet.ZoomLevel01.DefaultTextStyle = null;
          layer.ZoomLevelSet.ZoomLevel01.CustomStyles.Add(style);
      }
      else
      {
          layer.ZoomLevelSet.ZoomLevel01.DefaultTextStyle = style;
      }
  }
  else if (GeoType == GeoType.Polygon
        || GeoType == GeoType.Polyline
        || GeoType == GeoType.Point)
  {
      layer.ZoomLevelSet.ZoomLevel01.DefaultTextStyle = null;
  }

}

Style stores font family,size,font flags. LabelingField identifies the feature attribute containing the text. The helpers remove previous custom text styles and detect thematic styles so labels are
attached through the appropriate collection.

featureLayer.ZoomLevelSet = CopyZoomLevels(original.ZoomLevelSet);

  // Projection configuration omitted here.
  var translation = await Task.Run(() =>
      FeatureLayerTranslator.Translate(featureLayer, zoomScales, sourceId),
      cancellationToken);

  translation.Source.SharedSources = true;
  translation.Source.MaxConcurrentEncodes = 1;

  // Warning handling omitted here.
  composed.AddFeatureLayer(translation);

We added logging around scene rebuilds, SetStyleAsync, layer visibility and AGK map draw requests. In the latest reproduction:

  • The labels continued flickering after navigation stopped, with no further logged AGK draw requests, scene rebuilds or visibility changes.

The affected labels use Arial, size 18. Their translated layer has no min/max zoom bounds. No GPU errors were logged.

Hi Julian

All three are in 15.0.0-beta146.

  1. Layer opacity

FeatureLayer.Opacity now multiplies into every paint the layer becomes - fill, line, circle, icon and label. One difference from the classic renderer: it faded the layer’s finished image, while here the opacity is per style layer, so where a layer’s own style layers stack (an outline over its fill) the composite is a little denser. The translation notes it in Warnings when that applies.

  1. Keeping tiles across a restyle

Translate over the source you already have; the tiles stay, only the paints are rebuilt:

// first time
var translation = FeatureLayerTranslator.Translate(layer, mapView.ZoomScales, "parcels");
await basemap.SetStyleAsync(new MapStyle().AddFeatureLayer(translation));

// after a style change on the classic layer
translation = FeatureLayerTranslator.Translate(layer, translation.Source, mapView.ZoomScales);
await basemap.SetStyleAsync(new MapStyle().AddFeatureLayer(translation));

Nothing to clean up between restyles - the source lives as long as your translations keep passing it.

  1. Flickering labels - This is what kept us busy for the last several days :slight_smile:

Reproduced and fixed. A tile that cut to no features never counted as arrived for the label pass, so over a translated layer - where empty tiles are normal - the scene never settled and placement re-ran on the still map every 1.5 seconds, dropping a label and bringing it back each time. In beta146 an empty or failed tile counts as arrived, placement runs on camera change, on tile arrival and once at settle - never again on a still map - and a label keeps its identity when its tile is replaced, so it fades rather than jumps.

If anything still moves on a still map with beta146, set ThinkGeoDebugger.LogType = ThinkGeoLogType.Gpu; LogLevel = ThinkGeoLogLevel.All; LogStreamWriter = new StreamWriter(path) { AutoFlush = true }; before the map opens and attach the file with the zoom you were at.

Thanks,
Ben

Sweet! Thanks for your good work! If I find anything flickering you know I’ll let you know - with the log setup that helps you :smiley:

Whenever you are ready! Just keep us as busy as you can :grin:

Hi Ben, see if you can reproduce that transparency is different between CPU and GPU render.

Currently, 50% transparency on CPU is way more transparent than 50% on GPU. on 100% it shows 100% transparent for both. It seems like for GPU it’s exponential instead of linear.

If you cannot let me know and I will try making a sample.

Hi Julian

It’s reproduced and fixed in beta148. A plain fill blends the same on both renderers - the difference is where a layer’s styles stack. The classic renderer draws the whole layer into one image and fades that image once. The GPU applied the opacity per style layer instead, so the opacity was applied twice at where a layer’s styles overlap and the result looked denser than the value you set.

Fixed in beta148: a layer with Opacity below 1 now draws into an offscreen image and is composited once at that opacity, on the GPU, which is exactly what the classic renderer does on the CPU. The beta146 warning about this difference is gone with the difference.

Thanks,
Ben

Hi Ben - that seems to be fixed, nice!

What we noticed here: And sorry about that this is about Labels - but it seems like SymbolLayout.FromTextStyle / FeatureLayerTranslator ignores TextStyle.OffsetFromLine in beta148 in GPU Render

Done! It’s supported in beta149 now.