Projection on White Model 白模投影
How a Flattened City Grew Back·40s·Lemo-Opuscarby Lemomo
A pure white massing model of a real place stands on a plinth in a dark room, and a projector paints data onto it as light: one layer at a time, falling on the surfaces that face it, spilling a little past the plate. The model never changes; only the light on it does.
The demo: A projector lights Rotterdam's white model layer by layer: what stood before 1940, the decades that filled the hole, the towers, and the winter shadow the tallest one casts on the pre-war roofs.
A pure white massing model of a real place stands on a plinth in a dark room, and a projector paints data onto it as light: one layer at a time, falling on the surfaces that face it, spilling a little past the plate. The model never changes; only the light on it does. References (grammar only): projection-mapped planning models in city galleries (the dark room, the soft throw, colour only on white surfaces); macro model photography (shallow focus at rooftop height); editorial data graphics (one figure, one label, one source per layer). Copy none of their layouts, typefaces, colours or music, and never name them in the film.
1. Essence, and what it is not
- The model is real geometry of a real place: building footprints and heights from open data (§2), extruded at a stated scale (1:1000, 1:2000…). Nothing on the model is invented or "representative", and no generated image ever stands in for the place.
- The model is white; data is light. Colour is never painted into the material: it is projected, so it brightens surfaces that face the projector, fades on walls turned away, stops at the edge of the throw and reaches a little onto the plinth.
- Layers arrive one at a time, each with its own gesture of light (a wash, a pop-on, a rising level, a moving sun), and each answers the question the last one raised.
- A physical room: house lights, a projector that strikes and switches, a plinth, darkness around it. Lights out is where the data starts; lights up is where it ends.
- Every number on screen is computed from the same buildings that are on the table.
Not Isometric Infographic (no diorama or icons), not Low-poly Island (no invented terrain), not Sci-fi Hologram HUD (no wireframes; light lands on matter), not Satellite Briefing (no imagery), not a laser-scan or point-cloud film (no scan lines; light is projected, not measured).
2. Materials & rendering
- Model: matte white (≈ #F3F3F1, roughness ≈ 0.9), crisp edges; water a darker satin inlay in the base plate; bridges low white slabs; a thin base plate on a dark satin plinth.
- Light model: house light = one soft key + a neutral studio environment + AO on close shots. Projection = a term added to the lit surface in the shader:
colour × (0.42 + 0.58 · max(N·toProjector, 0)) × throw mask, scaled by albedo, reduced on water. Never a texture on the mesh. - Building data is the material. First look for the place's own open 3D or footprint-with-height data: 3DBAG (Netherlands, CC BY 4.0), PLATEAU (Japan, CC BY 4.0-compatible), city open-data portals (footprints with heights or floors, zoning, plans). Fine: CC0, public domain, CC BY (credited in CREDITS and on screen). OpenStreetMap and Overture (ODbL), CC BY-SA and "no known restrictions" are not CC0: name the terms to the user and ask first. For their own site, ask the user for their data (plan, city model, survey heights).
- Layers depend on what exists. Construction years per building are a Dutch luxury (BAG); elsewhere they are often missing. Substitute what the data does hold and the medium can still project: heights or floor counts, footprint area, zoning or plan status, a boundary, sun and shadow (always computable). Never invent a year layer.
- A layer is state, not a picture: per-building attributes (year, height, rank, flags) live in vertex data; the shader decides each frame what colour the projector gives them.
- The sun is a real directional light with shadow maps, computed from the place's latitude and a real date and time (NOAA/Meeus solar position). The projector can trace what the sun does (a shadow footprint) but never fakes a shadow.
- Post: physical depth of field (stronger on close shots), bloom only above the projection highlights, vignette, light grain in ffmpeg. GTAO only on close shots: on wide grazing views it bands.
- Screen space only for the 2D layer: readouts, pins, measured lines and subtitles go through the camera's world → screen function so they stay pinned to the model.
- Keep a FACTS.md in the project folder (claim, source URL, quoted wording, or the measurement method) and deliver it with the film; every number on screen is computed by a script from the same data as the model.
3. Colour logic
- Neutral ground: white model, dark room (near black), dark plinth. Projector black level is a cool near-black on the plate.
- One hue family per layer, chosen for the data: a single categorical accent for "the old/the exception" (e.g. warm amber), a sequential ramp for continuous data (blue → cyan → white for time, violet → magenta → peach for height).
- A colour may return only with the same meaning (the "before" colour reused later marks the same set of buildings).
- The sun is warm, its shade is a projected deep blue; data colours are put away when the sun arrives.
- Never colour the model in the house light; never use more than two layer palettes on screen at once.
4. Type & subtitles
- Roles: a light grotesk for the figure (Inter Tight 200, 120–140 px), a sans for labels in the film's language (Noto Sans SC 300/500, 20–36 px), a monospace for instruments, years and coordinates (IBM Plex Mono, 18–22 px). OFL, self-hosted, CJK subset.
- Readout in the left third, one per layer: index + layer name, one figure, one label, one source line, a legend bar.
- Pins: a hairline from a roof or a ground point up to a tag on a dark plate (title 26 px + one sub line 20 px).
- Identity label top-left, quiet: place, scale, data sources.
- Subtitles centred at the bottom on a soft dark gradient, no box, measured with the real fonts; if the film is bilingual, the film's language on top, each language on a single line, split into two cues rather than wrap.
- Nothing below 18 px at 1080p. Hold subtitles ≥ max(1.8 s, speech + 0.6 s); labels ≥ characters ÷ 4.5 + 1.5 s (CJK), letters ÷ 15 + 1.5 s (Latin).
5. Motion quality
- The camera moves continuously and slowly; it changes speed with the layers, never stops dead.
- Light is the actor. Each layer arrives by a soft-edged gesture of light: a wash spreading from the projector axis, buildings switching on one by one with a short flash, a level climbing every facade, the sun's clock running. No hard line sweeping the table.
- House lights cut fast (a relay, 0.25 s) and return slowly (1 s); the projector strikes with a brief flicker.
- Counters roll while the light moves and land when it lands; measured lines grow from A to B with ticks.
- What never moves: the model, the plinth, the pins' anchors on the model.
6. Camera grammar
| Move | What it expresses | Can serve |
|---|---|---|
| Macro glide at rooftop height, shallow focus | the model is a real object | an opening; a detail that matters |
| Crane up and back | the whole pattern only seen from above | revealing a hole, a cluster, an edge |
| Slow orbit with a rise | the city as a whole, layers accumulating | growth over time; comparisons across the model |
| Boom down to a low angle against black | height, skyline | tall buildings, heights, a single tower |
| Side-on track along a line | a measured distance on the model | a shadow, a corridor, a route, a view line |
| Pull back as the house lights return | the data stops; the object remains | an ending; a reset between topics |
At key moments the subject fills a third of the frame height; readouts left third, subtitles bottom centre, pins never over the readout. Transitions are light changes on one take (lights out, a layer replacing another, the projector going dark), not cuts, fades or wipes.
7. Sound palette
- The room: room tone, the projector fan (hum + filtered air) rising when the lamp strikes, relays for the house lights, a lamp strike (zap + thump), the air of a light wash (sliding band-passed noise).
- Light as sound: mallets and glass for things switching on, bowed vibraphone for held light, a rising mallet line and air riser for a climbing level, one glass note per highlighted building.
- Weight: timpani, gran cassa, gong, low brass for a heavy fact; warm low brass for a turn.
- Floor: contrabass and cellos only as a floor under the chords; no generic piano and strings melody.
- Silence: the projector goes dark and everything stops but a thin fan hum before the turn; a short suck-out before the heaviest hit. The first sound after the silence is the turn.
- Mix: voice about 10 dB over the music; the two designed impacts are the loudest moments; −14 LUFS, true peak ≤ −1.2 dB. Voice: calm, short declarative lines, numbers spoken plainly.
8. Native moves
- Lights out, lamp on. House light, a relay, darkness, the lamp strikes, the first layer arrives. Fits content like: a site before and after; a district before a plan is shown; any "now look closer".
- The hole. Light only the set that answers a question (built before a date, owned by one party, inside a zone) and let the absence draw the shape. Fits content like: a war-damaged centre; a flood plain with no housing; land a company owns; a protected zone.
- Layers by time. Buildings switch on in the decade they were built, each layer faster than the last, a year counter rolling. Fits content like: a new town's growth; a campus master plan by phase; a port's expansion; a developer's portfolio by year.
- The rising level. A soft level of light climbs every facade at once; low buildings finish first, the tallest last, the reading rides up with it. Fits content like: building heights; sea-level or flood depth; floor counts; a height limit being tested.
- The sun clock. A real sun at a real date sweeps the shadows; the projector then traces one shadow and measures it. Fits content like: a new tower's shadow on a park; winter light in a courtyard; solar panels' best roofs; a street's daylight hours.
- The pick-out. One building or set lights alone while the rest stay dark, with a pin. Fits content like: a listed building; the plot for sale; the hospital in an evacuation plan.
- Lights up. The data stops, the white model comes back. An ending or a reset.
9. Pitfalls of the medium
- Wall normals wound the wrong way show inside faces (walls look hollow): derive the outward normal from ring orientation. Shared walls between parts z-fight: skip the overlap below the lower neighbour.
- Warm HDRIs turn the white model beige: use a neutral studio environment.
- GTAO on wide grazing views produces wedge-shaped dark bands; keep it on close shots only.
- A low sun lights facades, not roofs: shoot from the sunlit side or side-on. Narrow shadows vanish among all the others: trace the one that matters (fill + edge) and measure it.
- Hairline scan lines read as laser scanning: give every projection front a wide soft edge or switch buildings individually.
- Floating labels: anchor pins on the tallest part of a building, not on its footprint centroid.
- Numbers from mixed sources (official height vs measured roof): say which on screen ("roof", "measured").
10. Engine
demo/studio.js—makeStudio(W, H, model): renderer, post, room, the projection material (projMaterial(kind)injectsprojLight()/roofGlow()into MeshStandardMaterial), the city mesh (buildCity: outward normals, shared walls removed), plate, plinth, house light (index 0) and sun (index 1). UniformsUdrive every layer.demo/tools/prep.py— 3DBAG + BGT only: it reads 3DBAG fields (b3_h_70p,b3_h_maaiveld,oorspronkelijkbouwjaar) and Dutch RD coordinates (tools/rd.py). Another source needs its own adapter that writes the samemodel.json:{size:[w,d] model metres, b:[[year|0, height_m], …] per building, p:[[building_index, height_model_m, [[[x,z],…] outer ring, …holes]], …] per roof part, decks:[[[x,z],…]]}, with x east, z south, 1 unit = 1 model metre; pluswater.png(plate-sized mask) and astats.jsonwith every on-screen number.tools/sun.py(solar position, any place and date) and the heightfield shadow are source-independent.demo/overlay.js— readout, pin, measured range line, subtitle strip, title card; registers texts for readcheck.- Extra packages and packs: none beyond
setup.sh deps(+voice,musicfor narration and score); downloads are plaincurlfrom the data portal. Reprojecting another country's data may need pyproj or shapely: the agent installs them itself into a private venv in the project folder, not into$LIB/.venv. - Minimal example, not in the demo: set
U.uPreCto a station andU.uPreRgrowing to light everything within walking distance; or put a flood height inU.uHgtFwithU.uHgtBandheld at 1 and leave it still.
11. Variation space
Decide per film: place and scale, which layers in what order, their colours, the opening, the camera path, the ending, the pace.
- Structures: a question answered layer by layer; a plan versus today (the plan's buildings switching on in a second colour); one building's story told by its neighbours.
- Openings: a macro glide over white roofs; darkness and a single pick-out; the plinth from across the room as the house lights fall.
- Endings: lights up on the white model; one last layer left on alone; the camera rising until the model is an object on a table in a dark room.
How our demo was made (story, shots, score, title card, build): DEMO.md. Read it after your treatment exists.
Director's notes for the demo film
One example among many. Don't reuse its story, arc, shots, props or timings.
Demo: How a Flattened City Grew Back 《被炸平的城市怎样长回来》 (40.0 s) · white-model.mp4 · source in demo/
Rotterdam's centre as a 1:1000 white model (2.3 × 2.6 km: 3,887 buildings, 8,860 roof parts, 108 bridge decks). Chinese on screen, English narration, bilingual subtitles (Chinese line above, English line below).
Story & structure
Logline: light picks out everything on the model built before 1940 and finds a hole in the middle; decade by decade the hole fills and the city climbs; then the tallest new tower's midwinter-noon shadow runs 777 m across sixteen roofs, nine of them pre-war.
Arc: the white model under gallery light → lights out, the lamp strikes, the pre-war layer (23 % of the footprint) and the hole → 14 May 1940, 13:27; the city hall that came through almost undamaged → four post-war layers pop on, faster each time, a year counter rolling 1940 → 2025; the city crosses the river (Kop van Zuid) → a level of light climbs every facade and tops out on Zalmhaven I (roof 203 m); 24 of the 25 buildings over 100 m are post-1990 → the projector goes dark (silence) → the winter sun at solar noon (14.6°), the tower's shadow traced and measured (777 m), the sixteen roofs inside it light up, the nine pre-war ones in the first layer's amber → house lights up, white model, end card.
Native moves spent: lights out, lamp on at 1.8–2.4 s; the hole for 1940; layers by time for the growth; the rising level for heights; the sun clock plus a projected shadow trace and a range line as the signature; the pick-out for the roofs; lights up as the bookend.
Every number on screen is computed by demo/tools/prep.py into demo/data/stats.json; every claim and its source is in demo/FACTS.md.
Shots
One continuous take. Camera = target, azimuth (0 = south of the target looking north, 90 = east), elevation, distance in model metres.
| # | Time | Shot | Camera | Native move / note |
|---|---|---|---|---|
| 1 | 0.0–1.8 | white roofs at rooftop height, shallow focus, gallery light, AO | macro glide from the north-east (az 152 → 142, el 7 → 11, d 0.55 → 0.72) | the model as an object |
| 2 | 1.8–2.4 | house lights cut (0.25 s), darkness | glide continues | relay; silence of the room |
| 3 | 2.4–6.0 | the lamp strikes with a flicker; an amber wash spreads from the projector axis over every pre-1940 building | crane up and back (el 11 → 36, d 0.72 → 1.85) | the hole appears as the camera rises; readout 23 % |
| 4 | 6.0–11.6 | 14 May 1940 stamp in the hole (6.0), the city hall pinned (7.8) | slow push and orbit (az 128 → 104, d 1.85 → 1.62) | short suck-out before the date hit |
| 5 | 11.6–17.1 | four post-war layers switch on building by building (1.0 / 0.9 / 0.9 / 0.75 s spreads), year counter, legend rows; Kop van Zuid pinned at 14.6 | orbit to the south-east with a rise (az 104 → 52, d 1.62 → 2.75) | acceleration by spread, not by tempo |
| 6 | 18.0–21.6 | a soft level of light climbs every facade (heights ramp, 25 m rings), the reading rides up Zalmhaven I to 203 m | boom down to a low angle (el 36 → 17, d 1.95) | towers against black |
| 7 | 21.6–22.8 | projector dark | drift | silence |
| 8 | 22.8–27.0 | winter sun at the real azimuth, clock 10:55 → 12:40 (solar noon), data put away, shade projected blue | rise and turn (az 34 → 28, el 24) | first sound after the silence: brass in A♭ |
| 9 | 27.0–35.4 | the tower's noon shadow traced (fill + edge) as a range line grows 0 → 777 m with 100 m ticks; 16 roofs ping on (29.4–31.2), 9 amber | side-on from the south-east, closing in (az 40 → 32, d 1.6 → 1.16) | signature: poster at 30.4 s |
| 10 | 35.4–40.0 | house lights up, data off, end card | pull back and rise (d 1.3 → 2.85, el 35 → 44) | bookend |
Score structure
100 BPM, 4/4; every layer onset, the date, the top of the climb, the sun, the line landing, the roofs and the lights sit on a beat (demo/tools/cuecheck.py: 0 ms on 19 cues).
| Section | Time | Palette | Notes |
|---|---|---|---|
| White | 0–1.8 | bowed vibraphone G4/C5, room tone, idle projector fan | |
| Lamp | 1.8–6.0 | relay, lamp strike, wash air swell; contrabass C2 + cellos G2, bowed vibraphone E♭5, a quiet marimba pulse in 8ths | suck-out 5.55–6.0 |
| Date | 6.0 | timpani C3, gran cassa, gong, bass drum, low brass stab in C minor (tuba, trombones, horns) + a 60–120 Hz punch | loudest moment of the film |
| Aftermath | 6.0–12.0 | contrabass C2, cellos E♭3, bowed vibraphone A♭4 → G4, sparse vibraphone; glass tick on the city-hall pin | |
| Growth | 12.0–18.0 | E♭ major; bass climbs E♭–F–G–A♭ one step per layer; marimba 8ths → 16ths → 16th-note triplets, pizzicato violins from layer 2, hand-chime chord per layer, horns swell from the river crossing; a soft tick per building switching on | |
| Height | 18.0–21.6 | A♭: contrabass A♭1, bowed vibraphone C5/E♭5, glockenspiel + vibraphone line climbing with the level, air riser; tubular bell E♭5 + cymbal on the top | |
| Silence | 21.6–22.8 | thin fan hum only | |
| Sun | 22.8–27.0 | horns A♭3/C4/E♭4, trombones A♭2/E♭3, tuba A♭1, timpani, gran cassa, bowed cymbal; organ (soft) and bowed vibraphone hold | second-loudest moment |
| Shadow | 27.0–31.2 | F minor floor (contrabass, cellos, soft organ), timpani heartbeat every two beats, a click per 100 m, timpani + bell when the line lands; one glass note per roof (G5 pre-war, C6 newer) | |
| Coda / lights | 31.2–40.0 | D♭ → E♭ held; at the lights a C major chord (horns, bowed vibraphone), relays, fan settling; tubular bell C5 and two glass notes on the end card |
Mix (demo/mix.py): music balanced to sit 10 dB under the voice while it speaks and ducked 40 %; hits ducked 20 %, uncompressed; bus compression on music, foley and beds only; a 38 Hz high-pass (46 Hz, 4th order on hits) and a −5 dB shelf below 62 Hz on everything but the voice; master at −14.4 LUFS with a true-peak limiter at −4.6 dBTP; mux.sh measures the film at −13.8 LUFS, LRA 6.1 LU. demo/tools/impactcheck.py: the date is the loudest momentary moment, the sun −0.7 dB under it; each follows a dip of −20 and −28 dB; the silence sits 16 dB under the median program.
Palette & props
- Model #F3F3F1 (roughness 0.88), plate water inlay (0.10, 0.13, 0.17) satin, plinth #17181A; room background a dark radial gradient.
- Layers: pre-1940 #FFA13D; 1940–64 #2A46FF; 1965–89 #2F98FF; 1990–2009 #6FDCFF; 2010–25 #E6FBFF; height ramp #1A1440 → #5A35D6 → #E2509A → #FFE7C4; projected shade #1C2C66; sun #FFD9AD; shadow trace fill (0.10, 0.22, 0.65) with a pale edge; newer shaded roofs #BFF3FF.
- Props: the model (3DBAG LOD1.3, BAG construction years), BGT water and bridge decks, the 2 m heightfield noon shadow of Zalmhaven I, the sun table for 21 Dec 2025 at 51.914° N, 4.484° E.
- Narrator: Kokoro
bm_george, speeds 0.95–1.08. - Real places and buildings appear as data; this is not an official film of the City of Rotterdam or of any building's owner.
End card
Over the white model as the house lights return, a left block: 被炸平的城市怎样长回来 (Noto Sans SC 500, 64 px), How a Flattened City Grew Back (Inter Tight 200), an amber rule, 白模投影 · PROJECTION ON WHITE MODEL, 3DBAG · BAG · BGT · 鹿特丹市档案馆, LemoLab × Claude Opus 5.5. 35.7–40.0 s. The "LemoLab × Claude Opus 5.5" sign-off belongs to this library's demo only; a user's film carries no LemoLab credit and no copy of this card.
Build notes
demo/
build.sh one command: data (if missing) → voice → speech check → cues → events → video → mix → master → .srt → checks → poster
index.html WebGL canvas + 2D overlay canvas, fonts
main.js render(t): camera keys, projection state per layer, overlay, window.EV, SUBCHECK
studio.js renderer, post, room, projection material (projLight / roofGlow), model mesh, plate, plinth, lights
overlay.js readouts, pins, range line, subtitle strip, end card, text registry for readcheck
timeline.js beats, layers, sun clock, narration (single source; tools/export.mjs → timeline.json, voice/lines.json, cues.json)
mix.py score, foley, beds, voice, mix → out/mix.wav + out/stems/
tools/ prep.py (data → model, masks, stats), rd.py (RD ↔ WGS84), sun.py (solar position), fonts.py (CJK subset);
export.mjs (voice/cues); cuecheck.py, subcheck.mjs, motioncheck.py, impactcheck.py, finalasr.py (checks)
data/ model.json, water.png, zshadow.png, stats.json (generated)
assets/raw/ 3DBAG and BGT downloads (gitignored)
FACTS.md, CREDITS, TREATMENT.md, PRODUCTION_LOG.md, stills/styleframe.jpg
sh demo/build.shtakes about 4 minutes on Apple silicon (picture ~60 s with 4 workers; TTS and the Whisper check most of the rest).FETCH=1re-downloads the data (~55 MB).- Checks it runs:
asr_check.pywithsmall.en(British spellings "centre", "metres" set in theasrfields), cue grid, single-line subtitle widths with the real fonts (widest 618 px against 1500 px),readcheck.mjs, motion per second (no flat stretch; the jumps at 2.0, 2.6 and 35.6 s are the lights and the lamp), the impact ranking.tools/finalasr.pychecks every narration line on the final mix. - Demo-specific pitfalls: the 3DBAG WFS returns all LOD1.3 parts in one request at
count=20000for this box (13,345 features); parts are kept only when fully inside the crop, which leaves a clean street margin at the plate edge. The heightfield shadow counts a roof as shaded when its tallest part's centroid cell is under the tower's shadow height (2 m grid); the 16 roofs are listed instats.jsonwith distances 262–734 m. Zalmhaven's 3DBAG roof (203.0 m above ground, 70th percentile of the highest part) matches the published roof height; the 12 m mast is not in the model. GTAO is faded out after the lamp strikes because it bands on the wide grazing views.




