I turned a province's laser survey into a world you can walk around on a phone
New Brunswick has been scanned from the air with lasers, and the data is free to use — free in the way a pallet of unlabelled hard drives is free. I wanted to stand on a riverbank, in a town square or on a bridge, from a browser. Two days and 35 commits later, 5,139 square kilometres were walkable on the web.
- The province's free aerial laser surveys became a one-metre block world you can walk through in a browser, phone included.
- Every 1 km tile is its own little job: a home machine downloads, builds and packs it, then throws the source away.
- The browser gets 12 bytes per block face and works out the rest itself.
- Free hosting caps files per site, so the map spans several sites sharing one menu.
- The best bits were the mistakes: walkable rivers, bridges that were hedges, and a batch run that failed every single tile.
01Why I built it
LiDAR is an aircraft firing laser pulses at the ground and timing the echoes: billions of points, each a few centimetres from where it should be. Governments publish it for flood mapping and forestry, in formats only specialist software opens. I had already built a voxel game engine and a browser exporter for it, and one thought would not leave me alone: the province is already a block world. It just needs blocks.
The goal was easy to say and hard to do: anyone, on any phone, opens a link and walks down a real street — built from the real survey, at one-metre resolution, with no server to pay for. That last clause shaped more of the design than anything else.
02Two days, start to coast
This went faster than anything I have built, because most of it already existed: the mesher, the vertex format and the browser export came from my voxel game, and the machine fleet that builds the tiles was already running. I was mostly plumbing, not inventing. 35 commits over 2 days:
- 2026-10-26First tilea test tile voxelized in seconds and packed for the web
- 2026-10-26The bulk-data detourthe provincial site refused scripts; the federal copy did not
- 2026-10-26A corridor on the fleet396 tiles, one job each, zero failures
- 2026-10-26Pack format 412-byte quads, a 4 m middle distance, quality presets
- 2026-10-26Many regionsone viewer and one region list across several sites
- 2026-10-26Bilingualevery string in English and French
- 2026-10-27The Acadian Peninsulaeight regions live on four sites
- 2026-10-27A front pagea province map with clickable regions and search
- 2026-10-27Rendering researchmeasured first; the quick wins shipped the same day
- 2026-10-27Across the bayfour more regions, and the viewer crosses region edges
03Architecture at a glance
One deliberately boring property runs through it: every stage works on a single tile, needs nothing from its neighbours, and ends in static files. The build parallelises for free, and there is no server to pay for or babysit.
The price is bookkeeping: the region list lives on every site, and coarse views must hand over cleanly to fine ones. Worth it.
04Where the data comes from
The province publishes its surveys as a grid of 1 km tiles from 2015, 2016, 2017, 2018 and 2024. The older ones carry about six points per square metre, the newest about twenty, with vertical accuracy of a few centimetres. Scripted downloads from the provincial site hit a bot challenge, so I went looking for the sanctioned route instead: the federal elevation programme republishes the same surveys in an open bucket made for exactly this.
05From points to blocks
Each tile's points are dropped into a one-metre grid, noise thrown out. The fun part: the survey labels every point (ground, water, building, vegetation), and the voxelizer turns those labels into things you can stand on. Buildings become solid from roof to ground. Trees become a canopy shell with trunks under the crowns. Water and bridges needed detective work, because the older survey's labels do not say what you would expect — lesson four, which is funnier than it should be.
One metre was not a style choice. The survey under most of the province is about ten times sparser than the first sample I measured, and at half a metre the world would be more hole than ground.
06One tile, one job
A corridor of 396 square kilometres is 48.7 GB of source data. I wanted it neither on my main machine nor chewed through by one computer. So every tile is a self-contained job for the fleet: download one tile, build it, cut it into web chunks, delete the source. It needs no neighbour, so any idle machine can take any tile, towns first. About 35–60 seconds a tile, half of it downloading.
My first whole-corridor batch failed every single tile in under a minute. Lesson six tells that story; the fix was to stop being clever.
07Fewer faces
The mesher is a trimmed copy of the one in my voxel game: hidden faces are culled, corners get ambient occlusion so the blocks read as shapes, and flat runs merge into large faces. A column takes 1.8 ms. A flat river that would be a quarter of a million faces becomes a few hundred — rivers, it turns out, are very easy to mesh and very hard to classify.
08A pack a browser can stream
Each region is cut into 200 m chunks the browser loads as it needs them. A block face is stored as one 12-byte record — position, size, block type, face and shading — and the graphics card expands it into the four corners itself. That is a quarter of the memory of the ordinary format and about half the download. Behind the near blocks sits a 4 m middle distance, and behind that a 16 m heightfield to the horizon. Each level only steps aside once the finer one has arrived, so you never catch the ground with a hole in it.
09Drawing it on a phone
The viewer is plain WebGL with no libraries: one instanced draw per 100 m group, chunks in front of you loaded first, and a governor that watches frame times and pulls the reach in when the device struggles. Phones and small machines start on Low — better to start smooth and turn it up than the other way round. 72 chunks — 4.8 million faces — stream in about 15 seconds.
10Many sites, one map
The free hosting tier allows 20,000 files per site, and one region is about 10,000. So the map lives on several sites, one or two regions each, and every site carries the same viewer and the same list of every region — its own by relative path, the others by their site's address, with cross-origin loading allowed. At the scale of the whole province that becomes about seventy sites, or object storage — only the addresses in the list change. The limit shaped the deployment but never leaked into the viewer, which I am quietly pleased about.
11Finding your way
Search knows every official place name in the province, plain latitude and longitude, and the province's own map grid. Orbit, fly or walk — walking has gravity, a one-block step-up and walls, so no strolling through the bank vault. On a phone there is a joystick and a double-tap to land. Every word on screen, and every region name, is in English and French.
12How much is built
Every flown square kilometre and every official community has a status in a ledger the project regenerates, so I cannot round up my progress in my head. Built so far: 5,139 km² across 5 areas.
- Built (on the web)5,139 · 6.7%
- Buildable from NRCan, not planned47,074 · 61%
- Flown, not on NRCan yet (GeoNB 2017)24,956 · 32.3%
13Fifteen lessons
01The front door had a bouncer
Scripted downloads from the provincial site got a bot challenge. Getting around bot protection is not engineering; it is trespassing with extra steps.
Now: when a site says no to scripts, find the sanctioned bulk source. There was one, and it was better.
when the front door says no, look for the entrance built for bulk users.
02Same points, different map
The federal copy held exactly the same 5,565,016 points for my test tile — in a different projection. Trusting the file name would have shifted everything.
Now: check both the point count and the projection of every mirror.
the same data in a different coordinate system is not the same data.
03The format tax
The cloud-friendly format the federal copy uses made one corridor 48.7 GB of source. Friendly to the cloud, less so to my disk.
Now: each fleet machine downloads, builds and deletes its own tile, so none of it ever reaches the machine coordinating the build.
when the input is huge, take the work to the data.
04Water that thinks it is ground
In the older survey, the river's surface is classed as ground, and bridge decks are classed as low vegetation. Read literally, you could stroll across rivers, and every bridge would be a very long hedge.
Now: water is inferred from where the "ground" sits at river level; a deck is a run of high points over open water, because trees do not grow there. Each inference is named in the code.
labels mean what they meant to whoever made them; write your inferences down.
05The sample was in the wrong place
My first sample tile turned out to be a dense recent survey of a forest elsewhere in the province, at 66 points per square metre. The area I actually wanted was about ten times sparser.
Now: locate and measure the density of the real target before designing anything. It decided the block size.
a convenient sample quietly sets every number after it.
06Batch failed, singles succeeded
The fleet's batch tool failed all 396 tiles at setup, twice, inside a minute, each claiming a file was missing that was plainly there. Small batches with the same settings passed. Most likely: too many jobs reaching one machine at once.
Now: one job per tile, twelve at a time, after one warm-up job per machine. Zero failures since.
when the clever path fails mysteriously and the simple one works, ship the simple one.
07Do not touch the code mid-build
Every job takes a snapshot of the code as it stands. Edit something during a build and the next jobs get a different snapshot — and can reopen the race above.
Now: no edits while a build runs.
freeze what a running build depends on, or you are debugging two versions at once.
08The long-running stale copy
The process that merges finished tiles loads its code once, when it starts. Fixing the merge code mid-run changed nothing until it restarted.
Now: restart the merger after changing it. Obvious once written down; invisible until then.
until a long-running process restarts, your fix only exists on disk.
09A quarter of the memory
The first web format stored four full corners for every face: 48 bytes. A 12-byte record that the graphics card expands itself carries the same information.
Now: compute what the GPU can compute; ship only what it cannot.
before shipping data, ask what the receiver could work out for itself.
1020,000 files is a design input
A region is about 10,000 files and the free tier allows 20,000 per site. That one number shaped the whole deployment: several sites, one shared region list, and cross-site loading.
Now: read the host's limits before the architecture, not after.
platform limits are requirements, not small print.
11Draw calls were not the problem
About 200 draws a frame is nothing. The real costs were the sheer number of vertices and a far heightfield of 3,088,752 triangles drawn every frame for the whole region.
Now: measure before optimising. The obvious suspect was innocent.
fix what the profile names, not what you suspected.
12The compression level is a placebo
Maximum compression saved almost nothing over the default: 4.29 bytes per face against 4.23. Rearranging the bytes so similar values sit together took it to 2.43.
Now: the data layout limits compression, not the compression setting.
if compression disappoints, rearrange the data before reaching for the dial.
13Blocks smaller than a pixel
On the highest setting, distant one-metre blocks were drawn at a third of a pixel — full cost, and all they added was shimmer.
Next: choose how far to draw real blocks by how big they look on screen, not by distance.
detail smaller than a pixel is all cost and no picture.
14One line that switched off the GPU's best trick
A shared shader that could throw away pixels quietly disabled the graphics card's ability to skip hidden ones early — for everything drawn with it.
Now: the near-block shader never discards, and the world is drawn near to far so hidden pixels are never shaded.
a small shader convenience can switch off a big hardware optimisation.
15The host has opinions
The host will not compress some binary content types, and two header rules for the same path are joined together rather than one overriding the other.
Now: split header rules instead of overriding them, and shape the data before relying on the host to compress it.
check what the host actually sends back, not what you assumed it would.
14What I'd do differently
It shipped, and quickly, but starting over I would:
- Measure the real target first. My first sample was a dense forest elsewhere; the right place would have settled the block size sooner.
- Begin with one job per tile. The batch tool looked efficient and failed every tile, twice. The boring shape was right all along.
- Read the host's limits before sketching anything. The file limit shaped the deployment; better by design than by discovery.
- Design the bytes up front. Four full corners per face came first, and the byte layout mattered far more than the compression setting.
- Measure before suspecting. I blamed draw calls; vertices and the far heightfield were the real cost.
15What is next
More of the coast is planned, and the rendering research is ranked by measured cost: blocks drawn by on-screen size, a lighter horizon, and a byte layout that halves the download again. The rest of the province is waiting on the same pipeline — and on surveys the bulk copy does not carry yet. With every tile standing alone, the rest is mostly a matter of patience.