From 09bc6fe02c29e9f0c6286c29a13a4ec0254e71ab Mon Sep 17 00:00:00 2001 From: itsrubberduck Date: Sat, 21 Feb 2026 00:13:47 +0100 Subject: [PATCH] feat(flightlab): add moving cloud field for speed heading and altitude cues --- .../flightlab/pfd/PfdAircraftModel.vue | 179 ++++++++++++++++-- public/models/Cloud.glb | Bin 0 -> 8340 bytes 2 files changed, 165 insertions(+), 14 deletions(-) create mode 100644 public/models/Cloud.glb diff --git a/app/components/flightlab/pfd/PfdAircraftModel.vue b/app/components/flightlab/pfd/PfdAircraftModel.vue index b3b3ae1..d41dd74 100644 --- a/app/components/flightlab/pfd/PfdAircraftModel.vue +++ b/app/components/flightlab/pfd/PfdAircraftModel.vue @@ -17,20 +17,37 @@ const props = defineProps<{ const containerRef = ref(null) const modelUrls = ['/models/airplane-user.glb', '/models/Airplane.glb', '/models/a320.glb'] +const cloudModelUrls = ['/models/cloud-user.glb', '/models/Cloud.glb'] + +const CLOUD_COUNT = 26 +const CLOUD_AREA_X = 120 +const CLOUD_AREA_Z = 150 +const CLOUD_BASE_Y = -8 let renderer: THREE.WebGLRenderer | null = null let scene: THREE.Scene | null = null let camera: THREE.PerspectiveCamera | null = null let aircraft: THREE.Group | null = null let cloudPlane: THREE.Mesh | null = null +let cloudField: THREE.Group | null = null let animFrameId: number | null = null let resizeObserver: ResizeObserver | null = null let disposed = false +let lastFrameTime: number | null = null +let initialAltitude = props.altitude // Reactive targets for smooth interpolation let targetPitchRad = 0 let targetBankRad = 0 +type CloudInstance = { + object: THREE.Object3D + baseY: number + driftScale: number +} + +const cloudInstances: CloudInstance[] = [] + function buildAircraft(): THREE.Group { // Three.js coords: X=right, Y=up, Z=toward viewer // Aircraft: nose toward -Z, wings along X, Y is up @@ -93,6 +110,110 @@ function buildAircraft(): THREE.Group { return group } +function randomRange(min: number, max: number): number { + return min + Math.random() * (max - min) +} + +function tuneCloudMaterial(material: THREE.Material) { + if ( + material instanceof THREE.MeshStandardMaterial + || material instanceof THREE.MeshPhongMaterial + || material instanceof THREE.MeshLambertMaterial + || material instanceof THREE.MeshBasicMaterial + ) { + material.transparent = true + material.depthWrite = false + material.opacity = Math.min(material.opacity, 0.74) + material.color.multiplyScalar(1.05) + } +} + +function normalizeCloudModel(model: THREE.Object3D, targetSize = 10) { + const box = new THREE.Box3().setFromObject(model) + const size = new THREE.Vector3() + box.getSize(size) + const maxDim = Math.max(size.x, size.y, size.z) + if (maxDim > 0) { + model.scale.setScalar(targetSize / maxDim) + } + + const centeredBox = new THREE.Box3().setFromObject(model) + const center = new THREE.Vector3() + centeredBox.getCenter(center) + model.position.sub(center) + + model.traverse((obj) => { + if (!(obj instanceof THREE.Mesh)) return + if (Array.isArray(obj.material)) { + obj.material.forEach(m => tuneCloudMaterial(m)) + } else { + tuneCloudMaterial(obj.material) + } + }) +} + +function createFallbackCloudLayer() { + if (!scene) return + const cloudGeo = new THREE.PlaneGeometry(260, 260) + const cloudMat = new THREE.MeshStandardMaterial({ + color: 0xffffff, + transparent: true, + opacity: 0.3, + side: THREE.DoubleSide, + depthWrite: false, + }) + cloudPlane = new THREE.Mesh(cloudGeo, cloudMat) + cloudPlane.rotation.x = -Math.PI / 2 + cloudPlane.position.y = CLOUD_BASE_Y + scene.add(cloudPlane) +} + +async function loadCloudField() { + if (!scene || disposed) return + + const loader = new GLTFLoader() + let cloudTemplate: THREE.Object3D | null = null + + for (const url of cloudModelUrls) { + try { + const gltf = await loader.loadAsync(url) + cloudTemplate = gltf.scene + break + } catch { + // Try next cloud model URL + } + } + + if (!cloudTemplate || disposed || !scene) { + createFallbackCloudLayer() + return + } + + normalizeCloudModel(cloudTemplate, 10) + cloudField = new THREE.Group() + scene.add(cloudField) + + cloudInstances.length = 0 + for (let i = 0; i < CLOUD_COUNT; i++) { + const cloud = cloudTemplate.clone(true) + const baseY = CLOUD_BASE_Y + randomRange(-3.5, 8.5) + const scale = randomRange(0.55, 1.45) + cloud.scale.multiplyScalar(scale) + cloud.position.set( + randomRange(-CLOUD_AREA_X, CLOUD_AREA_X), + baseY, + randomRange(-CLOUD_AREA_Z, CLOUD_AREA_Z), + ) + cloud.rotation.y = randomRange(0, Math.PI * 2) + cloudField.add(cloud) + cloudInstances.push({ + object: cloud, + baseY, + driftScale: randomRange(0.7, 1.55), + }) + } +} + function centerAndScaleModel(model: THREE.Object3D, targetSize = 5) { const box = new THREE.Box3().setFromObject(model) const size = new THREE.Vector3() @@ -166,19 +287,6 @@ function initScene() { aircraft = new THREE.Group() scene.add(aircraft) - // Cloud plane - const cloudGeo = new THREE.PlaneGeometry(200, 200) - const cloudMat = new THREE.MeshStandardMaterial({ - color: 0xffffff, - transparent: true, - opacity: 0.3, - side: THREE.DoubleSide, - }) - cloudPlane = new THREE.Mesh(cloudGeo, cloudMat) - cloudPlane.rotation.x = -Math.PI / 2 - cloudPlane.position.y = -8 - scene.add(cloudPlane) - // Renderer renderer = new THREE.WebGLRenderer({ antialias: true }) renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) @@ -204,8 +312,43 @@ function lerp(current: number, target: number, factor: number): number { return current + (target - current) * factor } -function animate() { +function updateCloudMotion(dt: number) { + const headingRad = (props.heading * Math.PI) / 180 + const forwardX = Math.sin(headingRad) + const forwardZ = -Math.cos(headingRad) + const speedFactor = Math.max(0.35, props.speed / 220) + const driftSpeed = 9 * speedFactor + const pitchRad = (props.pitch * Math.PI) / 180 + const verticalDrift = Math.sin(pitchRad) * driftSpeed * 0.4 + const altitudeOffset = (initialAltitude - props.altitude) * 0.008 + + if (cloudInstances.length > 0) { + for (const cloud of cloudInstances) { + cloud.object.position.x -= forwardX * driftSpeed * cloud.driftScale * dt + cloud.object.position.z -= forwardZ * driftSpeed * cloud.driftScale * dt + + const targetY = cloud.baseY + altitudeOffset + cloud.object.position.y = lerp(cloud.object.position.y, targetY, 0.06) + cloud.object.rotation.y += 0.02 * cloud.driftScale * dt + + if (cloud.object.position.x > CLOUD_AREA_X) cloud.object.position.x -= CLOUD_AREA_X * 2 + if (cloud.object.position.x < -CLOUD_AREA_X) cloud.object.position.x += CLOUD_AREA_X * 2 + if (cloud.object.position.z > CLOUD_AREA_Z) cloud.object.position.z -= CLOUD_AREA_Z * 2 + if (cloud.object.position.z < -CLOUD_AREA_Z) cloud.object.position.z += CLOUD_AREA_Z * 2 + } + } else if (cloudPlane) { + cloudPlane.position.y = lerp(cloudPlane.position.y, CLOUD_BASE_Y + altitudeOffset, 0.05) + cloudPlane.position.x -= forwardX * driftSpeed * dt * 0.25 + cloudPlane.position.z -= forwardZ * driftSpeed * dt * 0.25 + cloudPlane.rotation.z += verticalDrift * dt * 0.002 + } +} + +function animate(frameTime = 0) { animFrameId = requestAnimationFrame(animate) + if (lastFrameTime === null) lastFrameTime = frameTime + const dt = Math.min((frameTime - lastFrameTime) / 1000, 0.06) + lastFrameTime = frameTime if (aircraft) { // Aircraft: nose toward -Z, wings along X, Y is up @@ -215,6 +358,8 @@ function animate() { aircraft.rotation.z = lerp(aircraft.rotation.z, -targetBankRad, 0.1) } + updateCloudMotion(dt) + if (renderer && scene && camera) { renderer.render(scene, camera) } @@ -234,14 +379,20 @@ watch( onMounted(() => { disposed = false + lastFrameTime = null + initialAltitude = props.altitude initScene() updateTargets() loadAircraftModel() + loadCloudField() animate() }) onBeforeUnmount(() => { disposed = true + lastFrameTime = null + cloudInstances.length = 0 + cloudField = null // Cancel animation frame if (animFrameId !== null) { cancelAnimationFrame(animFrameId) diff --git a/public/models/Cloud.glb b/public/models/Cloud.glb new file mode 100644 index 0000000000000000000000000000000000000000..49e27452877623380c2bcec5325d28e66d801947 GIT binary patch literal 8340 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 d%mCbb2udc%6@MGGM0LeqX`#qJ