Version 2.5.0: how editing large maps went from seconds to a fraction of a second #25
Filroden
announced in
Announcements
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Version 2.5.0 is mostly a performance release, and this post is the longer account of it: what was slow, what changed, how I checked that the maps still come out the same, and what it costs. The short version is in the changelog. This is for anyone who wants the detail, including the parts that did not work.
The short version
On my own test map (3210 x 2400 pixels, 7,158 brush strokes, 46 custom rivers, Foundry v14), the work that follows an edit went from several seconds to a fraction of a second.
All of these are times measured in Foundry. They leave out the 2 second pause between finishing a stroke (or moving a node) and the change being applied, which I have left alone on purpose, because it is what lets you keep drawing without being interrupted.
The "before" column is not the true starting point. It was measured after the first change described below, the faster brush loop, was already in. Before that, replaying the brush strokes alone took about four times as long, so the real starting point was several seconds worse than these numbers.
Loading a map, and changing the seed or a slider, still regenerate the whole map. Those are faster too, because the largest part of a whole-map regeneration on a map with many strokes is replaying the strokes, and that is now about four times quicker: 3.6 seconds on my map.
Two changes are worth knowing about before you update. Keeping edits quick uses extra memory (details below). And procedural rivers on existing maps may take a slightly different route, for a reason explained in the last section of the changes.
Why edits were slow
The module builds a map in stages. It generates the base terrain from noise, replays every brush stroke onto it, carves faults and custom rivers, works out moisture and temperature (the climate), traces the procedural rivers, and finally paints the layers you see: base colour, topography, biomes and contours.
That order is right, but every edit paid for all of it. On my map, because it has custom rivers, every terrain stroke ended in a full replay of all 7,158 strokes, a full repaint, a full climate calculation, a river pass, and a second full repaint. Undo and redo did the same. A tiny stroke cost as much as a whole new map, and the cost grew with every stroke added, so a map got slower to edit the more you worked on it.
Two things I had tried or considered earlier shaped how the rest was done:
What changed
1. A faster brush loop
Replaying strokes was about three quarters of a full render, and the innermost loop (stamping the brush onto the terrain, once per pixel) was slower than it needed to be. It called
Math.hypotfor every pixel and looked up the stroke's settings again for every pixel.The rewrite resolves the stroke's settings once per stamp, skips the pixels outside the brush circle row by row, and specialises the raise and lower tools. The interesting detail is the distance calculation. Plain
Math.sqrt(dx * dx + dy * dy)is faster still, but it rounds differently fromMath.hypotfor about 38% of pixel offsets (up to 2 units in the last place, over 20 million offsets I measured). The terrain is stored as 32-bit floats and I never found a case where that changed the stored value, but that is luck of rounding rather than a guarantee, and it would let replayed terrain drift from what earlier versions produced. So the loop usesexactHypot, an inline copy of V8's own two-argument algorithm (scale both values by the larger, take the square root of the summed squares, scale back). It matchedMath.hypoton 60 million random offsets and the edge cases, with no differences.On a replica of my map's stroke mix, the replay went from 34.1 s to 8.8 s in a two-core sandbox (3.9 times faster), and it takes 3.6 s in Foundry on the real map. Terrain and biome output is identical to before, pixel for pixel.
2. Keeping the brushed terrain, so nothing is replayed
Even at four times faster, replaying thousands of strokes on every edit is wasteful, because all but the last one are the same as last time. The brush engine now keeps a second copy of the terrain, the brushed layer: the base terrain with every stroke applied and nothing else (no faults, no rivers). The working terrain is that layer with the vector features carved on top, so a rebuild copies the layer and carves again instead of replaying.
The layer has to be exactly what a full replay would produce, so it is only ever changed by operations that reproduce a replay's result:
If the browser refuses to allocate the memory for the layer, the module falls back to replaying the history, which needs no extra memory, and says so in the console.
3. Refreshing only what changed
With the replay gone, what remained was whole-map work: climate, rivers, and repainting all four layers. Most edits change a small part of the map, so the module now works out which part, and limits the rest of the work to it.
The important word is "works out". A river's carved bed follows the terrain along its whole length, so editing terrain near one end can change the carved elevation a long way from the edit. Predicting the changed area from the stroke's footprint is exactly the kind of guess that produced ghost terrain before. So instead:
The comparison is bit for bit, not numeric. A numeric
!==treats-0and+0as equal andNaNas different from itself, and a stage that skipped a pixel because of that could leave the map differing from a full rebuild.BufferDiffreads the float rasters through aUint32Arrayview of the same memory, so it compares raw bits:The stages after the comparison are then limited to that area, with three details that matter:
On a full-size replica the median repainted area was about 5% of the map.
4. Skipping the base terrain when it cannot have changed
Moving a fault or river node still went through the full pipeline, including regenerating the base noise and replaying the strokes, even though the base terrain cannot have changed: faults and rivers are applied after the strokes, never inside the base terrain. That was the 7.4 seconds in the table.
The module now records a description of everything the base terrain and the settings depend on: the generation engine, seed, map size, sea level, every derived generation parameter, the custom biomes and their colours, and (in Guided mode only) the land masks. It is a JSON string, stored when a full generation finishes and compared when the next one is asked for. If the two are equal, the same refresh described above is used instead of a full generation. If they differ, if the last generation never finished, or if the buffers have been replaced, the full generation runs as before. Any doubt means the full path, which is always correct.
What the description leaves out is what a refresh finds by comparison anyway: brush strokes, faults and manual rivers. It also leaves out the spring pins, which only feed the river pass, and a refresh always reruns that pass. A pin move once left the river map stale in a test until I added that rerun for a refresh that finds no terrain change.
5. Uploading only the changed rows
Each of the five map layers keeps an RGBA pixel buffer the size of the map (31 MB on my map). Foundry v14 uses PIXI 7, and PIXI 7's
BufferResourcecopies the whole buffer into its texture data and uploads all of it to the graphics card on every update. That happens even for a tiny repaint, and on every pointer move while painting.The map layers now use a
RegionUploadResource, a subclass ofBufferResource. It copies only the rows that changed into its texture data and uploads only that band of rows withtexSubImage2D. A band of full-width rows is the smallest piece that is contiguous in the buffer, which means it needs none of the WebGL 2 only unpack settings (and no cleanup of them afterwards). It uploads the whole texture, as before, whenever it cannot be sure that the graphics card's copy matches: the first upload, a size change, a texture the browser recreated (for example after a lost context), or a pixel buffer that is not the one it last copied from.This assumes PIXI 7 and one renderer per texture, which is what the map canvas has. It is written against PIXI 7.4's
BufferResource.upload, and would need rechecking if Foundry moves to a later PIXI version.I expected this to be the last big saving. It was not, and it is a useful example of why the module now prints timings. The Foundry log showed the copy and upload time dropping to about 7 ms, but the repaint as a whole barely moved, because the time was somewhere else.
6. The real cause of the last big cost: rivers sharing one random stream
After the changes above, moving a river node still took 861 ms, and the log showed the four painters taking 674 ms of it. In a sandbox the same painters take about 2 ms per layer for the changed area, so they had to be repainting something much larger. I had added a note to the timing summary saying how much of the map each repaint covered, and it said 52.2%, in a box spanning most of the map. Looking at those coordinates, the obvious reading was the right one: it was repainting all the rivers, not just the one that moved.
The cause was in the river tracer. Every river was traced from one shared random number stream, in order. The tracer uses it at every step: once for the direction its scan of the eight neighbouring pixels starts in (which decides ties between equally low neighbours) and once per neighbour for the meander jitter. So if an edit changed how many steps one river took, or whether it merged into another, the stream shifted for every river traced after it, and each of those could take a different route wherever a tie went the other way. On a replica of my map, moving one node reshaped between 27 and 34 of 80 rivers.
Now each river draws from a stream of its own, seeded from the map seed and the pixel index of its spring:
The mixing steps (the finaliser from MurmurHash3) make springs a pixel apart get unrelated streams instead of the nearly identical ones that seeds one apart would give. On the replica, moving one node now reshapes 1 to 5 rivers instead of about a third, and the river map differences shrink from a box covering 83 to 86% of the map to one covering 0.2 to 4.2%. On my map the repaint area went from 52.2% to 2.3%, the painters from 674 ms to 61 ms (and from 291 ms to 13 ms on the undo), and the whole refresh from 861 ms to 225 ms.
Two things follow from this that you should know:
How I checked that the maps come out the same
The rule for all of this is that a refresh must be identical to regenerating the whole map, bit for bit. I did not want a faster map that was slightly wrong, so most of the effort went into testing that.
Foundry itself cannot be driven by an automated test, so the checks are Node harnesses that import the module's real engine code and mirror what the app does around it. They generate a map, apply a random sequence of edits, refresh the way the app would, and then compare every derived layer (base and current elevation, biome overrides, moisture, temperature, river map, water mask and the five painted canvas buffers) with an independent generation of the same map from scratch.
Tests can pass because they are weak, so I also broke the code on purpose (mutation testing) and checked that the harnesses noticed. Across the pieces I tried several dozen deliberate breaks: dropping the wind padding, ignoring the water mask difference, skipping the peak check, off-by-one errors in every bound, leaving out parts of the settings description, and so on. Most were caught. About ten survived, and I checked each one to see why: they were all things that cannot change the output (for example, dropping the map size from the settings description, because the buffers are reallocated whenever the size changes).
What is not covered: the parts of the app that need Foundry itself, such as the glue in
generateTerrainand the timer that releases the scratch buffer when idle. The harness mirrors that glue, and I used the real thing myself on the real map, but that is a person looking at a result, not a test. My comparison of PNG exports before and after the brush loop change gave identical file sizes, which is reassurance rather than proof. The bit-for-bit evidence is the sandbox harnesses.What did not work
What it costs
BufferResource, which is what Foundry v14 uses. Foundry has deferred moving to PIXI 8 until a much later version, and the upload code will need revisiting then.What is still slow
Reading the console summary
Full renders, refreshes, undo and redo each finish with one summary in the browser console (F12), which lists every phase's time and share of the total, how many times a phase ran, and, for repaints, how much of the map was repainted. It looks like this:
If a map feels slow after this update, or an edit leaves something out of date on the canvas, please open an issue with that summary attached and a note of the map size and roughly how many strokes it has. The summary is what found the river problem above, and it is the quickest way for me to see where the time is going on a map that is not mine.
All reactions