Setting the file. One moment. Cesium Flythrough · Remotion Best Practices · remotion-dev/skills · Skills Docsremotion-maps/techniques/cesium/assets/CesiumFlythrough.tsx
TypeScript·284 lines·9 KB
LngLat}
from
'./flight-path'
;
12
13export type FlyoverMode = 'landscape' | 'city';
14export type {LngLat} from './flight-path';
15
16export type CesiumFlythroughProps = {
17 mode?: FlyoverMode;
18 path?: LngLat[];
19 pathSmoothingPasses?: number;
20 altitudeStart?: number;
21 altitudeEnd?: number;
22 lookAheadKm?: number;
23 travelKm?: number;
24 pitchFromNadir?: number;
25 verticalExaggeration?: number;
26 maximumScreenSpaceError?: number;
27};
28
29const MAPTILER_KEY = process.env.REMOTION_MAPTILER_KEY;
30const GOOGLE_MAPS_API_KEY = process.env.REMOTION_GOOGLE_MAPS_API_KEY;
31const CESIUM_VER = '1.143';
32const CDN = `https://cesium.com/downloads/cesiumjs/releases/${CESIUM_VER}/Build/Cesium/`;
33const R = 6371;
34const MAX_BANK = 0.13;
35const BANK_GAIN = 0.6;
36
37const havKm = (a: number[], b: number[]) => {
38 const r = Math.PI / 180;
39 const dLat = (b[1] - a[1]) * r;
40 const dLng = (b[0] - a[0]) * r;
41 const h =
42 Math.sin(dLat / 2) ** 2 +
43 Math.cos(a[1] * r) * Math.cos(b[1] * r) * Math.sin(dLng / 2) ** 2;
44 return 2 * R * Math.asin(Math.sqrt(h));
45};
46
47const makePathWalker = (path: LngLat[]) => {
48 if (path.length < 2)
49 throw new Error('Flyover path needs at least two points');
50 const cumulative = [0];
51 for (let i = 1; i < path.length; i++) {
52 cumulative.push(cumulative[i - 1] + havKm(path[i - 1], path[i]));
53 }
54 const lengthKm = cumulative[cumulative.length - 1];
55 const along = (km: number): LngLat => {
56 const d = Math.max(0, Math.min(lengthKm, km));
57 let i = 1;
58 while (i < cumulative.length && cumulative[i] < d) i++;
59 if (i >= cumulative.length) return path[path.length - 1];
60 const segmentLength = cumulative[i] - cumulative[i - 1] || 1;
61 const t = (d - cumulative[i - 1]) / segmentLength;
62 return [
63 path[i - 1][0] + (path[i][0] - path[i - 1][0]) * t,
64 path[i - 1][1] + (path[i][1] - path[i - 1][1]) * t,
65 ];
66 };
67 return {along, lengthKm};
68};
69
70const bearing = (a: number[], b: number[]) => {
71 const r = Math.PI / 180;
72 const y = Math.sin((b[0] - a[0]) * r) * Math.cos(b[1] * r);
73 const x =
74 Math.cos(a[1] * r) * Math.sin(b[1] * r) -
75 Math.sin(a[1] * r) * Math.cos(b[1] * r) * Math.cos((b[0] - a[0]) * r);
76 return Math.atan2(y, x);
77};
78
79const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
80const clamp = (v: number, lo: number, hi: number) =>
81 Math.max(lo, Math.min(hi, v));
82
83const loadCesium = () =>
84 new Promise<any>((resolve, reject) => {
85 if ((window as any).Cesium) return resolve((window as any).Cesium);
86 (window as any).CESIUM_BASE_URL = CDN;
87 const css = document.createElement('link');
88 css.rel = 'stylesheet';
89 css.href = `${CDN}Widgets/widgets.css`;
90 document.head.appendChild(css);
91 const script = document.createElement('script');
92 script.src = `${CDN}Cesium.js`;
93 script.onload = () => resolve((window as any).Cesium);
94 script.onerror = () =>
95 reject(new Error(`Failed to load CesiumJS ${CESIUM_VER}`));
96 document.head.appendChild(script);
97 });
98
99export const CesiumFlythrough: React.FC<CesiumFlythroughProps> = ({
100 mode = 'landscape',
101 path = terrainPath as LngLat[],
102 pathSmoothingPasses = 3,
103 altitudeStart = 4600,
104 altitudeEnd = 4300,
105 lookAheadKm = 1.5,
106 travelKm = 13,
107 pitchFromNadir = 76,
108 verticalExaggeration = 1.1,
109 maximumScreenSpaceError = 8,
110}) => {
111 const containerRef = useRef<HTMLDivElement>(null);
112 const started = useRef(false);
113 const viewerRef = useRef<any>(null);
114 const tilesetRef = useRef<any>(null);
115 const frame = useCurrentFrame();
116 const {durationInFrames, fps, width, height} = useVideoConfig();
117 const [ready, setReady] = useState(false);
118 const [handle] = useState(() =>
119 delayRender(`cesium init: ${mode}`, {timeoutInMilliseconds: 120000}),
120 );
121 const walker = useMemo(
122 () => makePathWalker(smoothFlightPath(path, pathSmoothingPasses)),
123 [path, pathSmoothingPasses],
124 );
125
126 const setCamera = (C: any, viewer: any, progress: number) => {
127 const maxTravel = Math.max(0, walker.lengthKm - lookAheadKm * 2);
128 const cameraDistance = Math.min(travelKm, maxTravel) * progress;
129 const camera = walker.along(cameraDistance);
130 const aim = walker.along(cameraDistance + lookAheadKm);
131 const aim2 = walker.along(cameraDistance + lookAheadKm * 2);
132 const heading = bearing(camera, aim);
133 let headingDelta = bearing(aim, aim2) - heading;
134 while (headingDelta > Math.PI) headingDelta -= 2 * Math.PI;
135 while (headingDelta < -Math.PI) headingDelta += 2 * Math.PI;
136 viewer.camera.setView({
137 destination: C.Cartesian3.fromDegrees(
138 camera[0],
139 camera[1],
140 lerp(altitudeStart, altitudeEnd, progress),
141 ),
142 orientation: {
143 heading,
144 pitch: C.Math.toRadians(-(90 - pitchFromNadir)),
145 roll: clamp(headingDelta * BANK_GAIN, -MAX_BANK, MAX_BANK),
146 },
147 });
148 };
149
150 const tilesAreLoaded = (viewer: any) => {
151 if (mode === 'landscape') return viewer.scene.globe.tilesLoaded;
152 return Boolean(tilesetRef.current?.tilesLoaded);
153 };
154
155 const settle = (viewer: any) =>
156 new Promise<void>((resolve) => {
157 let stable = 0;
158 let ticks = 0;
159 const tick = () => {
160 viewer.render();
161 ticks++;
162 stable = tilesAreLoaded(viewer) ? stable + 1 : 0;
163 if (stable > 8 || ticks > 600) {
164 viewer.render();
165 resolve();
166 } else {
167 setTimeout(tick, 8);
168 }
169 };
170 tick();
171 });
172
173 useEffect(() => {
174 if (started.current) return;
175 started.current = true;
176 (async () => {
177 if (mode === 'landscape' && !MAPTILER_KEY) {
178 throw new Error(
179 'Set REMOTION_MAPTILER_KEY. Create a key at https://cloud.maptiler.com/account/keys/',
180 );
181 }
182 if (mode === 'city' && !GOOGLE_MAPS_API_KEY) {
183 throw new Error(
184 'Set REMOTION_GOOGLE_MAPS_API_KEY. Create a Map Tiles API key at https://developers.google.com/maps/documentation/tile/get-api-key',
185 );
186 }
187 if (mode === 'city' && durationInFrames / fps > 30) {
188 throw new Error(
189 'Google Photorealistic 3D Tiles compositions must not exceed 30 seconds',
190 );
191 }
192
193 const C = await loadCesium();
194 const viewer = new C.Viewer(containerRef.current, {
195 baseLayer: false,
196 baseLayerPicker: false,
197 geocoder: false,
198 homeButton: false,
199 sceneModePicker: false,
200 navigationHelpButton: false,
201 animation: false,
202 timeline: false,
203 fullscreenButton: false,
204 infoBox: false,
205 selectionIndicator: false,
206 contextOptions: {webgl: {preserveDrawingBuffer: true}},
207 });
208 if (mode === 'landscape') {
209 viewer.imageryLayers.addImageryProvider(
210 new C.UrlTemplateImageryProvider({
211 url: `https://api.maptiler.com/tiles/satellite-v2/{z}/{x}/{y}.jpg?key=${MAPTILER_KEY}`,
212 maximumLevel: 20,
213 }),
214 );
215 viewer.terrainProvider = await C.CesiumTerrainProvider.fromUrl(
216 `https://api.maptiler.com/tiles/terrain-quantized-mesh-v2/?key=${MAPTILER_KEY}`,
217 {requestVertexNormals: true},
218 );
219 viewer.creditDisplay.addStaticCredit(
220 new C.Credit(
221 '<a href="https://www.maptiler.com/copyright/" target="_blank">© MapTiler</a>',
222 true,
223 ),
224 );
225 }
226 if (mode === 'city') {
227 viewer.scene.globe.show = false;
228 const tileset = await C.Cesium3DTileset.fromUrl(
229 `https://tile.googleapis.com/v1/3dtiles/root.json?key=${GOOGLE_MAPS_API_KEY}`,
230 {
231 showCreditsOnScreen: true,
232 maximumScreenSpaceError,
233 },
234 );
235 viewer.scene.primitives.add(tileset);
236 tilesetRef.current = tileset;
237 }
238
239 viewer.useDefaultRenderLoop = false;
240 viewer.scene.skyAtmosphere.show = true;
241 viewer.scene.fog.enabled = true;
242 viewer.scene.globe.enableLighting = false;
243 viewer.scene.verticalExaggeration = verticalExaggeration;
244 (window as any).__CESIUM_FLYOVER__ = {C, mode};
245 viewerRef.current = viewer;
246 setCamera(C, viewer, 0);
247 await settle(viewer);
248 setReady(true);
249 continueRender(handle);
250 })().catch((error) => cancelRender(error));
251 }, [durationInFrames, fps, handle, mode]);
252
253 useEffect(() => {
254 if (!ready) return;
255 const frameHandle = delayRender(`cesium ${mode} frame ${frame}`, {
256 timeoutInMilliseconds: 60000,
257 });
258 const C = (window as any).__CESIUM_FLYOVER__.C;
259 const viewer = viewerRef.current;
260 const progress = durationInFrames <= 1 ? 0 : frame / (durationInFrames - 1);
261 setCamera(C, viewer, progress);
262 settle(viewer).then(() => continueRender(frameHandle));
263 }, [ready, frame, durationInFrames, mode]);
264
265 return (
266 <AbsoluteFill style={{backgroundColor: '#000'}}>
267 <div ref={containerRef} style={{width, height, position: 'absolute'}} />
268 {mode === 'city' ? (
269 <div
270 style={{
271 position: 'absolute',
272 top: 20,
273 right: 24,
274 color: 'white',
275 font: '500 18px/1.2 sans-serif',
276 textShadow: '0 1px 4px black',
277 }}
278 >
279 For promotional purposes only
280 </div>
281 ) : null}
282 </AbsoluteFill>
283 );
284};