packages/skills/skills/remotion-maps/techniques/cesium/references/3d-troubleshooting.md
Cesium's globe will not draw in a standalone headless Playwright/Chromium harness. Verified on
Apple M4 (ANGLE Metal active, WebGL working): the skybox/stars render, but the globe surface produces
zero draw commands (scene.frameState.commandList.length === 0), globe.tilesLoaded never goes
true, and frames come back as the black starfield. No network failures; sampleTerrainMostDetailed
succeeds (terrain data is reachable). Dead-ends tried, all failed:
scene.render(), manual viewer.render(), headed mode (context-destroyed).What works: render Cesium through Remotion — same headless Chrome, but driven by Remotion's frame loop with these four non-negotiables:
useDefaultRenderLoop = false — drive frames by hand.viewer.render(), NOT scene.render(). viewer.render() does the full frame
(initializeFrame → tile streaming → render); scene.render() skips frame-init, so tiles never
advance and the globe never appears. This is the single most important line.contextOptions: { webgl: { preserveDrawingBuffer: true } } so Remotion's screenshot captures pixels.delayRender(…, {timeoutInMilliseconds}) — tile loading can exceed Remotion's
default; use 60–120 s.The standalone flythrough.html / render.mjs / probe.mjs from the original spike are kept only as the
record of this dead-end. The canonical render path is the Remotion component
(../assets/CesiumFlythrough.tsx).
| Symptom | Cause | Fix |
|---|---|---|
| Frames are black with stars | Globe not drawing (headless Playwright, or scene.render() used) | Render through Remotion; use viewer.render(); useDefaultRenderLoop=false. |
| Screenshots blank/transparent | No preserveDrawingBuffer | contextOptions:{ webgl:{ preserveDrawingBuffer:true } }. |
| "delayRender timed out" | Cold tiles exceed the default | delayRender(…, {timeoutInMilliseconds: 120000}) + --timeout=180000. |
| Globe is a dark/navy sphere | Imagery layer didn't attach | baseLayer:false then viewer.imageryLayers.addImageryProvider(...). |
| High-pitch frame shows a void/starfield above the horizon | No atmosphere | viewer.scene.skyAtmosphere.show = true. |
| 403 on tiles in headless | Domain-locked MapTiler key | Use an unrestricted key. |
| Google root tileset returns 403 | Map Tiles API disabled, billing absent, wrong key, or application restriction blocks local headless rendering | Enable Map Tiles API and billing; restrict the key to that API while allowing the Remotion request. |
| Google scene shows a duplicate/competing surface | MapTiler or the Cesium globe is still enabled | Do not add MapTiler; set viewer.scene.globe.show=false. |
| Google mesh remains coarse | Screen-space error is too high or the capture starts before refinement | Lower maximumScreenSpaceError; settle on tileset.tilesLoaded. |
| WebGL unavailable / software renderer | Missing GL flag | Render with --gl=angle. |
| Camera looks at sky / ground, not terrain | Pitch sign / convention | Cesium pitch 0 = horizon, negative = down (inverse of MapLibre); -(90 - PITCH_FROM_NADIR). |
| Aim/turn-probe clamps near the end | Path too short | PATHKM ≥ TRAVEL_KM + 2·LOOK_AHEAD_KM; raise WINDOW_KM in prep. |
| Path feels like straight-then-corner | Douglas-Peucker simplification | Use resample → moving-average smooth (see architecture §2), not turf.simplify. |
| Camera bumps left and right instead of swerving | Sparse route vertices are still being followed as straight segments | Keep pathSmoothingPasses={3}; use sparse intentional control points and arc-length movement. |
viewer.render(), never scene.render() per frame. The single biggest trap.window.CESIUM_BASE_URL before injecting the script.preserveDrawingBuffer: true or screenshots are blank.delayRender uses timeoutInMilliseconds; set it to at least 60,000.baseLayer:false, then add MapTiler imagery.showCreditsOnScreen:true.skyAtmosphere.show = true.