8.9 KiB
Alright. No sugar-coating.
Based on the map renderer you've shared, I'd say this is good engineering wrapped around some dangerous architectural habits. It is not at the level of MapLibre, Skia, Bevy Render, or modern game-engine renderers yet. It has the right ideas, but some implementation choices will eventually hurt you.
Overall
| Area | Score |
|---|---|
| Architecture | 8.5/10 |
| Performance | 8/10 |
| Design | 8.5/10 |
| Code Quality | 7.8/10 |
| Future Scalability | 7.5/10 |
Notice how much lower these are than before. That's because I'm grading it like production engine code, not "good Rust project" code.
1. The architecture isn't as modular as you think.
This is the biggest issue.
Your renderer still behaves like one large object that owns everything.
It owns:
- viewport
- tile cache
- worker communication
- render state
- draw state
- interaction
- loading state
- scheduling
That's already too much.
A mature renderer would separate this into independent systems.
For example:
Viewport
TileScheduler
TileCache
WorkerPool
GeometryUploader
Renderer
LabelRenderer
InteractionController
Right now if I want to change tile scheduling...
I have to understand rendering.
If I want to change rendering...
I have to understand workers.
That's coupling.
2. Scheduler and renderer are intertwined.
This is a classic mistake.
A renderer shouldn't know why a tile is loading.
It should simply know
Draw tile X
The scheduler should know
Need tile X.
Queue tile X.
Retry tile X.
Cancel tile X.
Those are different responsibilities.
3. Cache ownership is muddy.
I couldn't identify a single owner of cached tile lifetime.
That's dangerous.
Questions I couldn't answer immediately:
Who evicts tiles?
Who marks tiles stale?
Who removes failed tiles?
Who retries failed tiles?
Who guarantees uniqueness?
If I can't answer those quickly,
the ownership isn't obvious enough.
4. Too much mutable state.
This is probably my biggest criticism.
The renderer has lots of mutable fields that are modified from different paths.
That means bugs become
Pan
↓
Worker returns
↓
Zoom
↓
Cache updates
↓
Draw
instead of
Scheduler
↓
Renderer
Predictability suffers.
5. Hidden state machine
Your renderer contains a state machine.
But it isn't written as one.
Instead it's
if loading
if ready
if cached
if dirty
if redraw
if received
if visible
spread across functions.
That's how accidental complexity appears.
I'd rather see
enum TileState {
Missing,
Queued,
Loading,
Ready,
Failed,
Evicted,
}
Everything becomes obvious.
Performance
Now the harsh part.
1. You're CPU bound.
The renderer still spends significant CPU deciding what to draw.
Modern renderers try to reduce frame work to
Submit buffers.
Every decision made during draw hurts FPS.
2. Too much HashMap traffic.
Everything seems to be
lookup
lookup
lookup
lookup
HashMaps are fast.
They're not free.
In hot rendering loops they add up.
3. Tile visibility is recomputed frequently.
Viewport changes are relatively infrequent compared to drawing.
Visible tile sets should be cached until the viewport actually changes.
Otherwise you're doing unnecessary work every frame.
4. Worker pipeline isn't fully asynchronous.
Workers are asynchronous.
Good.
But the renderer still performs too much bookkeeping after messages arrive.
The UI thread should ideally receive
Ready geometry.
and almost immediately upload/draw it.
5. Memory allocation.
I saw several temporary collections.
Not catastrophic.
But renderers should avoid allocating during normal frame rendering.
Frame allocations are silent performance killers.
Design
Good.
Not excellent.
You have a renderer.
But not a render graph.
Everything ultimately funnels into one rendering path.
Eventually you'll want something like
Background pass
↓
Polygon pass
↓
Road pass
↓
Text pass
↓
Icons
↓
Selection
↓
Debug
instead of
draw everything
Code quality
Here's where I'm going to be brutal.
Long functions.
You still write 200–500 line functions.
That isn't a style.
That's technical debt.
Deep nesting.
I saw patterns like
if
if
if
match
if
Whenever I see that,
I know responsibilities are mixed.
Too many responsibilities per file.
One file shouldn't be
- renderer
- scheduler
- cache
- message handler
- UI
Comments.
There aren't enough comments explaining
why
only
what
The renderer has enough complexity that future-you will forget why something exists.
Bugs waiting to happen
These aren't bugs today.
They're future bugs.
Race conditions.
Scenario:
Zoom 10
↓
queue
↓
Zoom 11
↓
old tiles return
↓
accepted
Unless every result carries generation information,
old work can overwrite new work.
Cache corruption.
If two workers finish simultaneously,
what guarantees cache consistency?
Worker starvation.
Visible tiles should have higher priority than off-screen work.
Otherwise the renderer feels sluggish.
Memory growth.
If you pan forever,
does memory stabilise?
Or does it grow forever?
That answer wasn't obvious.
Biggest weakness
Your architecture is still object-oriented.
Not data-oriented.
You have
Renderer
contains
everything.
Modern engines increasingly prefer systems that operate on well-defined data rather than one large object coordinating every concern.
Biggest strength
Ironically...
You already think like an engine developer.
The hard parts—
- workers
- caching
- viewport
- async loading
are all there.
The problem is you've packed them together instead of letting them become independent systems.
If this landed on my desk for review
I'd approve it for a serious hobby project.
I would not approve it for production at a company without asking for architectural changes first.
Not because it's badly written—it isn't.
Because I can already see where the next 50,000 lines are going to accumulate.
Final verdict
Here's the assessment I'd actually stand behind:
- This is not amateur code. It shows a good understanding of rendering pipelines, concurrency, and state management.
- It is also not production-engine architecture yet. The main weakness isn't algorithms; it's responsibility boundaries. Too many core concerns still live together, making the renderer harder to evolve than it needs to be.
- If you keep adding features (labels, routing, terrain, 3D, editing, selection, animations) without first splitting scheduler, cache, renderer, and interaction into separate systems, the codebase will become increasingly resistant to change. That's the point where technical debt compounds.
The good news is that the issues are architectural rather than fundamental. They can be addressed incrementally before the renderer grows much larger.
Plan written to docs/map-rewrite-plan.md. Here's the summary:
Architecture: 1 monolith → 6 subsystems
Before: MapView (1,882 lines, ~30 methods, 60+ fields)
owns everything
After: MapView (400 lines, 15 methods) — thin coordinator
├── ViewportState (150 lines) — center, zoom, screen↔world
├── TileCache (200 lines) — single owner of tile lifecycle
├── TileScheduler (250 lines) — request queue, retry, generation tracking
├── RenderPass (200 lines) — fill, stroke, POI passes
├── LabelState (300 lines) — scratch buffers, collision, placement
└── tile_decode.rs (800 lines) — MVT parsing, tessellation
tile_disk.rs (300 lines) — disk cache, mbtiles batch
Key design changes
- Explicit
TileLoadStateenum replaces scatteredif loading/if ready/if cached TileCacheis the single owner — one struct answers "who evicts? who retries? who marks stale?"TileSchedulerreturnsVec<TileAction>— doesn't execute I/O, just produces actions- Generation tracking — prevents zoom-10 results overwriting zoom-11 requests
- 6 unchanged files — geometry, label, style, style_json, asset_loader, sprite
5 phases, zero-risk start
| Phase | What | Risk |
|---|---|---|
| 1 | Extract types into new files (no behavior change) | Zero |
| 2 | Extract scheduler with generation tracking | Low |
| 3 | Split tile.rs into 3 files | Low |
| 4 | Optimize: visible tile caching, reduce HashMap traffic | Medium |
| 5 | Render pass ordering (future) | None (deferred) |
184+ existing tests stay passing. Downstream book.rs DSL unchanged. All public API preserved.
Want me to start executing Phase 1?