Skip to content

Commit a2d892c

Browse files
committed
improved wall rendering
1 parent ed197e3 commit a2d892c

11 files changed

Lines changed: 133 additions & 79 deletions

File tree

‎app/javascript/maplibre/layers/geojson.js‎

Lines changed: 21 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { draw, select } from 'maplibre/edit'
22
import { removeDescBanner, renderDescBanners, showDescBanners, syncDescBanner } from 'maplibre/layers/geojson/desc_banners'
3-
import { bufferLoaded, buildLineExtrusion, loadBuffer } from 'maplibre/layers/geojson/extrusion'
3+
import { buildLineExtrusion } from 'maplibre/layers/geojson/extrusion'
44
import {
55
removeFeatureImageOverlay,
66
renderImageOverlays,
@@ -24,10 +24,10 @@ import {
2424
import { highlightFeature } from 'maplibre/feature'
2525
import { Layer, queryFeaturesNear } from 'maplibre/layers/layer'
2626
import { frontFeature, getFeature, layers } from 'maplibre/layers/layers'
27-
import { addGeoJSONSource, map, mapProperties, noValidate, removeGeoJSONSource } from 'maplibre/map'
27+
import { addGeoJSONSource, map, noValidate, removeGeoJSONSource } from 'maplibre/map'
2828
import { pruneShapeImages } from 'maplibre/styles/circle_image'
2929
import { prunePatternImages } from 'maplibre/styles/pattern_image'
30-
import { clusterStyles, initializeClusterStyles, initializeViewStyles, styles, viewStyleNames } from 'maplibre/styles/styles'
30+
import { clusterStyles, extrusionOpacityBuckets, initializeClusterStyles, initializeViewStyles, styles, viewStyleNames } from 'maplibre/styles/styles'
3131

3232
// Every layer counts, so that the render of one layer never drops the shape images of another
3333
function pruneShapes() {
@@ -36,13 +36,12 @@ function pruneShapes() {
3636
prunePatternImages(features)
3737
}
3838

39-
// Whether a feature needs a buffered extrusion polygon: a LineString with a height set, not
40-
// already rendered as a route-extras segment (which builds its own extrusion), and not hidden by 3D terrain.
39+
// Whether a feature needs an extrusion polygon: a LineString with a height set, not
40+
// already rendered as a route-extras segment (which builds its own extrusion).
4141
function needsExtrusionPolygon(feature) {
4242
return feature.geometry?.type === 'LineString' &&
4343
!!feature.properties?.['fill-extrusion-height'] &&
44-
!feature.properties?.['show-route-extras'] &&
45-
!mapProperties.terrain
44+
!feature.properties?.['show-route-extras']
4645
}
4746

4847
export class GeoJSONLayer extends Layer {
@@ -76,8 +75,10 @@ export class GeoJSONLayer extends Layer {
7675
createSource() {
7776
super.createSource()
7877
addGeoJSONSource(this.kmMarkerSourceId, false)
79-
addGeoJSONSource(this.routeExtrasSourceId, false)
80-
addGeoJSONSource(this.extrusionSourceId, false)
78+
// Wall pieces are 0.5 m quads. Simplification below the source maxzoom drops them or
79+
// collapses them to two points, which MapLibre draws as thin vertical sticks.
80+
addGeoJSONSource(this.routeExtrasSourceId, false, null, 0)
81+
addGeoJSONSource(this.extrusionSourceId, false, null, 0)
8182
}
8283

8384
setupClickHandler() {
@@ -129,6 +130,15 @@ export class GeoJSONLayer extends Layer {
129130
initializeViewStyles(this.routeExtrasSourceId)
130131
initializeExtrasLabelStyles(this.routeExtrasSourceId)
131132
initializeViewStyles(this.extrusionSourceId)
133+
// Walls are built from short quads (see extrusion.js). Rounded corners and the vertical
134+
// gradient shade every quad on its own, which shows each seam as a dark band.
135+
for (const sourceId of [this.routeExtrasSourceId, this.extrusionSourceId]) {
136+
extrusionOpacityBuckets.forEach((_, i) => {
137+
const id = `polygon-layer-extrusion-${i + 1}_${sourceId}`
138+
map.setLayoutProperty(id, 'fill-extrusion-rounded-corner-distance', 0, noValidate)
139+
map.setPaintProperty(id, 'fill-extrusion-vertical-gradient', false, noValidate)
140+
})
141+
}
132142

133143
map.setFilter(`line-layer_${this.sourceId}`, withLevelFilter(this.mainLineFilter), noValidate)
134144
// Hide the outline layer for route extras - we only want the colored segments visible
@@ -240,7 +250,6 @@ export class GeoJSONLayer extends Layer {
240250
detectLevels()
241251

242252
const features = this.layer.geojson.features
243-
this.ensureBuffer(features)
244253

245254
renderKmMarkers(features, this.kmMarkerSourceId)
246255
renderRouteExtras(features, this.routeExtrasSourceId)
@@ -307,7 +316,6 @@ export class GeoJSONLayer extends Layer {
307316
const source = map.getSource(this.sourceId)
308317
if (!source) { return }
309318
source.updateData({ remove: [feature.id], add: sourceFeatures })
310-
this.ensureBuffer([feature])
311319

312320
// Route-extras segments copy the parent's style properties (see inheritedProps) and build
313321
// their own extrusion, so ANY property edit on a route feature leaves them stale.
@@ -317,7 +325,7 @@ export class GeoJSONLayer extends Layer {
317325

318326
// Extrusion polygon lives in a separate source (only LineStrings ever have one): upsert it
319327
// for a non-route extrusion line, otherwise remove any stale polygon (height cleared,
320-
// route-extras enabled, terrain on, …).
328+
// route-extras enabled, …).
321329
const extrusionSource = map.getSource(this.extrusionSourceId)
322330
if (extrusionSource && feature.geometry?.type === 'LineString') {
323331
const polygon = needsExtrusionPolygon(feature) ? buildLineExtrusion(feature) : null
@@ -347,7 +355,6 @@ export class GeoJSONLayer extends Layer {
347355
const source = map.getSource(this.sourceId)
348356
if (!source) { return }
349357
source.updateData({ add: [feature] })
350-
this.ensureBuffer([feature])
351358

352359
if (hasRouteExtras(feature)) {
353360
renderRouteExtras(this.layer.geojson.features, this.routeExtrasSourceId)
@@ -429,26 +436,11 @@ export class GeoJSONLayer extends Layer {
429436
}
430437
}
431438

432-
// buildLineExtrusion() returns null until @turf/buffer is loaded, so the extrusions render again then
433-
ensureBuffer(features) {
434-
if (bufferLoaded() || !features.some(f => needsExtrusionPolygon(f) || hasRouteExtras(f))) { return }
435-
loadBuffer().then(() => {
436-
if (!this.layer?.geojson?.features) { return }
437-
renderRouteExtras(this.layer.geojson.features, this.routeExtrasSourceId)
438-
this.renderExtrusionLines(this.layer.geojson.features)
439-
})
440-
}
441-
442439
renderExtrusionLines(features) {
443440
const source = map.getSource(this.extrusionSourceId)
444441
if (!source) { return }
445442

446-
if (mapProperties.terrain) {
447-
source.setData({ type: 'FeatureCollection', features: [] })
448-
return
449-
}
450-
451-
// LineStrings with fill-extrusion-height are buffered into polygons for MapLibre's fill-extrusion layer.
443+
// LineStrings with fill-extrusion-height are turned into polygons for MapLibre's fill-extrusion layer.
452444
// Placed in a separate non-selectable source so the polygons are never clickable.
453445
// Skipped when 'show-route-extras' renders its own extrusion.
454446
const extrusionFeatures = features

‎app/javascript/maplibre/layers/geojson/extrusion.js‎

Lines changed: 66 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,23 @@
1+
import { mapProperties } from 'maplibre/map'
12
import { defaultLineWidth, defaults } from 'maplibre/styles/defaults'
23

3-
// @turf/buffer pulls in JSTS and d3-geo (~350 KB), which most maps never need
4-
let buffer = null
5-
let bufferLoading = null
4+
// With terrain, MapLibre lifts every extrusion polygon by the elevation at its centroid,
5+
// so one long polygon floats over a slope. Short pieces follow the ground in small steps.
6+
// Without terrain every polygon sits at 0, so each segment stays one piece.
7+
const minPieceLength = 0.5
8+
const maxPieces = 5000
9+
const metersPerDegree = 111320
610

7-
export function bufferLoaded() { return !!buffer }
8-
9-
export function loadBuffer() {
10-
bufferLoading ||= import('@turf/buffer').then(module => { buffer = module.buffer })
11-
return bufferLoading
12-
}
13-
14-
// Buffer a LineString into a polygon for MapLibre's fill-extrusion layer, which has no line support.
15-
// Returns null for geometry that can't be buffered (non-line, <2 coords, or degenerate),
16-
// and before loadBuffer() finished.
17-
// @turf/buffer (JSTS) is expensive, so callers should only invoke this for features that changed.
11+
// Turn a LineString into a wall polygon for MapLibre's fill-extrusion layer, which has no line support.
12+
// Returns null for geometry that can't be extruded (non-line, <2 coords, or degenerate).
1813
export function buildLineExtrusion(feature) {
19-
if (!buffer || feature.geometry?.type !== 'LineString' || feature.geometry.coordinates.length < 2) {
14+
if (feature.geometry?.type !== 'LineString' || feature.geometry.coordinates.length < 2) {
2015
return null
2116
}
2217
const width = feature.properties['fill-extrusion-width'] || feature.properties['stroke-width'] || defaultLineWidth(feature)
23-
const extrusionLine = buffer(feature, width / 2, { units: 'meters' })
24-
if (!extrusionLine) { return null }
18+
const pieces = wallPieces(feature.geometry.coordinates, width / 2)
19+
if (!pieces.length) { return null }
20+
const extrusionLine = { type: 'Feature', geometry: { type: 'MultiPolygon', coordinates: pieces } }
2521
// Route-extras segments are anonymous slices of a route, so they stay id-less.
2622
if (feature.id) {
2723
extrusionLine.id = `${feature.id}-extrusion`
@@ -35,17 +31,68 @@ export function buildLineExtrusion(feature) {
3531
extrusionLine.properties['stroke-width'] = 0
3632
extrusionLine.properties['stroke-opacity'] = 0
3733
setExtrusionMinZoom(extrusionLine.properties)
38-
// The label stays on the line ('line-labels'). On the buffered polygon the point-placed
34+
// The label stays on the line ('line-labels'). On the polygon the point-placed
3935
// 'text-layer' would draw a horizontal duplicate beside the line.
4036
delete extrusionLine.properties.label
4137
delete extrusionLine.properties['label-title']
4238
return extrusionLine
4339
}
4440

41+
// One quad per piece, in local metres around each segment, plus a disc on every vertex.
42+
// The discs round the joins on the outside of a bend and the caps at both ends.
43+
function wallPieces(coords, halfWidth) {
44+
const segments = []
45+
for (let i = 1; i < coords.length; i++) {
46+
const [lng0, lat0] = coords[i - 1]
47+
const [lng1, lat1] = coords[i]
48+
const mx = metersPerDegree * Math.cos(lat0 * Math.PI / 180)
49+
const dx = (lng1 - lng0) * mx
50+
const dy = (lat1 - lat0) * metersPerDegree
51+
const length = Math.hypot(dx, dy)
52+
if (length > 0) { segments.push({ lng0, lat0, lng1, lat1, mx, ux: dx / length, uy: dy / length, length }) }
53+
}
54+
if (!segments.length) { return [] }
55+
const total = segments.reduce((sum, s) => sum + s.length, 0)
56+
// Math.ceil adds at most one piece per segment and every vertex adds a disc, so those are
57+
// kept out of the budget. A line with too many segments still gets one piece per segment.
58+
const pieceLength = mapProperties.terrain
59+
? Math.max(minPieceLength, total / Math.max(1, maxPieces - 2 * segments.length - 1))
60+
: Infinity
61+
const pieces = []
62+
for (const { lng0, lat0, mx, ux, uy, length } of segments) {
63+
const toLngLat = (along, side) => [
64+
lng0 + (ux * along - uy * side) / mx,
65+
lat0 + (uy * along + ux * side) / metersPerDegree
66+
]
67+
const n = Math.max(1, Math.ceil(length / pieceLength))
68+
for (let p = 0; p < n; p++) {
69+
const from = length * p / n
70+
const to = length * (p + 1) / n
71+
const right = toLngLat(from, -halfWidth)
72+
pieces.push([[right, toLngLat(to, -halfWidth), toLngLat(to, halfWidth), toLngLat(from, halfWidth), right]])
73+
}
74+
pieces.push(disc(lng0, lat0, mx, halfWidth))
75+
}
76+
const last = segments.at(-1)
77+
pieces.push(disc(last.lng1, last.lat1, last.mx, halfWidth))
78+
return pieces
79+
}
80+
81+
const discSides = 12
82+
83+
function disc(lng, lat, mx, radius) {
84+
const ring = []
85+
for (let i = 0; i <= discSides; i++) {
86+
const angle = 2 * Math.PI * (i % discSides) / discSides
87+
ring.push([lng + radius * Math.cos(angle) / mx, lat + radius * Math.sin(angle) / metersPerDegree])
88+
}
89+
return [ring]
90+
}
91+
4592
// A wall below one pixel is not worth drawing, and the taller it is, the further out that point sits.
4693
// The extrusion layers already filter on 'min-zoom', so writing the threshold onto the polygon hides
4794
// it per feature. The polygon itself stays in the source at every zoom level: dropping it would move
48-
// all the buffering into the single frame that crosses the threshold and freeze the zoom gesture.
95+
// all the polygon building into the single frame that crosses the threshold and freeze the zoom gesture.
4996
function setExtrusionMinZoom(properties) {
5097
// A wall with a base starts above the ground, so only the difference is drawn.
5198
const height = Number(properties['fill-extrusion-height']) - (Number(properties['fill-extrusion-base']) || 0)

‎app/javascript/maplibre/layers/geojson/route_extras.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -336,7 +336,7 @@ export function renderRouteExtras (features, sourceId) {
336336

337337
showExtrasLegend(activeExtrasType, activeValues, mergeBboxes(legendFeatures))
338338

339-
// Buffer LineString segments into polygons for 3D extrusion
339+
// Turn LineString segments into polygons for 3D extrusion
340340
const extrusionFeatures = extrasFeatures
341341
.filter(f => f.properties['fill-extrusion-height'])
342342
.map(buildLineExtrusion)

‎app/javascript/maplibre/map.js‎

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,10 +122,46 @@ function setAttributionControl () {
122122
map.addControl(attributionControl, 'bottom-right')
123123
}
124124

125+
// ?fps=<n> caps every requestAnimationFrame user, MapLibre included, for screen recordings:
126+
// evenly spaced frames, and half the GPU work of a 60Hz screen at 30fps.
127+
// ponytail: frames snap to the vsync grid, so 144Hz gives every 5th vsync (~28.8fps).
128+
let frameCapInstalled = false
129+
function capFrameRate () {
130+
const fps = Number(new URLSearchParams(window.location.search).get('fps'))
131+
if (!(fps > 0) || frameCapInstalled) { return }
132+
frameCapInstalled = true
133+
const request = window.requestAnimationFrame.bind(window)
134+
const cancel = window.cancelAnimationFrame.bind(window)
135+
// The 2ms slack keeps a 60Hz screen on every second vsync despite timestamp jitter
136+
const interval = 1000 / fps - 2
137+
// Our id -> the native id of the vsync the callback currently waits for
138+
const pending = new Map()
139+
let lastId = 0
140+
let frame = null
141+
let next = 0
142+
window.requestAnimationFrame = callback => {
143+
const id = ++lastId
144+
const tick = time => {
145+
// All callbacks of one vsync get the same time, so they run or wait together
146+
if (time !== frame && time >= next) { frame = time; next = time + interval }
147+
if (time !== frame) { pending.set(id, request(tick)); return }
148+
pending.delete(id)
149+
callback(time)
150+
}
151+
pending.set(id, request(tick))
152+
return id
153+
}
154+
window.cancelAnimationFrame = id => {
155+
cancel(pending.get(id))
156+
pending.delete(id)
157+
}
158+
}
159+
125160
export async function initializeMap (divId = 'maplibre-map') {
126161
backgroundMapLayer = null
127162
backgroundStyleKey = null
128163
rasterZoomLimits = {}
164+
capFrameRate()
129165

130166
// Initialize level from URL FIRST, before any layer initialization
131167
// This ensures the level is set before style.load triggers initializeLayers
@@ -148,6 +184,8 @@ export async function initializeMap (divId = 'maplibre-map') {
148184
attributionControl: false, // see setAttributionControl()
149185
hash: true, // enable hash in URL for map center/zoom
150186
fadeDuration: 200, // shorter fade
187+
// A 2x screen draws 4x the pixels and drops frames in pitched camera flights
188+
pixelRatio: Math.min(window.devicePixelRatio, 1.5),
151189
interactive: (window.gon.map_mode !== 'static'), // can move/zoom map
152190
// The static styles in public/layers/ must not carry an api key, so add the
153191
// configured one to every maptiler request that comes out of them
@@ -355,7 +393,7 @@ function updateCursorPosition(e) {
355393

356394
// Each map layer has its own source, so different style layers can be applied
357395
// sourceName convention: layer.type + '-source-' + layer.id
358-
export function addGeoJSONSource(sourceName, cluster=false, attribution=null) {
396+
export function addGeoJSONSource(sourceName, cluster=false, attribution=null, tolerance=null) {
359397
// https://maplibre.org/maplibre-style-spec/sources/#geojson
360398
// console.log("Adding source: " + sourceName)
361399
if (map.getSource(sourceName)) {
@@ -371,7 +409,8 @@ export function addGeoJSONSource(sourceName, cluster=false, attribution=null) {
371409
clusterRadius: 50,
372410
// the attribution control drops a null and de-duplicates the rest, so layers of the
373411
// same type credit their data source only once
374-
...(attribution && { attribution })
412+
...(attribution && { attribution }),
413+
...(tolerance !== null && { tolerance })
375414
})
376415
}
377416

‎config/importmap.rb‎

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -94,11 +94,5 @@
9494
pin "@turf/destination", to: "@turf--destination.js", preload: "map" # @7.4.0
9595
pin "@turf/length", to: "@turf--length.js", preload: "map" # @7.4.0
9696
pin "@turf/area", to: "@turf--area.js", preload: "map" # @7.4.0
97-
pin "@turf/buffer", to: "@turf--buffer.js", preload: false # @7.4.0
9897
pin "@turf/bbox", to: "@turf--bbox.js", preload: "map" # @7.4.0
9998
pin "@turf/center", to: "@turf--center.js", preload: "map" # @7.4.0
100-
pin "@turf/jsts", to: "@turf--jsts.js", preload: false # @2.7.2
101-
# dependencies of turf/buffer
102-
pin "d3-array", preload: false # @3.2.4
103-
pin "d3-geo", preload: false # @3.1.1
104-
pin "internmap", preload: false # @2.0.3

‎lib/tasks/demo.rake‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ require "net/http"
33
namespace :demo do
44
desc "Draw on an existing map in three scenes, for screen recording (Elba, La Zanca)"
55
task draw: :environment do
6-
fps = 25
6+
fps = 30
77
pace = 0.75 # every cursor action runs 25% faster
88

99
# Opening and closing shot: all three scenes in one frame
@@ -66,7 +66,7 @@ namespace :demo do
6666
abort "No map with private_id #{private_id}" unless map
6767
# Printed before the slow setup below, so the page is open by the time the first
6868
# broadcast goes out. A cursor is a broadcast, so it needs a connected client.
69-
puts "Open #{ENV.fetch("MAPFORGE_HOST", "http://localhost:3000")}/m/#{map.private_id}"
69+
puts "Open #{ENV.fetch("MAPFORGE_HOST", "http://localhost:3000")}/m/#{map.private_id}?fps=30"
7070

7171
hiker = { uuid: "demo-hiker", name: "Cleo", image: "/images/frontpage/avatar2.jpeg" }
7272
scout = { uuid: "demo-scout", name: "Ben", image: "/images/frontpage/avatar3.jpeg" }
@@ -204,7 +204,7 @@ namespace :demo do
204204
track_properties = ->(upto) {
205205
{ "title" => "Coast walk", "stroke" => "#e8590c", "stroke-width" => 5,
206206
"stroke-opacity" => 0.65, "stroke-dasharray" => true,
207-
"fill-extrusion-height" => 8, "fill-extrusion-width" => 3, "fill-extrusion-opacity" => 0.6,
207+
"fill-extrusion-height" => 8, "fill-extrusion-width" => 3, "fill-extrusion-opacity" => 1,
208208
"show-route-extras" => "steepness",
209209
"route" => { "provider" => "ors", "profile" => "foot",
210210
"waypoints" => stops[0..upto].map { |stop| track[stop].first(2) },
@@ -297,7 +297,7 @@ namespace :demo do
297297
"desc" => "Steel hull at 22 m, groupers inside\n\n" \
298298
"<img src=\"/images/frontpage/shipwreck.jpg\" width=\"150\">\n",
299299
"marker-color" => "#000000", "stroke" => "transparent",
300-
"show-desc" => "banner", "marker-size" => 14,
300+
"show-desc" => "banner", "marker-size" => 5,
301301
"marker-symbol" => "/icon-sets/fontawesome/ship.png" })
302302
}
303303

0 commit comments

Comments
 (0)