Flying Home Through a Photorealistic World: What Building a Kuwait-to-Pennsylvania Fly-Through Taught Me About CesiumJS and Google's 3D Tiles

After months of living under air-raid sirens in Kuwait, my family made the long trip to our new home in State College, Pennsylvania: by road into Saudi Arabia, then through Dammam, Doha, and JFK. I rebuilt that journey as a cinematic, browser-based fly-through using CesiumJS and Google Photorealistic 3D Tiles. The first version took about a week. Making it survive a full recording took months. This post covers the lessons from those months: memory, tile streaming, coverage gaps, API quotas and the craft of recording it all.

SCIENTIFIC INSIGHTS INTO AI AND GEOSPATIAL TECHNOLOGIES

Scott Pezanowski

11/5/202614 min read

Glowing route across a dark globe from the Persian Gulf to Pennsylvania, with 3D map tiles lighting up near the destination and drifting away behind it.
Glowing route across a dark globe from the Persian Gulf to Pennsylvania, with 3D map tiles lighting up near the destination and drifting away behind it.

Why I Built This

Let me start with the part that isn't about code.

For many months, my family lived in Kuwait with a kind of background fear that never really switched off.

Bomb sirens went off regularly. Occasionally we heard bombs land in the distance. Many nights we were woken by the sound of missiles being intercepted overhead.

Every time I drove to work and entered the city on Road 40, known locally as King Fahad Road, I passed Kuwait's social security building, damaged by a direct strike. You don't get used to that. You just learn to keep driving.

The airport was hit many times. Travel was restricted, flights were diverted, and when it was finally time to leave, our flight out of Kuwait was canceled. So we did what many families did: we drove. Across the border into Saudi Arabia, down to Dammam, and out through Dammam's airport.

Left, a photo through a van window of the Kuwait border checkpoint. Right, the same crossing in the fly-through, shown as aerial imagery with the route line and a car icon.
Left, a photo through a van window of the Kuwait border checkpoint. Right, the same crossing in the fly-through, shown as aerial imagery with the route line and a car icon.

Even that flight wasn't simple. Our short hop from Dammam to Doha took a strange, looping route, likely to avoid certain areas or to avoid being mistaken for something more hostile in the sky. Then came a tight connection in Doha, the long flight to JFK, and a drive across New York, New Jersey, and Pennsylvania to State College, where we're now settling in for good.

When we arrived, I wanted to see that journey. All of it, end to end, as one continuous story.

Here's the honest reason I built it myself instead of just posting photos: I've advocated for geospatial visualization for most of my career. I love Google's Photorealistic 3D Tiles. I love what CesiumJS can do. This was a chance to put all three together on something that actually mattered to me.

Here's the result:

▶ Watch the full fly-through on YouTube

You can also run the live web app yourself at demo.scottpez.tech, and the code is on GitHub at scottpez/cesium-travel-flythrough.

The One-Week Prototype and the Months That Followed

The first draft came together fast, in about one week of part-time work.

Two Cesium viewers running at once: a big cinematic chase camera following the car or plane, and a small seatback-style flight tracker in the corner. A timeline built from 13 legs (drives, border crossing, hotel stops, flights, a tight connection) stitched into one continuous animation. A HUD with distance, altitude, speed, a progress bar, country flags and chapter titles.

It looked great for about ninety seconds.

Then it crashed.

What followed was months of fighting out-of-memory errors, blurry ground, frozen frames and blank gray patches, plus the extras I kept adding along the way: a speed gauge, playback controls, trip photos that pop up at the right moment, a border "passport stamp," and a credits roll. Some of that time was just life. I had a lot going on, and finding focused hours was hard. But most of it went into one fact:

Rendering photorealistic 3D for a few seconds is easy. Rendering it continuously for thousands of miles is a completely different problem.

I started thinking of it as a baggage allowance problem. We crossed half the world with a fixed number of suitcases, and you can't carry everything you've ever owned. The browser works the same way. It has a strict limit on what it can hold, and a camera flying from the Persian Gulf to central Pennsylvania keeps picking up new things to carry. Nearly every lesson below comes down to deciding what to pack, what to leave behind, and when.

Full disclosure: I built this with Claude Code as my coding partner. That's a big part of why the first draft took only a week. It's also why this isn't a step-by-step tutorial. Tools like Claude Code have made code tutorials far less valuable than they used to be, since you can get working boilerplate in minutes. What they can't hand you is the scar tissue: knowing which problems to look for, which theories to doubt, and when a fix is making things worse. Those calls were still mine to make. So I'll show a few snippets where they help, and spend most of the time on what went wrong and why.

Lesson 1: The Crash That Wasn't Where I Thought It Was

The biggest challenge, by far, was running out of memory.

The trip moves continuously across four countries and two continents. At various points, Cesium needs tiles for wherever the camera is, plus wherever it's about to be, plus whatever it hasn't let go of yet. On the US legs especially, where Google's photogrammetry is dense and detailed, the browser tab would simply die.

My first instinct was the obvious one: give it more room. My GPU has plenty of memory, so bigger caches, sharper detail, done. I turned the knobs up:

Code: the two globe settings I tuned first, maximumScreenSpaceError 1.0 and tileCacheSize 15000
Code: the two globe settings I tuned first, maximumScreenSpaceError 1.0 and tileCacheSize 15000

Those two lines tune the globe: Cesium's terrain and imagery layer. But Google's Photorealistic 3D Tiles aren't part of the globe. They're a separate Cesium3DTileset added to the scene as its own primitive, with its own cache and its own memory budget. It never reads those settings.

I've spent a lot of time in this trap, and it's easy to fall into because a lot of online advice still talks about settings that no longer exist. Many older posts tell you to set maximumMemoryUsage on a tileset. That property has been removed from current CesiumJS. The modern equivalents are cacheBytes and maximumCacheOverflowBytes, set on the tileset itself:

Code: creating the Google Photorealistic 3D Tileset with cacheBytes, maximumCacheOverflowBytes, maximumScreenSpaceError and showCreditsOnScreen
Code: creating the Google Photorealistic 3D Tileset with cacheBytes, maximumCacheOverflowBytes, maximumScreenSpaceError and showCreditsOnScreen

Even this has a twist. According to Cesium's own documentation, if a view needs more than cacheBytes + maximumCacheOverflowBytes, Cesium doesn't crash. It quietly coarsens the detail (via memoryAdjustedScreenSpaceError) until things fit. So when I set a tight cap to stop the crashes, the image just got permanently blurrier in exactly the dense areas I cared about most. The limits behave like targets, not budgets.

And then the real surprise. A debug capture moments before a crash on the JFK-to-Stroudsburg leg showed:

  • JavaScript heap: 4,054 of 4,192 MB (97% of Chrome's limit)

  • Tile content memory: 1,023 MB (behaving correctly)

  • Tiles loaded: 1,734,842

The tile content was fine. The JavaScript heap was full. So I built a mechanism to periodically throw away and rebuild the whole tileset, on the theory that 1.7 million tile objects were the culprit.

A later capture proved me wrong: about 84,000 tiles, with the heap still at 3,863 MB. Twenty times fewer tiles, essentially the same heap. Correlation, not causation.

Worse, my "fix" triggered repeatedly (64 times in a single leg), each time spinning up a new tileset while the old ones evidently weren't being freed. The recycling meant to prevent the leak had become the leak. It's switched off by default now.

Two debug captures compared. The first shows 1.7 million tiles and a nearly full 4,054 MB heap; the second shows only 84,000 tiles with the heap still at 3,863 MB, disproving the tile-count theory.
Two debug captures compared. The first shows 1.7 million tiles and a nearly full 4,054 MB heap; the second shows only 84,000 tiles with the heap still at 3,863 MB, disproving the tile-count theory.

The lesson: measure which memory is full before you touch a single setting. Cesium gives you tileset.totalMemoryUsageInBytes and tileset.statistics, and Chrome gives you performance memory. I wish I'd started with them.

Lesson 2: Small Allocations, Sixty Times a Second, for Hours

Not all of the heap pressure came from tiles. Some came from my own code.

The app drives its own render loop instead of letting Cesium do it. That gives precise control over simulation time (speeding up, slowing down, dwelling on a mid-Atlantic sunrise), but it also means one function runs 60 times a second for the entire recording. Anything wasteful in there gets multiplied by hundreds of thousands.

Three small things turned out to matter:

  • Assigning entity properties every frame. In Cesium, entity.position = newValue doesn't just update a number. It wraps the value in a brand-new property object and fires change events through the entity system. Doing that every frame is a steady stream of garbage. Creating the property once and calling setValue() on it updates it in place.

  • A trail that never stopped growing. The minimap trail kept every point of the journey, and its geometry was rebuilt every frame. Slowing playback down for recording (more on that later) made it worse: the same stretch of road was sampled over far more frames. The fix was a small fixed-size ring buffer that reuses its own objects.

  • Checking which US state we're in, every frame. A full point-in-polygon test against state boundaries, 60 times a second, on exactly the legs where the tab was dying. State borders don't move. Checking every few frames is plenty.

Code: replacing a per-frame position assignment with setValue on a reused property
Code: replacing a per-frame position assignment with setValue on a reused property

None of these are dramatic on their own. Together, they're the difference between a flat memory graph and one that slowly ratchets upward until the tab gives up three hours into a recording.

Lesson 3: Your Camera Lens Is a Memory Setting

This was the most surprising lesson, and probably the most useful.

My original drive camera used Cesium's default 60° field of view, tilted slightly down behind the car. At that angle, the top of the frame actually sat above the horizon, so the camera was asking for ground all the way out to roughly 48 km away. That's about 1,234 square kilometers of photorealistic city being requested every frame.

Over the Atlantic, that costs little. Over New York, it's thousands of tile requests at once.

Narrowing the lens to 42° tucked the horizon just out of frame and cut the ground in view to about 12 square kilometers. That's roughly 1% of the original.

Side view and to-scale comparison of two camera lenses. A 60° lens sees ground out to the horizon, about 1,234 km²; a 42° lens sees ground only 5.9 km out, about 12 km², or 1% as much.
Side view and to-scale comparison of two camera lenses. A 60° lens sees ground out to the horizon, about 1,234 km²; a 42° lens sees ground only 5.9 km out, about 12 km², or 1% as much.

It also looked better. A 42° lens has a longer, more compressed, more cinematic feel. The 60° default reads slightly fisheye. The cost is that the sky leaves the frame during the drives, but the sky was where all the expense was coming from.

The flights had the opposite problem. Altitude multiplies the ground in frame enormously:

  • Driving, camera at ~185 m: about 12 km² of ground in frame

  • Flight, camera at 7,000 m: about 48,000 km²

  • Flight, camera at 11,500 m: about 78,000 km²

Up there, each tile is coarser, but there are vastly more of them. So the app now adjusts the tileset's screen-space error (Cesium's main "how sharp" dial) dynamically with altitude: full detail near the ground, steadily relaxed as the camera climbs, capped at a ceiling. At cruising altitude, you can't see a park bench anyway. On descent, it tightens back up automatically.

Code: relaxing screen-space error as the camera altitude grows, capped at 128
Code: relaxing screen-space error as the camera altitude grows, capped at 128

The lesson: in streamed 3D, what the camera sees is the biggest cost driver you control. Framing is a performance decision, not just an artistic one.

Lesson 4: Loading Fast Enough While Everything Keeps Moving

The second big challenge was speed. Not frame rate exactly, but getting high-resolution tiles to download and draw fast enough while the car or plane kept moving forward.

A few of the things I tried here were confidently wrong:

  • Throttling requests. At one point I capped every server at 3 simultaneous connections, hoping to protect my Google quota. It didn't protect anything (Google's limit is enforced server-side per minute, not by how many connections my browser opens). What it did do was starve the 3D tileset on dense legs like New York, which came out soft. Raising the concurrency for Google's tile server fixed the blur.

  • A full-route warm-up. I built a "warm-up" pass that swept the camera over all 466 waypoints before playback to pre-load tiles. Sounds smart. But the route held far more photogrammetry than the cache could store, so tiles loaded at the start were evicted before the sweep ended. Only the ones from the end of the trip remained. Only the ones from the end of the trip remained. I now warm up just the opening shot, through the real playback camera.

  • Rendering extra frames to "push" tiles in. Rendering the same view three times doesn't make the network faster. Only waiting does.

  • Culling requests while moving. Cesium can skip requests for tiles the camera will have passed by the time they arrive. For a normal interactive map, that's smart. For a slow recording, it threw away requests that would have landed in time.

The pattern behind all four: defaults and "best practices" are tuned for someone panning around a city. A camera that never stops moving, across thousands of miles, breaks a lot of those assumptions. You have to reason about your own use case rather than copying settings.

Lesson 5: Where Google Has No 3D, You Need a Plan B

The hardest segments to make look good were the first ones: Kuwait and Saudi Arabia.

Google's Photorealistic 3D Tiles cover a lot of the world (thousands of cities at launch), but not everywhere. Over our route through Kuwait and the eastern Saudi desert, true photorealistic 3D simply isn't available. Worse, asking for fine detail there can return Google's "Map data not yet available" placeholder tiles, which are solid geometry that covers up anything underneath. On screen, that reads as the ground going gray right under the car.

So the app layers a fallback underneath the 3D tiles:

  • Satellite imagery on the globe. Google's 2D satellite imagery first, then free Esri imagery as a fallback, so gaps show real aerial imagery instead of nothing.

  • Coarser detail where Google has it. Per-leg overrides request a level of detail that actually exists in sparse regions.

  • Hiding the 3D tiles entirely on legs where the gaps dominate, trading 3D for a consistent aerial view.

  • Color matching. Satellite basemaps are more saturated and cooler than Google's photogrammetry, so I pull the imagery's saturation down to about 70% so the seams aren't jarring.

One more counterintuitive fix: I removed 3D terrain under the tiles. Cesium World Terrain and Google's photogrammetry are two independent measurements of the same ground, and they disagree by several meters. Wherever the terrain sat higher, satellite imagery punched up through the city in irregular blotches. A flat surface at sea level sits below the photogrammetry nearly everywhere, so it can never poke through.

Map of the full route from Kuwait to State College, colored by what the app could show: aerial imagery only in Kuwait and Saudi Arabia, coarse 3D on flights, no data over the ocean, and full photorealistic 3D from New York to Pennsylvania.
Map of the full route from Kuwait to State College, colored by what the app could show: aerial imagery only in Kuwait and Saudi Arabia, coarse 3D on flights, no data over the ocean, and full photorealistic 3D from New York to Pennsylvania.

The Middle East legs looked good, not great. The draped aerial imagery is fine. It's just not the same as flying through real 3D.

And the US had the opposite problem: the photorealistic tiles were so rich and memory-intensive that they caused crashes much sooner. Too little data on one side of the world, too much on the other.

Lesson 6: Quotas and Attribution Are Part of the Design

I didn't see this one coming.

During recording, the app hit Google's default limit of 100,000 tile requests per day from a single fly-through session. That makes sense: a typical map user pans and zooms occasionally, while a cinematic camera streaming high-zoom 3D tiles and 2D satellite imagery nonstop across a multi-country route is a completely different consumption pattern.

So I requested a permanent increase to 200,000 per day. It's not a self-service button. It's a conversation with Google Maps Platform support, and they wanted to understand the use case: what the app is, who it's for, which map types it uses, how I estimated my daily and per-minute needs, and confirmation that it's visualization-only with no offline caching or export.

Honestly, they handled it very professionally. It took about two weeks of back-and-forth, and it was approved once I'd provided the information and made sure the required Google logo and data attributions appeared on screen. I didn't pay for the increase itself, though I do pay for tile usage beyond the free tier.

That attribution piece deserves its own warning. Google's Map Tiles API policies require the Google logo and the aggregated data credits to be visible. When you build your own HUD over the globe, it's very easy to bury them. My bottom gradient was doing exactly that, and I also had to explicitly turn on Cesium's showCreditsOnScreen for the tileset, which defaults to off.

The lesson: treat quota, cost, and attribution as design requirements from day one, not paperwork you deal with after launch. The policies also limit caching, so "just save the tiles locally" isn't an option. Smart streaming is your only real defense.

Lesson 7: Record Slow, Show Fast

Even with a stable app, the tiles still couldn't keep up at normal speed. So I flipped the problem around: I recorded the fly-through very slowly, at about 1/40th of normal playback speed, and sped it up later in editing. The tile pipeline gets a slow, leisurely trip. The viewer sees a fast one.

The full recording ran for hours. Probably four or five, though I honestly didn't time it. I recorded in OBS Studio with the browser in full screen, and captured each leg as its own raw video with a little overlap on either side, so a crash or glitch only cost me one segment, not the whole trip. In editing, each segment got its own speed. The long Doha-to-JFK flight was compressed the most.

Recording slowly created a sneaky side effect: every on-screen overlay (chapter titles, passport stamps, photo memories) would flash by in a fraction of a second once the footage was sped up. The fix was to scale every overlay's duration by the playback rate, so a three-second chapter card stays up for minutes during recording and reads as three seconds in the final video.

Two timelines linked leg by leg. The real 104-hour trip maps to 174 seconds of fly-through, with hotel stays compressed over 12,000 times and key drives given more screen time.
Two timelines linked leg by leg. The real 104-hour trip maps to 174 seconds of fly-through, with hotel stays compressed over 12,000 times and key drives given more screen time.

A few other recording lessons:

  • Make every setting reachable from the URL. The app has a URL-driven "director mode": jump to any leg, set the speed, hide all controls, turn on letterbox bars and film grain. One URL per take, nothing to click, perfectly repeatable.

  • Antialiasing has to fit the camera. Temporal antialiasing builds up quality across frames and needs a mostly still camera. Mine never stops, so TAA just smeared. Plain FXAA plus 8x MSAA gave much cleaner edges on rooftops and bridge cables.

  • Watch for the frame that kills the loop. If one frame throws an error in a hand-rolled render loop, the next frame may never get scheduled. The app just freezes mid-take. That's one I still want to harden.

And even after all of that, the tiles still didn't download perfectly in every frame. If you watch closely, you'll find soft patches. I decided that was okay. Perfect was never really on the table at this scale.

What I'd Tell Someone Starting Today

If you're about to build something similar, here's the short version:

  • Know which system a setting belongs to before you turn it. The globe and a 3D tileset are separate worlds.

  • Measure before you fix. Find out which memory is full. My best-sounding theory was wrong.

  • Frame for performance. Lens angle and altitude are your biggest levers on how much data you pull.

  • Distrust defaults built for interactive maps. A continuously moving camera breaks many of them.

  • Plan for coverage gaps. Photorealistic 3D isn't everywhere, and placeholder tiles can be worse than nothing.

  • Budget quota, cost, and attribution up front. Expect a human conversation if you need more.

  • Record slow, show fast, and scale your UI timing to match.

  • Accept imperfection. Ship the story.

Conclusion: A Week to Build, Months to Fly

The prototype took a week. Making it survive an intercontinental flight took months. And nearly every hour of that went into the same question: what can I afford to carry right now, and what can I leave behind?

What I came away with isn't just a video of our trip, though I'm really glad to have it. It's a much clearer sense of where these tools are today. CesiumJS and Google's Photorealistic 3D Tiles are genuinely remarkable. They're also still expert territory once you push past a simple embed: memory, coverage, quotas, attribution, and recording are all problems you have to solve yourself.

For my family, the video tells a story we lived through and are still processing. When we finally made it to State College, I knelt down on the lawn at Penn State and thought:

Flag of the United States
Flag of the United States
This isn't just soil. This is American soil.
It's great to be home.
Scott kneeling on the grass in front of Penn State's Old Main, smiling
Scott kneeling on the grass in front of Penn State's Old Main, smiling

For me, professionally, it's a reminder of why I fell in love with geospatial visualization in the first place: there are some things you can only really understand when you see them.

What's Next?

Thanks for reading, and thanks to everyone who reached out after seeing the video.

In my next post, I'll step back from the code and take on a bigger question: if we can now render an almost perfect visual copy of the physical world, is that actually useful? I'll argue that it isn't enough on its own, that humans are still essential to making sense of spatial AI and digital twins, and that maps have taught us that lesson for centuries.

In the meantime, I'm curious: what's the hardest performance problem you've hit with streamed 3D data, and what finally fixed it?

Let's keep the conversation going on LinkedIn!

Further reading:

Contacts

scottpez@brightworldlabs.com

Socials