# How Pixel Singapore Was Made

*Yadong Xie · November 7, 2026*

![Panning and zooming around Marina Bay on the pixel-art map, under a tilt-shift blur](https://www.yadongxie.com/blog/pixel-singapore-demo.webp)

*Moving around Marina Bay on an early build.* ([video](https://www.yadongxie.com/blog/pixel-singapore-demo.mp4))

[Pixel Singapore](https://www.yadongxie.com/lab/singapore) is an isometric pixel-art map of central Singapore. An image model drew it at 0.25 meters per pixel, and I added traffic, birds, swaying trees, and moving water on top. The finished map is 31,744 × 16,384 pixels, about 520 megapixels, and covers roughly 8 km by 6 km.

*[Image: The Pixel Singapore page as it opens: the whole map under a tilt-shift blur, with the title, a minimap and the credits]*

There are five steps: render the city from 3D data, redraw the render with an image model, cut the result into zoomable tiles, animate it, and show it in the browser.

*[Diagram: pipeline]*

## The camera

The source data is Google Photorealistic 3D Tiles, which I stream through Cesium ion and draw with three.js in a headless browser. The whole map comes from one orthographic camera, pointed 15 degrees west of north and tilted 45 degrees down. Strictly speaking this isn't isometric, but that's what most people call this view.

*[Diagram: camera]*

Because the camera is orthographic, its rays are parallel and there's no vanishing point. A building is the same size wherever it stands, and its verticals stay vertical on screen. At 0.25 meters per pixel, one meter across the screen is 4 pixels, and one meter into the view or straight up is about 2.83 pixels.

*[Image: Towers around Raffles Place on the finished map, all standing straight up, with the river behind them]*

*Towers around Raffles Place.*

I split the image plane into a fixed grid of square shots, each 2048 pixels (512 m) wide. Moving from one shot to the next shifts the camera by exactly one shot width, so neighboring shots line up to the pixel. A larger region can reuse the same grid later, which came in handy: I originally planned to do the whole island and only later narrowed it to a rectangle around the Central Area. That rectangle needs 160 shots.

The same camera also renders a height pass, which stores the height of the surface at each pixel in centimeters, using the red and green channels. You never see it on the page, but most of the animation depends on it.

## Generating the image

If you redraw a map one tile at a time, each tile makes its own decisions. A road changes color at the border, or a roof comes out in a different style. To avoid that, I never sample tiles independently.

The model is Qwen-Image-2.1-Turbo with a style LoRA, conditioned on the render, and the prompt is a single line: *Redraw this aerial view as detailed isometric pixel art.* Sampling follows [MultiDiffusion](https://arxiv.org/abs/2302.08113). One latent covers the whole canvas, with one cell for every 16 × 16 pixels. At each denoising step, the model runs on overlapping 1024-pixel windows spaced 768 pixels apart, each conditioned on its own crop of the render. The predicted velocities are then averaged, using a weight that's flat across the middle 512 pixels and falls to zero at the edges:

```python
for (y, x) in grid:              # every other step, a grid shifted by 384 px
    vel[cells(y, x)] += predict(y, x, t) * weight(y, x)
    wsum[cells(y, x)] += weight(y, x)
latents = latents + (sigmas[i + 1] - sigmas[i]) * vel / wsum
```

Because of the shift, the places where windows meet on one step land in the flat middle of a window on the next. There are eight steps, which is the schedule the Turbo checkpoint ships with. Decoding uses the same idea, with overlapping windows blended in pixel space.

*[Image: Sampling windows over the finished map: orange and blue tints alternate window by window and mix where they overlap, with one window outlined, its flat middle marked, and a dashed window shifted by 384 pixels]*

*Windows over one block. The dashed outline is from the shifted grid.*

## Generating in blocks

The map isn't one run. It's five blocks of 16,384 × 10,240 pixels, overlapping by 1,024, generated one after another on a single RTX 5080. Marina Bay came first, then the four blocks around it.

*[Image: The whole map with the shot lattice drawn over it and five overlapping blocks outlined and numbered in the order they were made]*

*The five blocks, numbered in the order I made them.*

Each new block starts from the pixels of any finished neighbor that overlaps it. Those pixels get encoded once, and after each step the latent in that area is reset to the known content, noised to match the current step, so the new area grows out of what's already there. This is the idea behind [RePaint](https://arxiv.org/abs/2201.09865), applied to flow matching:

```python
# hold the known part at this step's noise level
s1 = sigmas[i + 1]
latents = torch.where(pin_cells, (1 - s1) * pin_lat + s1 * noise, latents)
```

A 64-pixel band along the edge of the known area stays free, and at the end the known pixels are feathered back in over it. Windows that fall entirely inside the known area are skipped.

*[Image: A crop across the band where two blocks overlap: roads, roofs and rows of trees run through it with no visible break]*

*Two blocks meet between the ticks.*

For one block, the logs show 273 windows in the first grid and 308 in the second, 112 of them fully held. Conditioning took 20 minutes, the eight steps took 67 minutes at 2.15 seconds per window, and decoding took 17 minutes, with VRAM peaking at 14.84 GB. The four blocks around Marina Bay took 83 to 112 minutes each, 6.6 hours in total. State is saved after every step, so an interrupted block can pick up where it stopped.

*[Image: Marina Bay Sands, the ArtScience Museum and the edge of Gardens by the Bay on the finished map]*

*Marina Bay Sands.*

*[Image: Chinatown at full size: rows of orange-roofed shophouses, a temple, housing blocks and a junction]*

*Chinatown at full size.*

*[Image: The National Stadium and the buildings around it on the finished map]*

*The National Stadium.*

*[Image: Rows of stacked containers and cranes at the port on the finished map]*

*The port.*

The model also makes things up. For example, it added tree crowns over streets that are clear in the 3D render.

## Tiles

[OpenSeadragon](https://openseadragon.github.io/) draws the map from a Deep Zoom pyramid: 512-pixel tiles with 1 pixel of overlap across 16 levels, where each parent pixel is the average of its four children. There are 2,660 tiles in WebP at quality 60, which I picked after trying settings between 90 and 20. They come to 189 MB, and with the animation data the whole map is 321 MB.

*[Image: Four tiles of the pyramid around Marina Bay Sands, from level 12 at 2 m per pixel to level 15 at 0.25 m per pixel, each marking where the next one lies]*

*One tile per zoom level.*

The pyramid is sparse. Tiles without content don't exist, and the manifest lists the finished blocks, so the viewer only requests tiles that touch one. That let me put the page up while blocks were still being generated. Above 1.25× zoom the map is drawn with crisp pixels, and below that it's smoothed so it doesn't shimmer. Tiles are cached for a year and their URLs include the manifest version, so anything I regenerate has to ship under a new version or returning visitors keep the old file.

## Animation

Traffic and birds only load once you zoom in. At full-map zoom you wouldn't see them anyway, and the road data is the largest file after the tiles.

### Traffic

The roads come from OpenStreetMap: about 490 km, split at junctions into some 14,000 edges, each sampled every 4 m. They go through the same camera as the map. OpenStreetMap's lines can be a few meters off, so I place the lanes on the asphalt found in the render. Cars follow the [intelligent driver model](https://en.wikipedia.org/wiki/Intelligent_driver_model):

```js
const A = 1.4, B = 2.2, S0 = 2.2, T_HEAD = 1.3;
const sStar = S0 + Math.max(0, v * T_HEAD + (v * dv) / (2 * Math.sqrt(A * B)));
const acc = Math.max(-5, A * (1 - (v / v0) ** 4 - (sStar / gap) ** 2));
```

Cars keep left, as they do in Singapore, and usually go straight at junctions. Traffic lights run two phases per junction on a 70-second cycle: 31 seconds of green, 3 of yellow, and 1 of all red. Only streets near the view are simulated. Cars spawn at the margin at a density that depends on the road, and they're removed once they leave it.

*[Image: Cars, taxis and trucks on a viaduct crossing the water, at deck height]*

*Cars at deck height on a viaduct.*

The vehicles are small 3D models made of boxes. I draw them through the map's camera, light them with the same sun, and turn them into pixel sprites at 32 headings, so a car looks right at any angle.

*[Image: Sprite sheet of cars, taxis, buses and trucks, each at eight headings]*

*Eight of the 32 headings.*

### Hiding cars behind things

Cars are drawn on top of the map, so without some help they'd appear in front of everything. The fix is a layer made of the map's own pixels that stand in front of the road. Along a line of sight, whatever is higher is closer to the camera, so for each pixel near a road, I compare the height a car's side would reach there (4.4 m for a double-decker) with the surface in the height pass. If the surface is higher, that pixel goes in the foreground layer. The viewer draws a car, then draws this layer over that car. Cars on viaducts are drawn afterward and are only hidden where a tower stands in front of them.

Height alone misses the tree crowns the model invented, so green pixels near the road go in too, with specks removed and gaps filled. Otherwise a car passing under a tree would flicker through the holes. The layer is lossless WebP because it has to match the map exactly.

*[Image: The foreground layer alone on a dark background: a footbridge, slivers of tree crowns along the road and part of a tower]*

*[Image: The same stretch of road on the live page, with a bus passing under the footbridge and cars under the trees]*

*The foreground layer, then the same road on the page.*

### Trees and water

The trees and water aren't drawn separately. A transparent WebGL2 canvas moves the map's own pixels, using a mask laid out like the tiles. Canopy is anything green in the render that stands 2.5 to 45 m above the local ground. A watershed on the canopy height splits it into crowns: each crown top seeds a piece, which grows downhill until it meets another one. Crowns within 2 m of a building don't move, and those within 4 m sway half as much, so a crown never slides over a wall.

*[Image: Half the map beside half its life mask: each tree crown is a separate color, and the rest is dark]*

*Each color is one tree crown.*

Gusts cross the map from the northwest, 400 m apart, and each piece also flutters on its own, shifting a pixel or two eight times a second. Water is whatever is flat to within 0.4 m over 10 m and teal or blue on the map. Google's water surfaces sit at different heights, so a single water level wouldn't work. Six times a second, each 3 × 2 patch takes its color from a pixel along the swell, and occasionally a bright crest flashes for half a second. Only pixels that change get drawn.

### Birds and chickens

Bird locations also come from OpenStreetMap. There are 118 flocks of white pigeons around hawker centers, squares, and parks, 60 egrets over water, and 21 brahminy kites on thermals 1.2 km apart, each flying for 6 minutes every half hour on a shared clock. Pigeons aren't drawn while they're on the ground, because at this scale they'd look too big. When they take off they all go at once, fly close together, circle, and glide down somewhere nearby. Tapping near them scares them up.

*[Image: A flock of white pigeons in flight over a lawn, with its shadow on the grass below it]*

*Pigeons over Fort Canning.*

Sprites are about 0.3 m per pixel, which makes a pigeon roughly seven times life size. Any smaller and you couldn't make it out. Every bird is drawn from above and turned to face the way it's flying.

*[Image: Sprite sheet of a pigeon, an egret, a kite, a rooster, a hen, a pale hen and a chick, each at four headings]*

There are also 769 free-range chickens in 90 flocks, on lawns of at least 400 m² found using the map, the masks, and the height pass. They peck, look around, and walk a few meters to another spot, and chicks run after their hen.

*[Image: A rooster, two hens and a chick on the grass beside a blue tarpaulin]*

### The tilt-shift

Two stacked `backdrop-filter` blurs cover the top and bottom of the page, masked so the blur gets stronger toward the edges. They sit above the map, the cars, and the birds, and they're a big part of why the city looks like a miniature.

*[Image: The page at phone width, showing the whole map on the left and a close view of Marina Bay Sands with traffic on the right]*

*On a phone.*

## What went wrong

### The blur froze while dragging

On phones, dragging the map left a ghost, a blurred copy of the map lying over the sharp one.

*[Image: A frame from a drag in the iOS Simulator: the top of the map is sharp, and a blurred, displaced copy of the map lies over the lower half]*

*The ghost, mid-drag.*

WebKit doesn't sample a `backdrop-filter` again when the canvas under it is redrawn, so while the map moved, the blurred bands kept showing where it had been. Chrome's phone emulation doesn't reproduce this because it's still Chrome. I had to record a drag in the iOS Simulator and go through it frame by frame. With the bands' masks removed, you can see them stay put for the whole drag:

*[Image: Six frames of a drag with the blur masks off: the middle of the map moves, and the blurred bands above and below stay exactly the same]*

Changing the filter value forces WebKit to sample again, so on every map redraw I change it by an amount too small to see. Nothing extra happens while the map is at rest:

```js
let nudged = false;
viewer.addHandler("update-viewport", () => {
  nudged = !nudged;
  applyTilt(nudged ? 0.0005 : 0);
});
```

*[Image: The same moment of a drag after the fix: the blur lines up with the map]*

*After the fix.*

### Chickens in the river

Five flocks ended up on water, including one in a canal, and another was on top of some trees. A lot of the water here is a dull green. It's as flat as a lawn and greener than it is blue, so it passed my lawn test, and the water mask, which looks for teal and blue, missed it.

*[Image: Eight crops of the map with a flock's points marked as dots: red dots cover open water and a canal, yellow dots lie on lawns and on tree tops]*

*Red dots are on water, yellow dots are on land.*

Saturation turned out to separate them: grass is 0.6 or higher, and that water is 0.4 or lower. Trees can be told apart by hue and roughness. Flocks were also walking in straight lines across whatever lay between two points on a lawn, such as a pond in the middle, so now every pair of a flock's points has to be connected over grass. In a 20-minute simulation, birds spent 6% of their time on water before the fix and none after. I didn't change the water mask, so that dull water still has no ripples.

### The blur that only worked in dev

The tilt-shift worked in development but was missing from every production build in Chrome. Given both `backdrop-filter` and `-webkit-backdrop-filter` in one rule, the CSS minifier kept only the prefixed one, which Chrome ignores. I now write them separately, and the build fails if the unprefixed one goes missing.

### Gridlock

Two cars stuck nose to side in a junction can never get out of each other's way, and everything behind them queues forever. So I started treating junctions as boxes. Nodes where three or more streets meet within 20 m of each other count as one junction, and crossing traffic takes turns. A car only enters if there's room on the other side. A move that would make two cars overlap gets undone, and if two cars have been waiting on each other for 12 seconds, the newer one is removed from the road.

### Cars shaking on viaducts

Looking straight down from a point on a road, the height pass can show several surfaces: a deck, the ground beside it, a tree canopy above. Reading one sample at a time, the height jumped between them, and since each 0.5 m moves a car 1.4 pixels up or down the map, cars shook. Now each road gets a single lowest-cost line through its possible heights, from −8 to 70 m in 0.5 m steps, which stays on the right surface and climbs no faster than 1 in 8. Edges that share a node then agree on its height.

### Ghost cars on slow connections

A car drawn before its foreground tile arrives shows up on top of the trees that should hide it, then vanishes when the tile loads. On a slow connection, that meant ghost cars all over the map during a drag. For the same reason, sharp trees and water floated over a map that was still blurry. Now the animated layers wait for the map's own sharp tile, and a car stays hidden until then.

### What's still broken

The source data is a mosaic, and the model copies its patches. Off the east coast the sea came out split into wedges of different colours, and the forest behind it into a dark piece and a hazy one. I redrew those by holding the rest of the map fixed, cleaning the render the model is shown, and letting it paint only the patch. Other patches remain, such as the bright bay south of the port. On land there are hazy rectangles where the source has no high-resolution 3D model. The source is also years out of date, so finished buildings are still construction sites. And on a slow connection, you see a blurry coarser level until the sharp tiles arrive.

*[Image: The same stretch of coast before and after: wedges of dark and pale water on the left, an even sea on the right]*

*The east coast before and after the repair.*

## Credits

The map is drawn from Google Photorealistic 3D Tiles through Cesium ion, credited to Google Maps, Airbus, and Maxar Technologies. Roads and bird locations are © [OpenStreetMap](https://www.openstreetmap.org/copyright) contributors. The model is Qwen-Image-2.1-Turbo with a style LoRA. Built with Qwen.

You can [open the whole map](https://www.yadongxie.com/lab/singapore) or [jump straight to the traffic by Marina Bay Sands](https://www.yadongxie.com/lab/singapore#17800,11900,1).

This is only a technical exploration, not a product and not a map to rely on. A generative model redrew every building and street, and the traffic and birds are simulated.
