Who makes the triangles
I moved road rendering out of the simulation and into Unreal. That exposed a less obvious problem: the road you see and the road you drive on should not be the same mesh.
Who makes the triangles
Part eleven of this series. Part ten got the traffic simulation running inside Unreal and left the engine in charge of the physical cars. This part is about the road underneath them.
For a long time, DrivEngine made the road surface in Rust. It already had the curves and widths, so turning them into triangles was an obvious thing to do. The desktop tool still uses that mesh and it is fine there.
It was not a good way to make the road for a game.
The problem was not that the mesh looked bad. The problem was who could change it. If an artist wanted a different kerb, the change had to go through the simulation data, the C++ bridge and a rebuild of both projects. Even a tiny visual adjustment became a programming task.
I wanted Rust to say what the road is, then let Unreal decide what it looks like.
Describing a road instead of finishing it
The division is quite small. Rust cuts and classifies the road. Unreal matches those pieces against a table of style rules, then a PCG graph builds the meshes.
Across the road is an ordered list of slots. A typical one might read sidewalk, verge, parking, lane, lane, median, lane, lane, verge, sidewalk. A style rule looks for a pattern in that list. That is enough to find things such as the outside driving lane or the strip between a lane and a sidewalk.
Along the road, Rust cuts a new section whenever something changes. A crossing can cause a cut. So can a lane appearing, a lane ending or the start of a junction approach. Between two cuts, the cross section stays the same.
This leaves Unreal with pieces that are simple to style. It does not need to understand the road network. It gets a stable section, finds a matching rule and builds whatever art that rule names.

Each colour is one slot in the cross section.
The ground below one slot is called a strip. Its two edges come from the same curves used to build the lanes, so the visible road cannot slowly drift away from the road the drivers know about. Unreal can sweep a profile mesh along that strip to make asphalt, a verge or a kerb.
There are a couple of awkward details in the data. Left and right refer to the direction of the road, not the direction a particular lane is driven. A lane going against the road therefore has the driver's left stored as the road's right. This still makes me stop and think when I read it, but changing the meaning per lane would be worse.
The other detail is ownership. The city is generated in cells. A road can cross a cell boundary and be visible from both sides, but only one cell should build each piece. The query returns that as data rather than expecting every caller to remember the rule.
Three bugs that looked like bad geometry
The first result was not close. Some roads floated above the landscape and moved sideways as the camera crossed a cell boundary. The vertices were already in world space, then the PCG partition actor applied its transform to them again. Converting the vertices into the actor's local space fixed it.
The next result had overlapping, twisted ribbons on curves. I had taken a dense list of samples from the real road curve and rebuilt it as a smooth Unreal spline. Unreal added new tangents between the samples, so it was smoothing data that had already been sampled from a smooth curve. Using linear spline points kept the original shape.
The third bug was smaller and probably more dangerous. A fallback in the collision path assumed that every profile mesh was one metre wide. The sidewalk profile happened to be two metres wide, so its collision stopped halfway across the visible mesh. Reading the mesh bounds fixed that. It is the sort of hard-coded value that looks harmless until the second asset arrives.
The first whole city was far too expensive
Once the city rendered correctly, I measured it. The mesh objects used 1059 MB.
That version kept every sampled point and made a separate mesh for every swept piece. Most of those points described straight roads, and most of those objects were too small to need separate culling.
The current version has two curve limits. It can discard a point when the surface and both edges stay within five centimetres of the dense curve. It also forces a point at least every ten metres, so a long curve never becomes one huge interval. Pieces with the same tags are then joined within a 75 metre cell.
| first version | simplified and joined | |
|---|---|---|
| mesh objects | 1118 | 781 |
| mesh memory | 1059.3 MB | 95.5 MB |
| points kept | 100% | 8.27% (2117 of 25597) |
The map looked the same, but mesh memory was about eleven times smaller. The saved map fell from 128 MB to 16.6 MB, which also put it back below the host's 100 MB file limit.
The process memory numbers were not clean enough to compare because both runs happened in one editor session. The table uses counts and memory reported directly by the mesh objects after a forced garbage collection. I trust those numbers. I do not trust the editor's total working set from the same session.

Straight sections lose almost all of their samples. Curves keep more.
The road looked finished, but cars fell through it
Generated dynamic meshes do not cook collision by default. This is a sensible default and part of the reason the memory result above is good. It also meant that the entire city was scenery.
I first considered enabling collision on the visible meshes. That would have made collision out of detailed swept profiles, kerbs, corners and every bit of geometry an artist later adds. It would also give the sidewalk collision a raised walking surface when the car needs a simple ground plane.
Instead, every strip can produce a second mesh. The visible ribbon keeps the five centimetre error limit and ten metre segment cap. The collision ribbon uses 25 centimetres and 20 metres. It has no material or texture coordinates. A sidewalk can lift it by twelve centimetres so the top of the kerb is solid, while the road leaves it at ground level.
Both ribbons come from the same pair of edges. They can have different detail without disagreeing about where the road is.
| collision off | collision on | |
|---|---|---|
| mesh objects | 781 | 1561 |
| mesh memory | 97.9 MB | 100.2 MB |
That is 780 collision meshes for another 2.3 MB. Together they contain 17810 triangles, or about 23 per component. The 97.9 MB baseline differs from the 95.5 MB in the earlier measurement because they were taken on different days. A difference of two percent here is normal drift, not a result.

The collision ribbon is normally hidden. It is shown here as wireframe.
This needed a small custom PCG node because Unreal's dynamic mesh spawner does not expose collision settings. One API detail wasted half an hour: setting the named collision profile and then calling the general collision enable function silently changes the profile to Custom. The second call looks like confirmation, but it undoes part of the first.
I needed better ways to look at the result
Regenerating a city, counting a thousand components and checking their properties is miserable by hand. Unreal already has an editor-only HTTP endpoint, so I used that for most of the checks.
The endpoint only exists while the editor is running. I close the editor often because this machine is tight on memory, which meant a client that connected once would stay attached to a dead session after the first restart. A small local proxy now reconnects to whichever editor session is alive.
I added four tools on top of the editor API.
- A screenshot tool writes an image to disk and returns the path. Returning the image itself turns one frame into roughly a megabyte of text.
- A console command tool returns the log produced while the command ran.
- A graph helper can place plugin nodes that Unreal's own graph tool cannot create.
- A read-only tool returns every junction id and its pixel position in the current view.
The last one has been the most useful. A road network screenshot contains plenty of junctions and no names. The pixel list lets me put the ids on the image and talk about a specific bad junction without guessing which one it is.

The labels come from the id and screen-position query. They are not part of the network renderer.
I expected most missing tools to be about changing the level. They were mostly about reading it. Unreal already exposes selection, camera control, play mode and graph editing. What I lacked was a cheap way to see the result and identify what I was seeing.
The rebuild loop still hurts
Plugin code cannot be compiled while the editor has the module loaded. A C++ change still means save, close, build and relaunch. Saving first matters because an unsaved package opens a modal dialog during shutdown, and that dialog blocks the thread serving the editor API. From outside, the editor then looks both alive and unresponsive.
A full rebuild also found three colour constants that had the same names in two source files. Each file compiled alone. A combined build put them in one translation unit and exposed the clash days after it was introduced.
The editor eventually ran out of memory after about six whole-city regenerations with collision enabled. It died at 14.88 GB. There is still work to do there. The immediate practical answer is not to keep regenerating the whole map in one session.
Where this leaves the road system
The engine now receives a description instead of a finished surface. An artist can change the road without changing Rust, the bridge or the simulation. The visible mesh and the collision mesh follow the same source curve but use detail appropriate to their jobs.
There are unfinished parts. The style id still needs to live on roads and lanes instead of being chosen only by proof-graph tags. The roads are flat. A terrain fitting tool is designed but not built. Shipping will also need the generated components baked into static assets. Junction meshes supplied by presets are still on the list.
The useful part for me was not any one mesh algorithm. It was finally deciding where the simulation's responsibility ends. Rust describes the road accurately. Unreal makes it look like a road. Keeping that boundary narrow made the rest much easier to change.
Until recently, DrivEngine built the visible road surface itself and sent the finished triangles to Unreal. It was good enough for a prototype, but it left the art trapped on the Rust side of the project. Changing a kerb meant changing code and rebuilding both halves.
I replaced that with a description of the road. Rust divides the network into sections and reports what each section contains. Unreal decides which meshes and materials to use. Across the road, the description is an ordered list of slots such as sidewalk, verge and driving lane. Along the road, a new section starts whenever that list changes.
The first whole-city version used 1059 MB of mesh memory. Simplifying the curves and joining nearby pieces cut that to 95 MB. The saved map also fell from 128 MB to 16.6 MB.
Then I tried to drive on it and discovered that the generated art had no collision. Turning collision on for every detailed mesh would have been wasteful, so the road now creates a second, coarse ribbon from the same edges. The visible road and its collision use different error limits. The extra collision cost 2.3 MB.
Most of this was checked through small editor tools. The most useful tool did not edit anything. It returned every junction id and its screen position, which made screenshots useful for debugging.
This is the short version. The full article has the reasoning, the numbers, and the parts that went wrong.