diff --git a/atmosphere/package.json b/atmosphere/package.json index 1aefc5572..61493e406 100644 --- a/atmosphere/package.json +++ b/atmosphere/package.json @@ -15,6 +15,7 @@ "@react-three/postprocessing": "^3.0.4", "@takram/three-atmosphere": "^0.15.1", "@takram/three-clouds": "^0.5.2", + "3d-tiles-renderer": "^0.4.18", "react": "^19.0.0-rc.1", "react-dom": "^19.0.0-rc.1", "three": "^0.181.2" diff --git a/atmosphere/public/favicon.ico b/atmosphere/public/favicon.ico new file mode 100644 index 000000000..7f94540e2 Binary files /dev/null and b/atmosphere/public/favicon.ico differ diff --git a/atmosphere/src/App.jsx b/atmosphere/src/App.jsx index 3f3a54ac3..57dbd54e7 100644 --- a/atmosphere/src/App.jsx +++ b/atmosphere/src/App.jsx @@ -12,9 +12,20 @@ import * as THREE from 'three'; // Takram Libraries import { AerialPerspective, Atmosphere } from '@takram/three-atmosphere/r3f'; import { Clouds, CloudLayer } from '@takram/three-clouds/r3f'; +import { Geodetic, PointOfView, radians } from '@takram/three-geospatial'; + +// 3D Tiles Renderer +import { TilesPlugin, TilesRenderer } from '3d-tiles-renderer/r3f'; +import { + GLTFExtensionsPlugin, + GoogleCloudAuthPlugin, + TileCompressionPlugin, + TilesFadePlugin, + UpdateOnChangePlugin, +} from '3d-tiles-renderer/plugins'; +import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader'; const BASE_URL = import.meta.env.BASE_URL; - const VRM_URL = `${BASE_URL}ai.vrm`; const VRMA_URL = `${BASE_URL}fly.vrma`; const EARTH_RADIUS = 6378137; @@ -59,23 +70,101 @@ const WEATHER_PRESETS = [ } ]; +// --- Locations --- +// --- Locations --- +const LOCATIONS = [ + { name: 'Tokyo', longitude: 139.7671, latitude: 35.6812, heading: 180, pitch: -5, distance: 300 }, + { name: 'Fuji', longitude: 138.7278, latitude: 35.3606, heading: 0, pitch: -10, distance: 4000 }, + { name: 'Space', longitude: 139.7671, latitude: 35.6812, heading: 0, pitch: -90, distance: 10000000 }, +]; + // --- Shared State for Synchronization --- // 複数のCanvas間で状態を共有するための簡易ストア const worldState = { - // 世界座標 (Atmosphere内での位置) + // 世界座標 (Atmosphere内での位置 - ECEF) position: new Vector3(0, EARTH_RADIUS + 2000, 0), - // カメラの回転 (AvatarLayerのカメラ回転をAtmosphereに同期) + // ローカル回転 (AvatarLayerでの回転 - Y-up) + // Note: This is treated as the "Local" quaternion. quaternion: new Quaternion(), // 移動速度 (m/s) - speed: 5000.0, + speed: 1000.0, }; +// Helper: Calculate Basis Rotation (Alignment to Surface Normal) +function getSurfaceBasis(position) { + if (!position) return new Quaternion(); + // Debug Geodetic API + if (!window.geodeticLogged) { + console.log('Geodetic prototype:', Geodetic.prototype); + window.geodeticLogged = true; + } + const up = position.clone().normalize(); + if (up.lengthSq() < 0.1) return new Quaternion(); // Safety check + const basis = new Quaternion().setFromUnitVectors(new Vector3(0, 1, 0), up); + return basis; +} + +// Helper: Calculate Ellipsoid Radius at current position (WGS84) +function getEllipsoidRadius(position) { + const a = 6378137.0; + const b = 6356752.314245; + const r = position.length(); + if (r < 100) return a; // Fallback + + // Assuming Z is North in ECEF (Standard) + const z = position.z; + const sinPhi = z / r; + const cosPhi = Math.sqrt(1 - sinPhi * sinPhi); + + const aCos = a * cosPhi; + const bSin = b * sinPhi; + const num = (a * aCos) * (a * aCos) + (b * bSin) * (b * bSin); + const den = (aCos * aCos) + (bSin * bSin); + + return Math.sqrt(num / den); +} + +// Teleport Function +function teleportTo(location) { + const { longitude, latitude, heading, pitch, distance } = location; + const position = new Vector3(); + const globalQuaternion = new Quaternion(); // Global rotation + const up = new Vector3(0, 1, 0); + + // 1. Calculate True Position (at target lat/lon and altitude) + // Treat 'distance' as altitude in meters + const truePosition = new Geodetic(radians(longitude), radians(latitude), distance).toECEF(); + + // 2. Calculate Rotation using PointOfView + // We use PointOfView to get the correct orientation (looking at the target). + // We ignore the position calculated by PointOfView because it assumes an orbit/look-at constraint. + new PointOfView(distance, radians(heading), radians(pitch)).decompose( + new Geodetic(radians(longitude), radians(latitude)).toECEF(), + position, // Dummy position (ignored) + globalQuaternion, + up + ); + + console.log(`[Teleport] ${location.name} -> Altitude: ${distance}, TruePosLength: ${truePosition.length()}`); + + // 3. Apply True Position + worldState.position.copy(truePosition); + + // 4. Apply Rotation (Global -> Local) + // GlobalQ = BasisQ * LocalQ => LocalQ = BasisQ^-1 * GlobalQ + const basis = getSurfaceBasis(truePosition); + const basisInv = basis.clone().invert(); + worldState.quaternion.copy(basisInv.multiply(globalQuaternion)); +} + // キー入力管理 const keys = { w: false, a: false, s: false, d: false, + q: false, + e: false, Shift: false, Space: false, }; @@ -100,6 +189,25 @@ if (typeof window !== 'undefined') { // Scene Components (Inside Canvas) // --------------------------------------------------------- +const dracoLoader = new DRACOLoader(); +dracoLoader.setDecoderPath('https://www.gstatic.com/draco/v1/decoders/'); + +function GoogleMaps3DTiles() { + const apiKey = import.meta.env.VITE_GOOGLE_MAP_API_KEY; + + if (!apiKey) return null; + + return ( + + + + + + + + ); +} + // Canvasの内側で動作するメインシーンコンポーネント function AtmosphereScene() { const { gl } = useThree(); @@ -175,6 +283,7 @@ function AtmosphereScene() { /> + {weather.layers.map((layer, index) => ( @@ -199,9 +308,17 @@ function AtmosphereScene() { // Atmosphere側のカメラをShared Stateに同期させる function FollowCamera() { useFrame((state) => { - // Shared Stateの位置と回転を適用 + if (!worldState.position) return; + + // 1. Calculate Basis Rotation based on current position + const basis = getSurfaceBasis(worldState.position); + + // 2. Apply Position state.camera.position.copy(worldState.position); - state.camera.quaternion.copy(worldState.quaternion); + + // 3. Apply Rotation: GlobalQ = BasisQ * LocalQ + // worldState.quaternion is treated as Local Quaternion here + state.camera.quaternion.copy(basis).multiply(worldState.quaternion); }); return null; } @@ -362,7 +479,6 @@ function VrmCharacter() { function CameraSync() { const { camera } = useThree(); const vec = new Vector3(); - const euler = new Euler(0, 0, 0, 'YXZ'); // Dynamic Zoom State const zoomOffset = useRef(0); @@ -370,34 +486,64 @@ function CameraSync() { const ZOOM_SPEED = 2.0; useFrame((state, delta) => { - // 1. カメラの回転をShared Stateに同期 + // 1. カメラの回転をShared State (Local) に同期 worldState.quaternion.copy(camera.quaternion); - // 2. WASD移動ロジック - const isMoving = keys.w || keys.s || keys.a || keys.d; + // 2. Calculate Basis Rotation + const basis = getSurfaceBasis(worldState.position); + + // 3. WASD移動ロジック (Local -> Global) + const isMoving = keys.w || keys.s || keys.a || keys.d || keys.q || keys.e; const speed = worldState.speed * (keys.Shift ? 2.0 : 1.0) * delta; - // 前後 (W/S) - if (keys.w) { - vec.set(0, 0, -1).applyQuaternion(camera.quaternion); - worldState.position.addScaledVector(vec, speed); + // Local Movement Vector + const moveDir = new Vector3(); + if (keys.w) moveDir.z -= 1; + if (keys.s) moveDir.z += 1; + if (keys.a) moveDir.x -= 1; + if (keys.d) moveDir.x += 1; + + // Altitude Control (Global Up/Down relative to surface) + // We apply this directly to worldState.position along the up vector + const upVector = worldState.position.clone().normalize(); + if (keys.e) { + worldState.position.addScaledVector(upVector, speed); } - if (keys.s) { - vec.set(0, 0, 1).applyQuaternion(camera.quaternion); - worldState.position.addScaledVector(vec, speed); + if (keys.q) { + worldState.position.addScaledVector(upVector, -speed); } - // 左右 (A/D) - if (keys.a) { - vec.set(-1, 0, 0).applyQuaternion(camera.quaternion); - worldState.position.addScaledVector(vec, speed); - } - if (keys.d) { - vec.set(1, 0, 0).applyQuaternion(camera.quaternion); - worldState.position.addScaledVector(vec, speed); + if (moveDir.lengthSq() > 0) { + moveDir.normalize(); + // Apply Camera Rotation (Local) + moveDir.applyQuaternion(camera.quaternion); + + // Apply Basis Rotation (Local -> Global) + moveDir.applyQuaternion(basis); + + // Apply to Global Position + worldState.position.addScaledVector(moveDir, speed); } - // 3. Dynamic Zoom Logic + // 4. Minimum Altitude Safety Check + const currentDist = worldState.position.length(); + + // Calculate local ellipsoid radius + const localRadius = getEllipsoidRadius(worldState.position); + + const minAltitude = localRadius + 10; // Minimum 10m above ellipsoid + + // Debug Log + if (state.clock.elapsedTime % 1.0 < 0.02) { + console.log(`[CameraSync] Dist: ${currentDist.toFixed(1)}, LocalR: ${localRadius.toFixed(1)}, Alt: ${(currentDist - localRadius).toFixed(1)}`); + } + + if (currentDist < minAltitude) { + // console.log(`[CameraSync] Safety Check Triggered! Pushing up to ${minAltitude}`); + worldState.position.setLength(minAltitude); + } + + // 5. Dynamic Zoom Logic const targetZoom = isMoving ? MAX_ZOOM_OFFSET : 0; // Smoothly interpolate current zoom offset towards target const diff = targetZoom - zoomOffset.current; @@ -429,6 +575,45 @@ function AvatarLayer() { ); } +// --------------------------------------------------------- +// UI Components +// --------------------------------------------------------- +function LocationUI() { + return ( +
+ {LOCATIONS.map((loc) => ( + + ))} +
+ ); +} + // --------------------------------------------------------- // Main App // --------------------------------------------------------- @@ -453,6 +638,9 @@ export default function App() { return (
+ {/* UI Layer */} + + {/* Layer 0: Atmosphere */}