cosmo/frontend/src/features/rocket-simulator/RocketFlightScene.tsx

545 lines
22 KiB
TypeScript

import { useEffect, useLayoutEffect, useMemo, useRef } from 'react';
import { Canvas, useFrame } from '@react-three/fiber';
import { OrbitControls, Stars } from '@react-three/drei';
import * as THREE from 'three';
import type { OrbitControls as OrbitControlsImpl } from 'three-stdlib';
import type { RocketConfig, SimulationState } from './types';
import { useOptionalTexture } from '../../hooks/useOptionalTexture';
import { RocketModel } from './RocketModel';
import { clamp, fogColor, groundDrop, separationAge, skyColor, starOpacity } from './sceneMath';
export type CameraMode = 'follow' | 'global';
interface RocketFlightSceneProps {
rocket: RocketConfig;
state: SimulationState;
cameraMode: CameraMode;
viewScale: number;
viewScaleResetTrigger: number;
onViewScaleChange: (scale: number) => void;
}
const EARTH_RADIUS_METERS = 6_371_000;
const EARTH_RADIUS = 34;
const EARTH_TEXTURE = '/upload/texture/2k_earth_daymap.jpg';
const EARTH_CENTER = new THREE.Vector3(0, -EARTH_RADIUS, 0);
const EARTH_ROTATION = new THREE.Euler(0, -1.25, -0.08);
const VIEW_SCALE_CALIBRATION = 0.7;
interface LaunchSite {
latitude: number;
longitude: number;
}
interface ManualCameraRef {
current: boolean;
}
function launchSiteForRocket(rocket: RocketConfig) {
return {
latitude: rocket.launch_latitude_deg,
longitude: rocket.launch_longitude_deg,
};
}
function orbitLift(targetAltitude: number) {
return clamp(targetAltitude / 200_000 * 11, 8, 18);
}
function globalFlightFrame(altitude: number, downrange: number, targetAltitude: number, site: LaunchSite) {
const radius = EARTH_RADIUS + clamp(altitude / targetAltitude, 0, 1.45) * orbitLift(targetAltitude);
const angle = downrange / EARTH_RADIUS_METERS;
const latitude = THREE.MathUtils.degToRad(site.latitude);
const longitude = THREE.MathUtils.degToRad(site.longitude);
const launchNormal = new THREE.Vector3(
Math.cos(latitude) * Math.cos(longitude),
Math.sin(latitude),
-Math.cos(latitude) * Math.sin(longitude),
);
const east = new THREE.Vector3(-Math.sin(longitude), 0, -Math.cos(longitude));
const normal = launchNormal.multiplyScalar(Math.cos(angle)).addScaledVector(east, Math.sin(angle)).applyEuler(EARTH_ROTATION);
const tangent = east.multiplyScalar(Math.cos(angle)).addScaledVector(
new THREE.Vector3(
Math.cos(latitude) * Math.cos(longitude),
Math.sin(latitude),
-Math.cos(latitude) * Math.sin(longitude),
),
-Math.sin(angle),
).applyEuler(EARTH_ROTATION);
return {
normal,
tangent,
position: EARTH_CENTER.clone().addScaledVector(normal, radius),
};
}
/** 发射场:混凝土地坪 + 导流槽 + 发射台 + 服务塔 + 避雷塔。 */
function LaunchPadStructure() {
const towerHeight = 13;
return (
<group>
{/* 混凝土地坪 */}
<mesh position={[0, -0.74, 0]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
<circleGeometry args={[130, 64]} />
<meshStandardMaterial color="#2a3630" metalness={0} roughness={1} />
</mesh>
<mesh position={[0, -0.7, 0]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
<circleGeometry args={[26, 48]} />
<meshStandardMaterial color="#4a5350" metalness={0.05} roughness={0.95} />
</mesh>
{/* 尾焰熏黑区域 */}
<mesh position={[0, -0.69, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<circleGeometry args={[8.5, 32]} />
<meshStandardMaterial color="#171b1a" metalness={0.1} roughness={1} />
</mesh>
{/* 发射台 + 导流槽 */}
<mesh position={[0, -0.34, 0]} receiveShadow>
<boxGeometry args={[19, 0.7, 13]} />
<meshStandardMaterial color="#6d736f" metalness={0.08} roughness={0.92} />
</mesh>
<mesh position={[0, -0.5, 0]}>
<boxGeometry args={[7.4, 0.6, 15]} />
<meshStandardMaterial color="#1b1f21" metalness={0.3} roughness={0.7} />
</mesh>
{/* 导流锥 */}
<mesh position={[0, -0.18, 0]} rotation={[Math.PI, 0, 0]}>
<coneGeometry args={[3.1, 1.1, 4]} />
<meshStandardMaterial color="#343b3f" metalness={0.4} roughness={0.6} />
</mesh>
{/* 压紧机构 */}
{[[-2.6, -2.6], [2.6, -2.6], [-2.6, 2.6], [2.6, 2.6]].map(([x, z]) => (
<mesh key={`${x}-${z}`} position={[x, 0.1, z]}>
<boxGeometry args={[0.5, 0.9, 0.5]} />
<meshStandardMaterial color="#7c848a" metalness={0.5} roughness={0.5} />
</mesh>
))}
{/* 服务塔 */}
<group position={[-6.2, 0, 0]}>
{[-1.2, 1.2].map((offset) => (
<mesh key={offset} position={[offset, towerHeight / 2, 0]}>
<boxGeometry args={[0.42, towerHeight, 0.42]} />
<meshStandardMaterial color="#59636a" metalness={0.5} roughness={0.5} />
</mesh>
))}
{[3.2, 6.4, 9.6, 12.2].map((y) => (
<mesh key={y} position={[0, y, 0]}>
<boxGeometry args={[3.4, 0.28, 0.3]} />
<meshStandardMaterial color="#6b757b" metalness={0.45} roughness={0.55} />
</mesh>
))}
{/* 摆杆 / 加注臂 */}
{[5.4, 9.4].map((y, index) => (
<mesh key={y} position={[2.1 + (index === 0 ? 0.6 : 0), y, 0]}>
<boxGeometry args={[3.6, 0.24, 0.5]} />
<meshStandardMaterial color="#78838a" metalness={0.45} roughness={0.5} />
</mesh>
))}
{/* 塔顶工作平台(避雷针只装在四周的避雷塔上) */}
<mesh position={[0, towerHeight + 0.15, 0]}>
<boxGeometry args={[3.2, 0.3, 3.2]} />
<meshStandardMaterial color="#6b757b" metalness={0.45} roughness={0.55} />
</mesh>
{[-1.55, 1.55].map((offset) => (
<mesh key={offset} position={[offset, towerHeight + 0.85, 0]}>
<boxGeometry args={[0.1, 1.1, 3.2]} />
<meshStandardMaterial color="#828c92" metalness={0.4} roughness={0.6} transparent opacity={0.7} />
</mesh>
))}
<mesh position={[0, towerHeight + 0.85, -1.55]}>
<boxGeometry args={[3.2, 1.1, 0.1]} />
<meshStandardMaterial color="#828c92" metalness={0.4} roughness={0.6} transparent opacity={0.7} />
</mesh>
</group>
{/* 避雷塔(四根柱子顶部的避雷针) */}
{[[-14, -9], [14, -9], [-14, 9], [14, 9]].map(([x, z]) => (
<group key={`${x}-${z}`} position={[x, 0, z]}>
<mesh position={[0, 7.5, 0]}>
<cylinderGeometry args={[0.16, 0.28, 15, 8]} />
<meshStandardMaterial color="#4c555b" metalness={0.5} roughness={0.55} />
</mesh>
<mesh position={[0, 15.4, 0]}>
<cylinderGeometry args={[0.03, 0.07, 1.6, 6]} />
<meshStandardMaterial color="#aeb7bd" metalness={0.7} roughness={0.35} />
</mesh>
</group>
))}
{/* 场坪编号,让地坪有尺度参照 */}
<mesh position={[0, -0.68, 12]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[6, 1.2]} />
<meshStandardMaterial color="#8b949e" metalness={0} roughness={0.9} />
</mesh>
</group>
);
}
/**
* 发射瞬间的蒸汽/烟雾:点火后从发射台底部翻涌扩散,
* 随飞行高度升高逐渐淡出(离地后不再有地面烟雾)。
*/
function PadExhaust({ state }: { state: SimulationState }) {
const groupRef = useRef<THREE.Group>(null);
const puffs = useMemo(
() => Array.from({ length: 14 }, (_, index) => ({
angle: index * 2.399,
speed: 0.5 + (index % 5) * 0.09,
delay: (index % 7) / 7,
})),
[],
);
useFrame(({ clock }) => {
if (!groupRef.current) return;
const burning = state.isRunning && state.throttle > 0.05 && state.altitude < 4_000;
groupRef.current.visible = burning;
if (!burning) return;
const time = clock.elapsedTime * 0.5;
groupRef.current.children.forEach((child, index) => {
const puff = puffs[index];
const progress = (time * puff.speed + puff.delay) % 1;
const spread = 3 + progress * 26;
child.position.set(
Math.cos(puff.angle) * spread,
progress * 9,
Math.sin(puff.angle) * spread,
);
child.scale.setScalar(1.4 + progress * 4.2);
const material = (child as THREE.Mesh).material as THREE.MeshBasicMaterial;
material.opacity = (1 - progress) * 0.24;
});
});
return (
<group ref={groupRef} visible={false}>
{puffs.map((_, index) => (
<mesh key={index}>
<sphereGeometry args={[1, 10, 8]} />
<meshBasicMaterial color="#dfe6ea" transparent opacity={0.2} depthWrite={false} />
</mesh>
))}
</group>
);
}
/** 地面场景容器:随飞行高度下移、随射程后移。 */
function LaunchRack({ state }: { state: SimulationState }) {
const groupRef = useRef<THREE.Group>(null);
useFrame(() => {
if (groupRef.current) {
groupRef.current.position.y = -groundDrop(state.altitude);
// Slide sideways with downrange travel so the pad falls behind.
groupRef.current.position.x = -clamp(state.downrange / 4000, 0, 60);
}
});
return (
<group ref={groupRef}>
<LaunchPadStructure />
<PadExhaust state={state} />
</group>
);
}
function EarthSurface({ texture, state }: { texture: THREE.Texture | null; state?: SimulationState }) {
const highAltitude = state ? state.altitude >= 9_000 : true;
if (!highAltitude) return null;
const gap = state ? 7 + clamp((state.altitude - 9_000) / 91_000, 0, 1) * 5 : 0;
const radius = state ? 62 : EARTH_RADIUS;
const centerY = state ? -radius - gap : -EARTH_RADIUS;
return (
<group position={[0, centerY, 0]}>
<mesh rotation={[0, -1.25, -0.08]} receiveShadow>
<sphereGeometry args={[radius, 96, 64]} />
<meshStandardMaterial map={texture ?? undefined} color={texture ? '#ffffff' : '#295c72'} roughness={0.9} metalness={0} />
</mesh>
<mesh>
<sphereGeometry args={[radius * 1.018, 72, 48]} />
<meshBasicMaterial color="#7fc7e8" transparent opacity={state ? 0.12 : 0.18} side={THREE.BackSide} depthWrite={false} />
</mesh>
<mesh rotation={[0, -0.9, 0]}>
<sphereGeometry args={[radius * 1.006, 72, 48]} />
<meshPhongMaterial color="#ffffff" transparent opacity={0.07} depthWrite={false} />
</mesh>
</group>
);
}
function TargetOrbit({ targetAltitude, launchSite }: { targetAltitude: number; launchSite: LaunchSite }) {
const orbit = useMemo(() => {
const radius = EARTH_RADIUS + orbitLift(targetAltitude);
const points = Array.from({ length: 181 }, (_, index) => {
const angle = index / 180 * Math.PI * 2;
return globalFlightFrame(0, angle * EARTH_RADIUS_METERS, targetAltitude, launchSite)
.normal.multiplyScalar(radius).add(EARTH_CENTER);
});
const geometry = new THREE.BufferGeometry().setFromPoints(points);
const material = new THREE.LineDashedMaterial({ color: '#8a9496', dashSize: 1.2, gapSize: 0.75, transparent: true, opacity: 0.65 });
const line = new THREE.LineLoop(geometry, material);
line.computeLineDistances();
return line;
}, [launchSite, targetAltitude]);
useEffect(() => () => {
orbit.geometry.dispose();
(orbit.material as THREE.Material).dispose();
}, [orbit]);
return <primitive object={orbit} />;
}
function FlightPath({ state, targetAltitude, launchSite }: { state: SimulationState; targetAltitude: number; launchSite: LaunchSite }) {
const line = useMemo(() => new THREE.Line(
new THREE.BufferGeometry(),
new THREE.LineBasicMaterial({ color: '#59c78f', transparent: true, opacity: 0.95 }),
), []);
const lineRef = useRef<THREE.Line>(null);
useLayoutEffect(() => {
const activeLine = lineRef.current;
if (!activeLine) return;
const points = [globalFlightFrame(0, 0, targetAltitude, launchSite).position];
state.history.forEach((point) => {
points.push(globalFlightFrame(point.altitude, point.downrange, targetAltitude, launchSite).position);
});
points.push(globalFlightFrame(state.altitude, state.downrange, targetAltitude, launchSite).position);
const previousGeometry = activeLine.geometry;
activeLine.geometry = new THREE.BufferGeometry().setFromPoints(points);
previousGeometry.dispose();
}, [launchSite, state.altitude, state.downrange, state.history, targetAltitude]);
useEffect(() => () => {
lineRef.current?.geometry.dispose();
(line.material as THREE.Material).dispose();
}, [line]);
return <primitive ref={lineRef} object={line} />;
}
function GlobalEarthView({ rocket, state, texture }: { rocket: RocketConfig; state: SimulationState; texture: THREE.Texture | null }) {
const targetAltitude = rocket.target_orbit_km * 1000;
const launchSite = launchSiteForRocket(rocket);
const frame = globalFlightFrame(state.altitude, state.downrange, targetAltitude, launchSite);
const launchFrame = globalFlightFrame(0, 0, targetAltitude, launchSite);
const pitchFromVertical = THREE.MathUtils.degToRad(90 - state.pitch);
const direction = frame.normal.clone().multiplyScalar(Math.cos(pitchFromVertical))
.addScaledVector(frame.tangent, Math.sin(pitchFromVertical)).normalize();
const orientation = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), direction);
const markerPosition = launchFrame.position.clone().addScaledVector(launchFrame.normal, 0.25);
return (
<>
<EarthSurface texture={texture} />
<TargetOrbit targetAltitude={targetAltitude} launchSite={launchSite} />
<FlightPath state={state} targetAltitude={targetAltitude} launchSite={launchSite} />
<mesh position={markerPosition}>
<sphereGeometry args={[0.34, 16, 12]} />
<meshBasicMaterial color="#f2b84b" />
</mesh>
<group position={frame.position} quaternion={orientation} scale={0.7 * VIEW_SCALE_CALIBRATION}>
<RocketModel rocket={rocket} state={state} />
</group>
<pointLight position={frame.position} color="#f2b84b" intensity={2.5} distance={9} />
</>
);
}
function FollowVehicle({ rocket, state }: { rocket: RocketConfig; state: SimulationState }) {
const pitchFromVertical = THREE.MathUtils.degToRad(90 - state.pitch);
return (
<group rotation={[0, 0, -pitchFromVertical]}>
<SeparationBurst state={state} />
<RocketModel rocket={rocket} state={state} />
</group>
);
}
/** Expanding shockwave ring shown briefly at stage separation. */
function SeparationBurst({ state }: { state: SimulationState }) {
const ringRef = useRef<THREE.Mesh>(null);
const age = separationAge(state);
useFrame(() => {
if (!ringRef.current) return;
const visible = age !== null && age < 1.1;
ringRef.current.visible = visible;
if (visible && age !== null) {
const scale = 1 + age * 9;
ringRef.current.scale.set(scale, scale, scale);
const mat = ringRef.current.material as THREE.MeshBasicMaterial;
mat.opacity = clamp(1 - age / 1.1, 0, 1) * 0.7;
}
});
return (
<mesh ref={ringRef} rotation={[Math.PI / 2, 0, 0]} visible={false}>
<ringGeometry args={[0.6, 0.9, 32]} />
<meshBasicMaterial color="#ffd68a" transparent opacity={0} side={THREE.DoubleSide} depthWrite={false} />
</mesh>
);
}
const _camPos = new THREE.Vector3();
const _lookAt = new THREE.Vector3();
/**
* Two webcast-style camera modes:
* - follow: tight tracking shot that stays close to the vehicle, gently
* pulling back with altitude (like an onboard/tracking-dish view).
* - global: wide cinematic establishing shot that pulls far back so the
* receding pad, downrange arc and darkening sky are all in frame.
*/
function FlightCamera({ rocket, state, mode, viewScale, viewScaleResetTrigger, manualCameraRef }: { rocket: RocketConfig; state: SimulationState; mode: CameraMode; viewScale: number; viewScaleResetTrigger: number; manualCameraRef: ManualCameraRef }) {
useEffect(() => {
manualCameraRef.current = false;
}, [manualCameraRef, mode, rocket.code, viewScaleResetTrigger]);
useFrame(({ camera, size }, delta) => {
if (manualCameraRef.current) return;
const perspectiveCamera = camera as THREE.PerspectiveCamera;
if (mode === 'follow') {
const zoom = VIEW_SCALE_CALIBRATION * clamp(viewScale, 50, 250) / 100;
const portraitScale = size.width / size.height < 0.8 ? 1.55 : 1;
const pullback = (22 + clamp(state.altitude / 4000, 0, 14)) / zoom * portraitScale;
const height = (7 + clamp(state.altitude / 7000, 0, 7)) / Math.sqrt(zoom);
const pitchFromVertical = THREE.MathUtils.degToRad(90 - state.pitch);
_camPos.set(pullback * 0.32, height, pullback);
_lookAt.set(
Math.sin(pitchFromVertical) * 5.2,
Math.cos(pitchFromVertical) * 5.2,
0,
);
perspectiveCamera.fov = THREE.MathUtils.lerp(perspectiveCamera.fov, 42, 0.08);
} else {
const portrait = size.width / size.height < 0.8;
const zoom = clamp(viewScale, 50, 250) / 100;
const targetAltitude = rocket.target_orbit_km * 1000;
const launchFrame = globalFlightFrame(0, 0, targetAltitude, launchSiteForRocket(rocket));
const viewDirection = launchFrame.normal.clone()
.addScaledVector(launchFrame.tangent, -0.72)
.add(new THREE.Vector3(0, 0.16, 0))
.normalize();
_camPos.copy(EARTH_CENTER).addScaledVector(viewDirection, (portrait ? 320 : 165) / zoom);
_lookAt.copy(EARTH_CENTER);
perspectiveCamera.fov = THREE.MathUtils.lerp(perspectiveCamera.fov, 38, 0.08);
}
// Frame-rate independent smoothing.
const t = 1 - Math.pow(0.001, delta);
camera.position.lerp(_camPos, t * 0.9);
camera.lookAt(_lookAt);
perspectiveCamera.updateProjectionMatrix();
});
return null;
}
/** Sky + fog that shift color with altitude to sell the climb into space. */
function Atmosphere({ state, mode }: { state: SimulationState; mode: CameraMode }) {
useFrame(({ scene }) => {
if (mode === 'global') {
scene.background = new THREE.Color('#020408');
scene.fog = null;
return;
}
const sky = new THREE.Color(skyColor(state.altitude));
scene.background = sky;
if (!scene.fog) scene.fog = new THREE.Fog(sky, 40, 260);
const fog = scene.fog as THREE.Fog;
fog.color.set(fogColor(state.altitude));
// Thin the fog out with altitude so orbit reads crisp and dark.
const density = clamp(1 - state.altitude / 45_000, 0, 1);
fog.near = 40 + (1 - density) * 400;
fog.far = 260 + (1 - density) * 1400;
});
return null;
}
function SceneContents({ rocket, state, cameraMode, viewScale, viewScaleResetTrigger, onViewScaleChange }: RocketFlightSceneProps) {
const earthTexture = useOptionalTexture(EARTH_TEXTURE);
const manualCameraRef = useRef(false);
const orbitControlsRef = useRef<OrbitControlsImpl>(null);
const manualDistanceRef = useRef<number | null>(null);
const viewScaleRef = useRef(viewScale);
useEffect(() => {
viewScaleRef.current = viewScale;
}, [viewScale]);
const controlTarget = useMemo<[number, number, number]>(
() => cameraMode === 'follow' ? [0, 5.2, 0] : [EARTH_CENTER.x, EARTH_CENTER.y, EARTH_CENTER.z],
[cameraMode],
);
return (
<>
<Atmosphere state={state} mode={cameraMode} />
<OrbitControls
ref={orbitControlsRef}
target={controlTarget}
enablePan={false}
enableRotate
enableZoom
enableDamping
dampingFactor={0.08}
minDistance={4}
maxDistance={cameraMode === 'global' ? 500 : 100}
onStart={() => {
manualCameraRef.current = true;
manualDistanceRef.current = orbitControlsRef.current?.getDistance() ?? null;
}}
onChange={() => {
if (!manualCameraRef.current || manualDistanceRef.current === null || !orbitControlsRef.current) return;
const distance = orbitControlsRef.current.getDistance();
if (distance <= 0) return;
const nextScale = Math.round(clamp(viewScaleRef.current * manualDistanceRef.current / distance, 50, 250) / 5) * 5;
manualDistanceRef.current = distance;
if (nextScale !== viewScaleRef.current) {
viewScaleRef.current = nextScale;
onViewScaleChange(nextScale);
}
}}
/>
<FlightCamera rocket={rocket} state={state} mode={cameraMode} viewScale={viewScale} viewScaleResetTrigger={viewScaleResetTrigger} manualCameraRef={manualCameraRef} />
<ambientLight intensity={cameraMode === 'global' ? 0.3 : 0.55} />
<hemisphereLight args={['#bfe3ff', '#19150d', cameraMode === 'global' ? 0.25 : 0.6]} />
<directionalLight position={[28, 34, 45]} intensity={2.4} castShadow />
<group visible={cameraMode === 'global' || starOpacity(state.altitude) > 0.02}>
<Stars radius={420} depth={140} count={5000} factor={4.5} saturation={0} fade speed={0.35} />
</group>
{cameraMode === 'follow' ? (
<>
<LaunchRack state={state} />
<EarthSurface texture={earthTexture} state={state} />
<FollowVehicle rocket={rocket} state={state} />
</>
) : (
<GlobalEarthView rocket={rocket} state={state} texture={earthTexture} />
)}
</>
);
}
export function RocketFlightScene({ rocket, state, cameraMode, viewScale, viewScaleResetTrigger, onViewScaleChange }: RocketFlightSceneProps) {
const initialSky = useMemo(() => skyColor(0), []);
return (
<div className="rocket-flight-scene" aria-label="火箭飞行三维动画">
<Canvas
shadows
dpr={[1, 2]}
camera={{ position: [0, 7, 32], fov: 42, near: 0.1, far: 4000 }}
gl={{ antialias: true }}
onCreated={({ scene, camera }) => {
scene.background = new THREE.Color(initialSky);
camera.lookAt(0, 5.2, 0);
}}
>
<SceneContents rocket={rocket} state={state} cameraMode={cameraMode} viewScale={viewScale} viewScaleResetTrigger={viewScaleResetTrigger} onViewScaleChange={onViewScaleChange} />
</Canvas>
</div>
);
}