Low-Poly Tokyo: Real Map Data to 3D City
Render a real Shibuya neighborhood as a textured 3D diorama using OpenStreetMap data.
Introduction
30 Second Summary
Every city tells a story through its skyline. Imagine pulling real building data from a map and watching it transform into a tiny, rotatable 3D world right in your browser.
In this project, you will fetch live building footprint data from OpenStreetMap for a neighborhood in Shibuya, Tokyo, convert geographic coordinates into 3D space, and dress each building with procedurally generated pixel-art window textures.
What You'll Build
You will have an interactive 3D diorama of real Tokyo buildings that you can orbit around in the browser, with each building displaying its own unique pixel-art facade generated entirely from code.
By the end of this project, you'll have:
- A rotatable 3D cityscape of a real Shibuya neighborhood where you can orbit around hundreds of buildings laid out from live map data.
- Buildings with procedurally generated facade textures drawn on HTML Canvas, each with a unique pixel-art window grid that stays consistent across reloads.
- A complete data-to-rendering pipeline that fetches roads and parks alongside buildings, giving the diorama dark asphalt ribbons and green patches between the structures.
- Secret Mission: Add a day/night toggle that shifts the sky color and dynamically regenerates building textures with different lit-window percentages.
Are there any prerequisites?
This project assumes basic TypeScript knowledge and some familiarity with Three.js concepts like scenes, meshes, and materials. You should have Node.js installed and a code editor ready to go.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters.
Scaffold the Project and Render a Test Cube
Before you can fill a scene with Tokyo's skyline, you need a working render pipeline. A blank browser tab that draws anything in 3D proves your toolchain is connected end to end.
In this step you will scaffold a TypeScript project with Vite, wire in Three.js, and confirm everything renders by spinning a green test cube in the browser.
In this step, get ready to:
- Scaffold a TypeScript project with Vite and install Three.js.
- Replace the boilerplate with a minimal HTML shell and full-screen CSS.
- Write a Three.js scene with a spinning test cube and orbit controls.
Create the project and install dependencies
Vite gives you a fast dev server with hot-reload out of the box. You will scaffold the project, then add Three.js as a runtime dependency and its type definitions for TypeScript.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Scaffold the project by running this command:
npm create vite@latest low-poly-tokyo -- --template vanilla-ts
What does this command do?
This tells npm to download the create-vite scaffolding tool and run it. The --template vanilla-ts flag picks a plain TypeScript starter with no framework.
If this is your first time running the command, npm will ask Ok to proceed? (y). Type y and press Enter to continue.
- Move into the new project folder and install the base dependencies by running:
cd low-poly-tokyo
npm install
- Install Three.js and its TypeScript type definitions by running:
npm install three
npm install -D @types/three
- Confirm the project files exist by running:
ls
You should see index.html, a src folder, package.json, and a node_modules folder in the output.
Command not found or install failed?
Make sure you are inside the low-poly-tokyo folder before running npm install. If npm itself is not found, confirm Node.js is installed by running node --version.
help me fix my npm install
Replace the boilerplate with a minimal HTML shell
Vite generated demo files you do not need. You will replace them with a minimal HTML page and a full-screen CSS reset so Three.js can fill the entire viewport.
- Open the low-poly-tokyo folder in your code editor.
- Open index.html and replace all of its contents with this code:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Low Poly Tokyo</title>
<link rel="stylesheet" href="/src/style.css" />
</head>
<body>
<!-- Container where Three.js renders the 3D scene -->
<div id="app"></div>
<!-- Loading text shown while data is fetched -->
<div id="loading">Loading Tokyo...</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
What does this code do?
- The #app div is where Three.js will attach its canvas element.
- The #loading div displays a message while map data downloads later.
- The script tag loads src/main.ts as a module so Vite can process it.
- Open src/style.css and replace all of its contents with this code:
/* Reset default browser spacing */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* Make the page fill the entire viewport */
html, body {
height: 100%;
overflow: hidden;
}
/* Three.js canvas container fills all available space */
#app {
width: 100%;
height: 100%;
}
/* Centered loading indicator */
#loading {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-family: monospace;
font-size: 1.5rem;
color: #333;
}
What does this code do?
The reset removes default margins so nothing pushes the canvas away from the edges. Setting overflow: hidden prevents scrollbars from appearing, and #app stretches to fill the screen so the 3D scene gets maximum space.
- Save both index.html and src/style.css.
- Start the dev server by running this command in your terminal:
npx vite
- Open http://localhost:5173 in your browser.
You should see the text "Loading Tokyo..." centered on a white page. That confirms your HTML and CSS are wired up correctly.
Page is blank or shows old Vite demo?
Make sure you saved both files before checking the browser. If you still see the Vite demo, confirm you replaced the contents of index.html at the project root (not inside src/).
help me fix my Vite dev server
Build the Three.js scene
With the dev server running and hot-reloading, any changes you save will appear in the browser automatically. You will now write the Three.js scene that renders a spinning cube.
- Delete all existing code in src/main.ts (the Vite boilerplate) and paste this first block:
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
// Create the 3D scene with a sky-blue background
const scene = new THREE.Scene();
scene.background = new THREE.Color('#87CEEB');
// Set up a perspective camera positioned slightly above and back
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 2000);
camera.position.set(0, 5, 10);
// Initialize the WebGL renderer and attach it to the page
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
document.getElementById('app')!.appendChild(renderer.domElement);
// Add orbit controls for click-and-drag rotation
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
What does this code do?
- The Scene is the container that holds every 3D object. The blue background mimics a daytime sky.
- The PerspectiveCamera defines your viewpoint. It is placed at (0, 5, 10) so you look slightly down at the origin.
- The WebGLRenderer draws the scene to a canvas element that gets injected into the #app div.
- OrbitControls lets you click and drag to rotate the camera around the scene.
- Add the following code directly below what you just pasted in src/main.ts:
// Create a green test cube to verify rendering works
const geometry = new THREE.BoxGeometry();
const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);
// Animation loop: rotate the cube and render each frame
function animate() {
requestAnimationFrame(animate);
cube.rotation.x += 0.01;
cube.rotation.y += 0.01;
controls.update();
renderer.render(scene, camera);
}
animate();
// Handle window resize to keep the scene filling the viewport
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
What does this code do?
- BoxGeometry creates a 1x1x1 cube. MeshBasicMaterial colors it bright green (no lighting needed).
- The animate function runs every frame (~60 fps). Each frame it rotates the cube, updates the orbit controls, and re-renders the scene.
- The resize listener recalculates the camera aspect ratio when you resize the browser window.
- Save src/main.ts.
Before you look at the browser, what do you expect to see? Think about what the code renders and where the camera is pointing.
Check your browser. You should see a spinning green cube on a sky-blue background at http://localhost:5173. Try clicking and dragging to orbit around it.
Don't see the green cube?
Make sure you deleted all the original Vite boilerplate before pasting. Leftover code can cause import conflicts.
Check your terminal for red error text. A typo in the import path three/addons/controls/OrbitControls.js is the most common cause.
help me debug my Three.js scene
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files before moving on.
ⓧ I'd like to double check the full code
Here is what each file should look like at the end of this step.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Low Poly Tokyo</title>
<link rel="stylesheet" href="/src/style.css" />
</head>
<body>
<div id="app"></div>
<div id="loading">Loading Tokyo...</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
overflow: hidden;
}
#app {
width: 100%;
height: 100%;
}
#loading {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-family: monospace;
font-size: 1.5rem;
color: #333;
}
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
const scene = new THREE.Scene();
scene.background = new THREE.Color('#87CEEB');
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 2000);
camera.position.set(0, 5, 10);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
document.getElementById('app')!.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
const geometry = new THREE.BoxGeometry();
const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);
function animate() {
requestAnimationFrame(animate);
cube.rotation.x += 0.01;
cube.rotation.y += 0.01;
controls.update();
renderer.render(scene, camera);
}
animate();
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
Your render pipeline is live. Next up, you will fetch real building data from Tokyo and see it land in this scene.
Fetch Real Building Data from Tokyo
Your scene has a spinning green cube proving Three.js works. But a test cube is not a city. You need real building geometry to populate the diorama, pulled live from the same map data that powers OpenStreetMap.
In this step, you will query the Overpass API for building footprints in a Shibuya, Tokyo neighborhood. You will convert geographic coordinates into 3D space and render a wireframe preview proving the data arrived correctly.
In this step, get ready to:
- Build an Overpass API module that fetches real building data from OpenStreetMap.
- Write a coordinate conversion utility that translates latitude/longitude to local 3D coordinates.
- Render wireframe boxes to verify the building layout matches a real city block.
Write the Overpass API module
The Overpass API is a read-only query interface for OpenStreetMap data. You send it a bounding box and a filter, and it returns every matching map feature with full geometry. No API key required.
- Create a new file called src/overpass.ts in your low-poly-tokyo project folder.
- Add the data interfaces, API constants, and the query helper by pasting this code:
// Shape of a building returned by Overpass
export interface BuildingData {
id: number;
coordinates: { lat: number; lon: number }[];
height: number;
levels: number;
tags: Record<string, string>;
}
// Shape of a road (used in later steps)
export interface RoadData {
id: number;
coordinates: { lat: number; lon: number }[];
type: string;
}
// Shape of a park (used in later steps)
export interface ParkData {
id: number;
coordinates: { lat: number; lon: number }[];
}
// Shibuya bounding box: south,west,north,east
const BBOX = '35.657,139.698,35.662,139.703';
const OVERPASS_URL = 'https://overpass-api.de/api/interpreter';
// Sends a query string to the Overpass API and returns the JSON response
async function queryOverpass(query: string): Promise<any> {
const response = await fetch(OVERPASS_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `data=${encodeURIComponent(query)}`,
});
return response.json();
}
What does this code do?
The three interfaces define the shape of map features you will work with throughout the project. BuildingData holds a building's polygon coordinates, computed height, and OSM tags.
The BBOX constant targets a ~550m x 410m area in central Shibuya. The queryOverpass helper sends a POST request with the query URL-encoded in the body, which is how the Overpass API expects requests.
- Below the queryOverpass function, add the fetchBuildings function that queries for buildings and parses the response:
// Fetches all buildings in the Shibuya bounding box
export async function fetchBuildings(): Promise<BuildingData[]> {
const query = `[out:json][timeout:30];(way["building"](${BBOX}););out geom;`;
const data = await queryOverpass(query);
return data.elements
.filter((el: any) => el.type === 'way' && el.geometry && el.geometry.length >= 3)
.map((el: any) => {
const height = parseFloat(el.tags?.height) || 0;
const levels = parseInt(el.tags?.['building:levels']) || 0;
// Seeded random fallback for buildings missing height data
const seed = el.id % 1000;
return {
id: el.id,
coordinates: el.geometry as { lat: number; lon: number }[],
height: height || levels * 3.5 || (((seed * 16807) % 2147483647) / 2147483647) * 22 + 8,
levels: levels || Math.ceil((height || 15) / 3.5),
tags: el.tags || {},
};
});
}
What does this code do?
The Overpass query [out:json][timeout:30];(way["building"](BBOX););out geom; asks for all building ways in the bounding box, with full geometry included inline. The out geom flag tells Overpass to embed coordinate arrays directly on each element.
Many Tokyo buildings lack height tags. The fallback chain tries the height tag first, then building:levels * 3.5 meters per floor, then generates a seeded pseudo-random value between 8 and 30 meters. Using the OSM ID as seed keeps heights consistent across page reloads.
- Save src/overpass.ts.
✔️ Awesome, I've got everything!
Great. Confirm you have both the interfaces at the top and the fetchBuildings export at the bottom of the file.
ⓧ I'd like to double check the full code
Your src/overpass.ts should contain the interfaces chunk first (BuildingData, RoadData, ParkData, plus the BBOX constant and queryOverpass helper), followed by the fetchBuildings function. Scroll up and confirm both chunks are present in order.
Convert coordinates and render the building preview
Geographic coordinates use latitude and longitude in degrees. Three.js uses a Y-up meter-based coordinate system. You need a conversion function that turns lat/lon into local XYZ positions relative to the center of your bounding box.
- Create a new file called src/geo.ts and paste this coordinate conversion utility:
// Center of the Shibuya bounding box (used as world origin)
const CENTER_LAT = 35.6595;
const CENTER_LON = 139.7005;
// Approximate meters per degree at this latitude
const DEG_TO_M_LAT = 111320;
const DEG_TO_M_LON = 111320 * Math.cos((CENTER_LAT * Math.PI) / 180);
// Converts a lat/lon pair to local X/Z meter coordinates
export function latLonToLocal(lat: number, lon: number): { x: number; z: number } {
return {
x: (lon - CENTER_LON) * DEG_TO_M_LON,
z: -(lat - CENTER_LAT) * DEG_TO_M_LAT,
};
}
Why negate the Z axis?
Three.js uses Y-up with -Z pointing "into" the screen (forward). Latitude increases northward, so negating it maps north to -Z. This keeps the city oriented the same way you would see it on a map when looking from above.
- Save src/geo.ts.
Now you need to wire both modules into your main scene. You will replace the spinning cube with a wireframe building preview.
- Open src/main.ts and replace its entire contents with this scene setup:
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { fetchBuildings } from './overpass';
import { latLonToLocal } from './geo';
const scene = new THREE.Scene();
scene.background = new THREE.Color('#87CEEB');
// Camera pulled back to see the full city from above
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 2000);
camera.position.set(200, 250, 200);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
document.getElementById('app')!.appendChild(renderer.domElement);
// Orbit controls with damping for smooth rotation
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.target.set(0, 20, 0);
What changed from the previous setup?
The camera moved from (0, 5, 10) to (200, 250, 200) so it can see buildings spread across hundreds of meters. The green test cube is gone. The controls target is set to (0, 20, 0) so you orbit around building height.
- Below the controls setup, add the init function that fetches buildings and renders them as wireframe boxes:
// Fetch building data and render wireframe preview boxes
async function init() {
const buildings = await fetchBuildings();
console.log(`Fetched ${buildings.length} buildings`);
for (const building of buildings) {
// Compute bounding box from polygon coordinates
let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity;
for (const coord of building.coordinates) {
const local = latLonToLocal(coord.lat, coord.lon);
minX = Math.min(minX, local.x); maxX = Math.max(maxX, local.x);
minZ = Math.min(minZ, local.z); maxZ = Math.max(maxZ, local.z);
}
const w = maxX - minX, d = maxZ - minZ;
if (w < 2 || d < 2) continue;
// Create a white wireframe box at the building's position
const geo = new THREE.BoxGeometry(w, 5, d);
const mat = new THREE.MeshBasicMaterial({ color: 0xffffff, wireframe: true });
const mesh = new THREE.Mesh(geo, mat);
mesh.position.set((minX + maxX) / 2, 2.5, (minZ + maxZ) / 2);
scene.add(mesh);
}
document.getElementById('loading')!.style.display = 'none';
}
init();
What does this code do?
For each building, the loop converts every polygon vertex to local meters, finds the bounding box to get width and depth, then creates a BoxGeometry at the center point. Buildings smaller than 2 meters in either dimension are skipped (they are likely map artifacts).
All boxes use a fixed height of 5 meters and wireframe rendering. This is a quick sanity check that the data pipeline works before adding real heights and textures in later steps.
- Finally, add the animation loop and resize handler at the bottom of the file:
function animate() {
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
}
animate();
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
- Save src/main.ts.
✔️ Awesome, I've got everything!
Great. Confirm all three chunks are present in order: imports and scene setup at the top, the init function in the middle, and the animate loop plus resize handler at the bottom.
ⓧ I'd like to double check the full code
Your src/main.ts should contain these three sections in order. Scroll up and confirm each chunk is present:
- Imports + scene/camera/renderer/controls setup (the first code block).
- The async function init() block with the building loop, plus the init() call (the second code block).
- The animate function and resize listener (the third code block).
Before you check the browser, what do you expect to see? The Overpass API takes a few seconds to respond. Think about what the loading text and then the wireframe preview should look like.
- Switch to your browser at http://localhost:5173. The page should auto-refresh from Vite's hot reload.
- Open the browser console (Cmd+Option+J on macOS or Ctrl+Shift+J on Windows).
You should see a log line like Fetched 287 buildings (the exact number varies between 200-400). In the viewport, small white wireframe boxes appear scattered across the scene in a pattern that matches a real city block layout. The "Loading Tokyo..." text disappears once the data arrives.
Not seeing any wireframe boxes?
- Check the console for network errors. The Overpass API is free but rate-limited. If you see a timeout or 429 error, wait 30 seconds and refresh.
- Confirm your BBOX string in src/overpass.ts is exactly '35.657,139.698,35.662,139.703' with no spaces.
- If you see boxes but they are tiny specks, check that camera.position.set(200, 250, 200) is correct in src/main.ts. The old position (0, 5, 10) is far too close for a city-scale scene.
Help me debug why my Overpass API fetch is not returning building data.
What is the Overpass API?
The Overpass API is a public read-only query service for OpenStreetMap data. It lets you filter by geographic bounding box, feature type, and tags. The endpoint at https://overpass-api.de/api/interpreter is free with no API key required.
You now have a working pipeline from live map data to 3D geometry. Next up, you will replace these flat wireframe boxes with solid buildings that have correct heights, turning the preview into a real skyline.
Extrude Buildings into Solid 3D Boxes
Your wireframe preview proves the data pipeline works. Building outlines are appearing in roughly the right positions, which confirms coordinates convert correctly.
The problem is it does not look like a city. Every box is the same height, rendered as transparent wireframes. In this step, you will replace the flat wireframes with solid geometry that uses real building heights from the OSM data. You will also add lighting so the solid surfaces are actually visible.
In this step, get ready to:
- Create a dedicated city builder module that converts building data into solid 3D meshes.
- Add directional and ambient lighting so solid surfaces render with depth.
- Replace the wireframe preview with varying-height grey boxes that form a skyline.
Create the city builder module
Right now, the building loop lives inline inside main.ts. That is fine for a quick wireframe test, but solid buildings need more logic. Extracting it into a dedicated module keeps things clean.
- Create a new file called src/city.ts in your code editor.
- Add the imports and the buildBuildings function by pasting this code:
import * as THREE from 'three';
import { BuildingData } from './overpass';
import { latLonToLocal } from './geo';
// Takes all building data and returns a Group of solid meshes
export function buildBuildings(buildings: BuildingData[]): THREE.Group {
const group = new THREE.Group();
for (const building of buildings) {
const mesh = createBuildingMesh(building);
if (mesh) group.add(mesh);
}
return group;
}
What does this code do?
The buildBuildings function loops over every building from the Overpass data and tries to create a mesh for each one. It collects successful meshes into a THREE.Group so you can add the entire city to the scene in one call.
Buildings that are too small or lack coordinates get filtered out by createBuildingMesh returning null.
- Directly below that code in the same file, add the createBuildingMesh function:
// Converts one building's data into a positioned solid box
function createBuildingMesh(building: BuildingData): THREE.Mesh | null {
if (building.coordinates.length < 3) return null;
let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity;
for (const coord of building.coordinates) {
const local = latLonToLocal(coord.lat, coord.lon);
minX = Math.min(minX, local.x); maxX = Math.max(maxX, local.x);
minZ = Math.min(minZ, local.z); maxZ = Math.max(maxZ, local.z);
}
const width = maxX - minX;
const depth = maxZ - minZ;
const height = building.height;
if (width < 2 || depth < 2) return null;
const centerX = (minX + maxX) / 2;
const centerZ = (minZ + maxZ) / 2;
// Create a box sized to the building's footprint and height
const geometry = new THREE.BoxGeometry(width, height, depth);
const material = new THREE.MeshLambertMaterial({ color: '#888888' });
const mesh = new THREE.Mesh(geometry, material);
// Position the box so its base sits on the ground
mesh.position.set(centerX, height / 2, centerZ);
return mesh;
}
What does this code do?
This function computes each building's bounding box from its lat/lon coordinates (reusing the same latLonToLocal converter from the previous step). The bounding box gives us width and depth.
The height comes straight from the OSM data you already parsed. It uses the real height tag, falls back to building:levels * 3.5 meters, or generates a seeded random value between 8 and 30 meters.
The material is MeshLambertMaterial instead of MeshBasicMaterial. Lambert materials respond to light, which means faces angled toward a light appear brighter and faces turned away appear darker. This gives the boxes a sense of 3D depth.
- Save src/city.ts.
- Confirm the file exists by running:
ls src/city.ts
You should see src/city.ts printed in the output.
File not found?
Make sure you created the file inside the src/ folder, not in the project root. The path must be src/city.ts.
If you see a different filename, double check the spelling. It must be city.ts (lowercase, TypeScript extension).
help me find where my city.ts file went
Add lighting and replace the wireframe preview
The MeshLambertMaterial you just used requires light sources in the scene. Without them, every building would render as pure black. You also need to swap out the old wireframe loop for a call to your new buildBuildings function.
- Select all the content in src/main.ts and delete it.
- Paste this first section, which sets up imports, the scene, camera, renderer, controls, and lights:
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { fetchBuildings } from './overpass';
import { buildBuildings } from './city';
const scene = new THREE.Scene();
scene.background = new THREE.Color('#87CEEB');
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 2000);
camera.position.set(200, 250, 200);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
document.getElementById('app')!.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.target.set(0, 20, 0);
// Prevent the camera from going below the ground
controls.maxPolarAngle = Math.PI / 2.1;
// Hemisphere light provides soft ambient fill (sky + ground colors)
const ambientLight = new THREE.HemisphereLight('#87CEEB', '#556B2F', 0.6);
scene.add(ambientLight);
// Directional light simulates sunlight from above
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
directionalLight.position.set(100, 200, 50);
scene.add(directionalLight);
What does this code do?
The imports now pull in buildBuildings from your new module instead of latLonToLocal (the city module handles coordinate conversion internally).
The HemisphereLight casts a soft gradient from sky blue above to olive green below, simulating ambient outdoor light. The DirectionalLight acts like the sun, casting parallel rays that create visible shading differences between building faces.
The maxPolarAngle on controls stops the camera from orbiting below the ground plane, which would break the diorama illusion.
- Directly below the code you just pasted, add the ground plane, init logic, and animation loop:
// Dark ground plane beneath the buildings
const groundGeometry = new THREE.PlaneGeometry(600, 600);
const groundMaterial = new THREE.MeshLambertMaterial({ color: '#3D3D3D' });
const ground = new THREE.Mesh(groundGeometry, groundMaterial);
ground.rotation.x = -Math.PI / 2;
ground.position.y = -0.1;
scene.add(ground);
async function init() {
const buildings = await fetchBuildings();
const buildingGroup = buildBuildings(buildings);
scene.add(buildingGroup);
document.getElementById('loading')!.style.display = 'none';
console.log(`Loaded ${buildings.length} buildings`);
}
init();
function animate() {
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
}
animate();
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
What does this code do?
The ground plane is a large flat square rotated to face upward. It sits at y = -0.1 so buildings appear to rest on it rather than float.
The init() function is now much simpler. It fetches buildings, passes them to buildBuildings, and adds the resulting group to the scene. All the bounding-box math now lives in city.ts where it belongs.
Before you check the browser, what do you think the scene will look like now that buildings have real heights and solid grey faces?
- Save src/main.ts.
- Switch to your browser. Vite hot-reloads automatically.
You should see solid grey boxes of varying heights forming a recognizable city skyline. The buildings are all a single flat grey color, but you can see shading differences between faces thanks to the directional light.
Buildings are black or invisible?
If buildings appear solid black, the lighting code is missing or the HemisphereLight and DirectionalLight were not added to the scene with scene.add(). Double check both lines are present.
If you see nothing at all, open the browser console and look for import errors. The import path must be './city' (no .ts extension). Also confirm city.ts exports buildBuildings with the export keyword.
my buildings are not showing up after adding lighting and city.ts
✔️ Awesome, I've got everything!
Great. Double check you have saved both src/city.ts and src/main.ts.
ⓧ I'd like to double check the full code
Scroll up and confirm your files contain each of these blocks, in order:
- src/city.ts: imports (THREE, BuildingData, latLonToLocal), then buildBuildings function, then createBuildingMesh function.
- src/main.ts: imports (THREE, OrbitControls, fetchBuildings, buildBuildings), scene/camera/renderer setup, controls with maxPolarAngle, hemisphere light, directional light, ground plane, init() calling buildBuildings, animate loop, resize handler.
Your city now has real shape. Buildings rise to different heights and the directional light reveals which face is which. Next up, you will dress these grey boxes in procedurally generated pixel-art facade textures to bring the low-poly art style to life.
Generate Procedural Facade Textures
Your city skyline proves the data pipeline works. Buildings rise at the correct heights, and the layout matches a real Shibuya neighborhood. But every building is the same flat grey slab.
Real cities have texture. Brick facades, concrete walls, and rows of glowing windows define the character of a building. In this step, you will procedurally generate pixel-art facade textures using the HTML Canvas API and apply them to each building as unique per-face materials.
In this step, get ready to:
- Build a procedural texture generator that draws pixel-art building facades on a Canvas element.
- Apply unique textures to each building face using a 6-material array per BoxGeometry.
- Use seeded randomness so every building looks unique but stays consistent across reloads.
Create the facade texture generator
Each building needs a facade that looks like a real wall with windows. Instead of loading image files, you will draw textures at runtime using a tiny Canvas. A color palette picks the wall color, and a seeded random number generator decides which windows are lit.
- Create a new file src/textures.ts and add the color palette and canvas setup by pasting this code:
import * as THREE from 'three';
// Color palettes for building faces, windows, and roofs
const PALETTE = {
faces: ['#C75B3A', '#8B6F47', '#D4A574', '#A0522D', '#708090', '#B8860B', '#696969', '#CD853F'],
windowLit: ['#FFFACD', '#FFE4B5', '#FFEFD5'],
windowOff: '#2C2C2C',
roof: ['#5C4033', '#4A4A4A', '#6B4423', '#3D3D3D', '#8B4513'],
};
export function createFacadeTexture(
floors: number,
windowsPerFloor: number,
seed: number,
litPercentage: number = 0.6
): THREE.CanvasTexture {
// Window size, gap between windows, and padding from edge
const WS = 6;
const GAP = 2;
const PAD = 3;
// Calculate canvas dimensions based on window grid
const w = PAD * 2 + windowsPerFloor * WS + Math.max(0, windowsPerFloor - 1) * GAP;
const h = PAD * 2 + floors * WS + Math.max(0, floors - 1) * GAP;
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d')!;
What does this code do?
The PALETTE object stores arrays of hex colors for building walls (brick reds, concrete greys, tans), lit windows (warm yellows), dark windows, and roof tones.
The createFacadeTexture function accepts the number of floors, windows per floor, a seed for randomness, and a lit percentage. It calculates the exact canvas size needed to fit a grid of 6x6 pixel window blocks with 2px gaps.
- Add the window-drawing loop, texture creation, and the roof color helper directly below the code you just pasted in src/textures.ts:
// Fill background with a wall color picked by seed
ctx.fillStyle = PALETTE.faces[Math.abs(seed) % PALETTE.faces.length];
ctx.fillRect(0, 0, w, h);
// Seeded pseudo-random number generator for deterministic results
let s = Math.abs(seed);
const rand = () => {
s = (s * 16807) % 2147483647;
return (s - 1) / 2147483646;
};
// Draw window grid - each window is lit or dark based on random roll
for (let r = 0; r < floors; r++) {
for (let c = 0; c < windowsPerFloor; c++) {
const x = PAD + c * (WS + GAP);
const y = PAD + r * (WS + GAP);
if (rand() < litPercentage) {
ctx.fillStyle = PALETTE.windowLit[Math.floor(rand() * PALETTE.windowLit.length)];
} else {
ctx.fillStyle = PALETTE.windowOff;
}
ctx.fillRect(x, y, WS, WS);
}
}
// Convert canvas to Three.js texture with NearestFilter for pixel-art crispness
const tex = new THREE.CanvasTexture(canvas);
tex.magFilter = THREE.NearestFilter;
tex.minFilter = THREE.NearestFilter;
tex.colorSpace = THREE.SRGBColorSpace;
return tex;
}
Why NearestFilter?
The default texture filtering in Three.js smooths pixels together when the texture is magnified. That would blur your 6x6 pixel windows into an indistinct smear. NearestFilter preserves hard pixel edges at any zoom level, giving the low-poly pixel-art look.
- Finally, add the getRoofColor helper at the very bottom of src/textures.ts, after the closing brace of createFacadeTexture:
export function getRoofColor(seed: number): string {
return PALETTE.roof[Math.abs(seed) % PALETTE.roof.length];
}
- Save src/textures.ts.
- Check the terminal where your Vite dev server is running. You should see no TypeScript errors.
Seeing a TypeScript error?
Make sure the closing brace } of createFacadeTexture comes before the getRoofColor function. The two functions sit at the same level, not nested.
help me fix a TypeScript error in my textures.ts file
✔️ Awesome, I've got everything!
Great. Make sure you have saved src/textures.ts before moving on.
ⓧ I'd like to double check the full code
Your src/textures.ts should contain these sections in order:
- The import * as THREE from 'three' statement at the top.
- The PALETTE object with faces, windowLit, windowOff, and roof arrays.
- The exported createFacadeTexture function (canvas setup, background fill, seeded random, window loop, NearestFilter texture return).
- The exported getRoofColor function that picks a roof color from the palette.
Apply textures to each building
The texture generator is ready, but nothing uses it yet. You need to update city.ts to import the new functions and replace the single grey material with a 6-material array that gives each face of the box a different texture.
- In src/city.ts, find the import lines at the top of the file:
import * as THREE from 'three';
import { BuildingData } from './overpass';
import { latLonToLocal } from './geo';
- Add the texture imports so your import section looks like this:
import * as THREE from 'three';
import { BuildingData } from './overpass';
import { createFacadeTexture, getRoofColor } from './textures';
import { latLonToLocal } from './geo';
- Now find these two lines inside the createBuildingMesh function, right after the BoxGeometry line:
const material = new THREE.MeshLambertMaterial({ color: '#888888' });
const mesh = new THREE.Mesh(geometry, material);
- Replace those two lines with the following texture and material setup:
// Use building's OSM ID as seed for deterministic randomness
const seed = building.id;
const floors = Math.max(2, Math.round(height / 3.5));
const windowsFront = Math.max(2, Math.round(width / 4));
const windowsSide = Math.max(2, Math.round(depth / 4));
// Generate unique textures for front and side faces
const frontTex = createFacadeTexture(floors, windowsFront, seed);
const sideTex = createFacadeTexture(floors, windowsSide, seed + 7919);
const roofColor = getRoofColor(seed);
// One material per face: sides, roof top/bottom, front/back
const frontMat = new THREE.MeshLambertMaterial({ map: frontTex });
const sideMat = new THREE.MeshLambertMaterial({ map: sideTex });
const roofMat = new THREE.MeshLambertMaterial({ color: roofColor });
// BoxGeometry face order: +X, -X, +Y, -Y, +Z, -Z
const materials = [sideMat, sideMat, roofMat, roofMat, frontMat, frontMat];
const mesh = new THREE.Mesh(geometry, materials);
What does the 6-material array do?
A BoxGeometry has 6 faces, and Three.js lets you pass an array of 6 materials to assign one per face. The order is: right (+X), left (-X), top (+Y), bottom (-Y), front (+Z), back (-Z).
Left and right sides share one texture, front and back share another, and top and bottom get a flat roof color. The offset seed + 7919 ensures the side texture differs from the front texture even though both are generated from the same building ID.
Before you check the browser, what do you think will happen to the grey boxes?
- Save src/city.ts.
- Switch to your browser at http://localhost:5173. You should see every building now dressed in a unique pixel-art facade. Each building has a colored wall (brick reds, greys, tans), a grid of lit and dark windows, and a distinct roof color.
The scene looks dramatically different from the plain grey boxes. Orbit around to see that front, side, and roof faces all carry different colors and patterns.
Buildings still look grey or blank?
Check that the import in city.ts matches exactly: import { createFacadeTexture, getRoofColor } from './textures'. A typo in the function name or file path means the old grey material is still being used.
Also make sure you removed the old const material = new THREE.MeshLambertMaterial({ color: '#888888' }) line entirely. If both the old material and the new materials array exist, you may get unexpected results.
help me debug why my buildings still look grey after adding textures
✔️ Awesome, I've got everything!
Great. Confirm you have saved both src/textures.ts and src/city.ts.
ⓧ I'd like to double check the full code
Your src/city.ts should contain these sections in order:
- Four import lines: THREE, BuildingData, createFacadeTexture and getRoofColor from textures, and latLonToLocal from geo.
- The buildBuildings function that loops over buildings and calls createBuildingMesh.
- The createBuildingMesh function with bounding box calculation, then seed/floors/windowsFront/windowsSide calculations, texture generation, and the 6-element materials array.
Your Shibuya neighborhood has transformed from grey slabs into a colorful pixel-art cityscape. Next up, you will add roads and parks between the buildings to complete the diorama.
Add Roads, Parks, and Scene Polish
Your textured buildings look impressive, but they're floating in a flat grey void. Real cities have roads threading between buildings and green spaces that break up the concrete.
In this step, you'll extend the Overpass API pipeline to also fetch highways and parks, then render them as flat colored surfaces between buildings to complete the diorama.
In this step, get ready to:
- Add road and park data fetchers to the Overpass module.
- Create an environment rendering module for roads and parks.
- Wire everything into the main scene for a complete cityscape.
Add road and park data fetchers
The RoadData and ParkData interfaces already exist in overpass.ts from when you set up the file. Now you need the functions that actually query the Overpass API for highway and park data.
- In src/overpass.ts, add the fetchRoads function below the existing fetchBuildings function:
export async function fetchRoads(): Promise<RoadData[]> {
const query = `[out:json][timeout:30];(way["highway"](${BBOX}););out geom;`;
const data = await queryOverpass(query);
return data.elements
.filter((el: any) => el.type === 'way' && el.geometry && el.geometry.length >= 2)
.map((el: any) => ({
id: el.id,
coordinates: el.geometry as { lat: number; lon: number }[],
type: el.tags?.highway || 'residential',
}));
}
What does this code do?
This queries all ways tagged with highway inside the Shibuya bounding box. The filter requires at least 2 coordinate points (a road needs a start and end). Each road also captures its type (primary, secondary, residential) for controlling road width later.
- Directly below fetchRoads, add the fetchParks function:
export async function fetchParks(): Promise<ParkData[]> {
const query = `[out:json][timeout:30];(way["leisure"="park"](${BBOX});way["landuse"="grass"](${BBOX}););out geom;`;
const data = await queryOverpass(query);
return data.elements
.filter((el: any) => el.type === 'way' && el.geometry && el.geometry.length >= 3)
.map((el: any) => ({
id: el.id,
coordinates: el.geometry as { lat: number; lon: number }[],
}));
}
What does this code do?
This queries two types of green areas: ways tagged leisure=park and ways tagged landuse=grass. Parks need at least 3 coordinate points to form a polygon shape.
- Save src/overpass.ts.
- Check the terminal where Vite is running. You should see no red error messages.
Seeing a TypeScript error?
Make sure both functions are placed after the closing brace of fetchBuildings, not inside it. Each function should be at the top level of the file.
Help me debug a TypeScript error in my overpass.ts file
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before moving on.
ⓧ I'd like to double check the full code
Your src/overpass.ts file is over 30 lines now, so confirm these sections are present in order:
- The three interfaces (BuildingData, RoadData, ParkData) at the top.
- The BBOX and OVERPASS_URL constants and the queryOverpass helper.
- fetchBuildings (from a previous step).
- fetchRoads (just added).
- fetchParks (just added).
Create the environment rendering module
Roads and parks are flat geometry that sits between buildings. You'll create a dedicated module that converts the coordinate data into visible surfaces.
- Create a new file src/environment.ts and add the imports and buildRoads function:
import * as THREE from 'three';
import { RoadData, ParkData } from './overpass';
import { latLonToLocal } from './geo';
export function buildRoads(roads: RoadData[]): THREE.Group {
const group = new THREE.Group();
const material = new THREE.MeshLambertMaterial({ color: '#2C2C2C' });
for (const road of roads) {
if (road.coordinates.length < 2) continue;
const points = road.coordinates.map(c => latLonToLocal(c.lat, c.lon));
const width = road.type === 'primary' || road.type === 'secondary' ? 8 : 4;
for (let i = 0; i < points.length - 1; i++) {
const start = points[i];
const end = points[i + 1];
const dx = end.x - start.x;
const dz = end.z - start.z;
const length = Math.sqrt(dx * dx + dz * dz);
if (length < 1) continue;
const segGeo = new THREE.PlaneGeometry(length, width);
const segMesh = new THREE.Mesh(segGeo, material);
segMesh.rotation.x = -Math.PI / 2;
segMesh.position.set((start.x + end.x) / 2, 0.05, (start.z + end.z) / 2);
segMesh.rotation.z = -Math.atan2(dz, dx);
group.add(segMesh);
}
}
return group;
}
What does this code do?
Each road is a sequence of coordinates. The function walks through consecutive pairs and creates a flat PlaneGeometry strip for each segment. The plane is rotated flat (X-axis rotation) and then rotated on the Z-axis using atan2 to align with the road's direction.
Primary and secondary roads get a width of 8 meters. Smaller residential streets get 4 meters. The Y position of 0.05 places them just above the ground plane to avoid z-fighting.
- Below buildRoads in the same file, add the buildParks function:
export function buildParks(parks: ParkData[]): THREE.Group {
const group = new THREE.Group();
const material = new THREE.MeshLambertMaterial({ color: '#4A7C3F' });
for (const park of parks) {
if (park.coordinates.length < 3) continue;
const points = park.coordinates.map(c => latLonToLocal(c.lat, c.lon));
const shape = new THREE.Shape();
shape.moveTo(points[0].x, points[0].z);
for (let i = 1; i < points.length; i++) {
shape.lineTo(points[i].x, points[i].z);
}
shape.closePath();
const geometry = new THREE.ShapeGeometry(shape);
const mesh = new THREE.Mesh(geometry, material);
mesh.rotation.x = -Math.PI / 2;
mesh.position.y = 0.1;
group.add(mesh);
}
return group;
}
What does this code do?
Parks use THREE.Shape to trace the polygon outline from the coordinate points, then ShapeGeometry fills it in. The mesh is rotated flat and placed at Y 0.1, slightly above roads, so grass patches always render on top of road surfaces.
- Save src/environment.ts.
- Check the Vite terminal. You should see no red error output.
Seeing an import error?
Confirm the import paths match exactly: './overpass' and './geo'. The file must be in the src/ folder alongside those modules.
Help me fix an import error in environment.ts
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before moving on.
ⓧ I'd like to double check the full code
Your src/environment.ts file is over 30 lines. Confirm these sections are present in order:
- Three import lines at the top (THREE, types from ./overpass, and latLonToLocal from ./geo).
- The buildRoads function with the segment loop.
- The buildParks function with THREE.Shape and ShapeGeometry.
Wire roads and parks into the main scene
The data fetchers and renderers are ready. Now you need to connect them in main.ts so the scene actually loads and displays roads and parks alongside the buildings.
- In src/main.ts, find the import lines at the very top of the file:
import { fetchBuildings } from './overpass';
import { buildBuildings } from './city';
- Replace those two lines with these three lines:
import { fetchBuildings, fetchRoads, fetchParks } from './overpass';
import { buildBuildings } from './city';
import { buildRoads, buildParks } from './environment';
What changed?
You added fetchRoads and fetchParks to the overpass import. You also added a brand new import pulling buildRoads and buildParks from the environment module you just created.
- Now find the existing init function in main.ts:
async function init() {
const buildings = await fetchBuildings();
const buildingGroup = buildBuildings(buildings);
scene.add(buildingGroup);
document.getElementById('loading')!.style.display = 'none';
console.log(`Loaded ${buildings.length} buildings`);
}
- Replace the entire init function with this updated version that fetches all three data types in parallel:
async function init() {
// Fetch buildings, roads, and parks in parallel
const [buildings, roads, parks] = await Promise.all([
fetchBuildings(),
fetchRoads(),
fetchParks(),
]);
// Render buildings with textured facades
const buildingGroup = buildBuildings(buildings);
scene.add(buildingGroup);
// Render road segments as dark strips
const roadGroup = buildRoads(roads);
scene.add(roadGroup);
// Render parks as green polygons
const parkGroup = buildParks(parks);
scene.add(parkGroup);
document.getElementById('loading')!.style.display = 'none';
console.log(`Loaded ${buildings.length} buildings, ${roads.length} roads, ${parks.length} parks`);
}
Why Promise.all?
Promise.all fires all three Overpass queries simultaneously instead of waiting for each one to finish before starting the next. This reduces loading time from roughly 6-9 seconds (sequential) to 2-3 seconds (parallel).
- Save src/main.ts.
Before you check the browser, what do you expect to see alongside the textured buildings?
- Switch to your browser at http://localhost:5173. The page should reload automatically.
You should see dark grey road ribbons weaving between buildings, green patches where parks exist, and the sky-blue background framing the whole diorama. The scene looks like a finished miniature city.
- Open the browser console (Cmd+Option+J on macOS or Ctrl+Shift+J on Windows). You should see a log message showing the count of buildings, roads, and parks loaded.
- Try orbiting the scene by clicking and dragging. The camera should rotate smoothly around the city with inertia from the damping.
Scene looks the same as before?
Check the console for errors. A common issue is a typo in the import path for ./environment. Make sure the file is named exactly environment.ts inside the src/ folder.
If you see roads and buildings but no parks, that's normal for some areas. The Shibuya bounding box may only contain a handful of park polygons.
Help me debug why roads and parks aren't appearing in my Three.js scene
✔️ Awesome, I've got everything!
Great. Double check you've saved all your files before moving on.
ⓧ I'd like to double check the full code
Your src/main.ts is over 30 lines. Confirm these sections are present in order:
- Five import lines at the top (THREE, OrbitControls, overpass with all three fetchers, city, environment).
- Scene, camera, renderer, controls, lights, and ground setup (unchanged from before).
- The updated init function using Promise.all and adding buildingGroup, roadGroup, and parkGroup to the scene.
- The animate loop and resize listener (unchanged from before).
Your diorama is complete. You've gone from raw OpenStreetMap data to a fully rendered 3D miniature city with textured buildings, road networks, and green spaces. Next up, a secret mission that adds dynamic lighting to bring the city to life at night.
Secret mission
Add a Day/Night Toggle
Your city looks great in daylight, but real cities come alive at night. Can you add a toggle that switches the entire scene between day and night, regenerating every building texture so 90% of the windows glow warm yellow against a dark sky?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. The Overpass API is free and requires no API key. Decide whether to keep your project files, pause the dev server, or delete everything.
Resources you used:
- The low-poly-tokyo project folder on your Desktop (includes all source files and node_modules).
- A local Vite dev server (running in your terminal).
Keep everything running
No action needed. Choose this if you are still actively building or want to keep experimenting with the scene.
- Your dev server stays running at http://localhost:5173. You can restart it any time with npx vite from the project folder.
- The project uses no paid services and incurs zero cost while sitting on your machine.
Pause - I'll come back to this later
Shut down the running dev server to free up your terminal. Your files stay on disk at no cost.
- Switch to the terminal where Vite is running.
- Press Ctrl+C to stop the dev server.
- When you are ready to resume, open a terminal in the low-poly-tokyo folder and run npx vite again.
Delete - I don't want to use this again
Remove the entire project folder. There are no cloud resources or accounts to clean up.
- Stop the dev server first by pressing Ctrl+C in the terminal where Vite is running.
- Delete the project folder by running this command from your Desktop:
cd ~/Desktop
rm -rf low-poly-tokyo
On Windows, use this instead:
cd ~\Desktop
Remove-Item -Recurse -Force low-poly-tokyo
- Confirm it is gone by running ls (macOS/Linux) or dir (Windows). The low-poly-tokyo folder should no longer appear.
That's a Wrap!
That's a Wrap!
Nice work. You just built a 3D city diorama of a real Shibuya, Tokyo neighborhood from live map data, procedural textures, and nothing but code.
You've learned how to:
- Fetch real-world building data from OpenStreetMap's Overpass API and convert geographic coordinates into a local 3D coordinate system.
- Extrude 2D building footprints into solid 3D box geometry with realistic heights derived from OSM tags, and render roads and parks from the same data source.
- Procedurally generate pixel-art facade textures on HTML Canvas and apply them as per-face materials using Three.js CanvasTextures with NearestFilter for crisp pixel edges.
- Secret Mission: Added a day/night toggle that dynamically regenerates all building textures with adjusted lit percentages and swaps scene lighting at runtime.
Ready to quiz yourself?