// 顯示真實 iris 機體 + 平滑機體內插 + 訓練視角(自由/LOS觀察者/FPV/第三人稱跟隨)。 // gzbridge 的位姿封包是離散步進(~10-50Hz),機體位姿每幀朝最新封包內插 // (位置 lerp + 姿態 slerp),機體本身不跳格;各視角的鏡頭再各自平滑。 // gzweb 內建 OrbitControls 的 update() 把 lookAt 註解掉了,轉向必須自己 lookAt。 // 視角: // free = 自由(滑鼠 orbit,預設) // los = 地面觀察者:鏡頭定在啟用當下位置的人眼高(1.7m),只轉頭追無人機 // (視距內考照的真實視覺:飛遠就是會變小,練距離/方位感) // fpv = 第一人稱:鏡頭綁機頭朝前,跟著機身俯仰滾轉(FPV 飛手視角) // chase = 第三人稱跟隨:阻尼漸進追,沿用切換當下的視角偏移(教學觀摩用) (function () { var DRONE_SCALE = 1.8; // 機體視覺放大(純顯示;物理/碰撞不變)。zyDroneScale(v) 可調 var mode = 'free'; var framedOnce = false; var followOffset = null; // chase:切換當下的 鏡頭-機體 偏移 var obsPos = null; // los:觀察者固定位置 var DEF_OFFSET = { x: -4, y: -4, z: 2.2 }; // 機體平滑狀態:raw = gzbridge 最新封包值(以「與上次自己寫入值不同」偵測) var rawPos = null, rawQuat = null, lastWritten = null; var camPos = null, camTgt = null; // chase/los 鏡頭平滑狀態 var lastT = null; function findDrone() { var s = window.scene && window.scene.scene; if (!s) return null; var d = s.getObjectByName('iris'); if (d) return d; var found = null; s.traverse(function (o) { if (!found && o.name && /^iris/i.test(o.name)) found = o; }); return found; } // iris 機體套深灰材質(真 iris 是深灰碳纖;白色 fallback 在天空下看不見) function styleDrone(d, THREE) { d.traverse(function (o) { if (o.isMesh && o.geometry && !o.__zyStyled) { o.__zyStyled = true; o.material = new THREE.MeshPhongMaterial({ color: 0x33373d, specular: 0x222428, shininess: 18 }); o.castShadow = true; o.receiveShadow = true; } }); } // 指數阻尼係數:與幀率無關的平滑(k 越大追越緊) function damp(dt, k) { return 1 - Math.exp(-k * dt); } function smoothDrone(d, THREE, dt) { if (!rawPos) { rawPos = d.position.clone(); rawQuat = d.quaternion.clone(); lastWritten = { p: d.position.clone(), q: d.quaternion.clone() }; return; } if (!d.position.equals(lastWritten.p)) rawPos.copy(d.position); // 新位置封包 if (!d.quaternion.equals(lastWritten.q)) rawQuat.copy(d.quaternion); // 新姿態封包 d.position.lerp(rawPos, damp(dt, 14)); d.quaternion.slerp(rawQuat, damp(dt, 14)); lastWritten.p.copy(d.position); lastWritten.q.copy(d.quaternion); } // 首次入鏡(一次性硬切,之後才有東西可平滑) function snapAim(p) { var sc = window.scene, ctl = sc.controls, cam = sc.camera; if (!ctl || !cam) return false; ctl.target.set(p.x, p.y, p.z); cam.position.set(p.x + DEF_OFFSET.x, p.y + DEF_OFFSET.y, p.z + DEF_OFFSET.z); cam.lookAt(ctl.target); ctl.update(); return true; } // chase:飛行方向追隨(AirSim 式)。鏡頭鎖在「後方 dist、上方 height」,朝向取 // ①移動中(>1.2m/s)=水平「速度方向」——PX4 位置模式右搖桿是平移、機頭不轉, // 要用飛行方向鏡頭才會跟著飛行軌跡轉;②懸停=機頭方向(左搖桿轉向也會帶動)。 // faceDir 自身再阻尼,方向切換/速度抖動不會甩鏡頭。 var chaseDist = 7, chaseHeight = 2.5; var faceDir = null, velEst = null, lastP = null; function chaseAim(d, p, THREE, dt) { var sc = window.scene, ctl = sc.controls, cam = sc.camera; if (!camTgt) { camTgt = ctl.target.clone(); camPos = cam.position.clone(); } // 水平速度估計(平滑後位置的差分,再阻尼) if (!velEst) velEst = new THREE.Vector3(); if (lastP && dt > 0.001) { velEst.lerp(new THREE.Vector3((p.x - lastP.x) / dt, (p.y - lastP.y) / dt, 0), damp(dt, 4)); } lastP = { x: p.x, y: p.y }; // 機頭水平方向(幾乎垂直時視為無效) var fwd = new THREE.Vector3(1, 0, 0).applyQuaternion(d.quaternion); fwd.z = 0; if (fwd.length() < 0.05) fwd.set(1, 0, 0); fwd.normalize(); var spd = Math.hypot(velEst.x, velEst.y); var want = spd > 1.2 ? velEst.clone().setZ(0).normalize() : fwd; if (!faceDir) faceDir = want.clone(); faceDir.lerp(want, damp(dt, 2.5)); if (faceDir.length() < 0.05) faceDir.copy(want); else faceDir.normalize(); var wantTgt = new THREE.Vector3(p.x + faceDir.x * 2, p.y + faceDir.y * 2, p.z); var wantPos = new THREE.Vector3( p.x - faceDir.x * chaseDist, p.y - faceDir.y * chaseDist, p.z + chaseHeight); camTgt.lerp(wantTgt, damp(dt, 6)); camPos.lerp(wantPos, damp(dt, 2.8)); ctl.target.copy(camTgt); cam.position.copy(camPos); cam.lookAt(ctl.target); ctl.update(); } // los:位置釘死在觀察者點,只平滑轉頭追(無人機飛遠變小是刻意保留的訓練要素)。 // 視線瞄準點壓在機體下方(z*0.55):機體落在畫面上 1/3、地景佔下半部, // 若直接置中,仰角>30° 時地平線會被 FOV 下緣切掉 → 滿屏天空沒有參照。 function losAim(p, THREE, dt) { var sc = window.scene, ctl = sc.controls, cam = sc.camera; if (!camTgt) camTgt = ctl.target.clone(); camTgt.lerp(new THREE.Vector3(p.x, p.y, Math.max(1.7, p.z * 0.55)), damp(dt, 8)); ctl.target.copy(camTgt); cam.position.copy(obsPos); cam.lookAt(ctl.target); ctl.update(); } // fpv:鏡頭綁機頭(機體座標 x 前),up 跟機身滾轉,含 ~10° 上仰(FPV 慣例) function fpvAim(d, THREE) { var sc = window.scene, ctl = sc.controls, cam = sc.camera; var q = d.quaternion; var fwd = new THREE.Vector3(1, 0, 0).applyQuaternion(q); var up = new THREE.Vector3(0, 0, 1).applyQuaternion(q); var sc2 = d.scale.x || 1; // 機體放大時鏡頭掛載點等比外移,避免看到自己機殼 var mount = d.position.clone() .add(fwd.clone().multiplyScalar(0.3 * sc2)) .add(up.clone().multiplyScalar(0.1 * sc2)); var look = mount.clone().add(fwd.multiplyScalar(10)).add(up.clone().multiplyScalar(0.5)); cam.up.copy(up); cam.position.copy(mount); cam.lookAt(look); if (ctl) ctl.target.copy(look); // 退出 fpv 後 controls 狀態不錯亂 } function loop(t) { var THREE = window.THREE; var dt = lastT == null ? 0.016 : Math.min(0.1, (t - lastT) / 1000); lastT = t; var d = findDrone(); if (d && THREE) { styleDrone(d, THREE); if (Math.abs(d.scale.x - DRONE_SCALE) > 0.01) d.scale.set(DRONE_SCALE, DRONE_SCALE, DRONE_SCALE); smoothDrone(d, THREE, dt); var p = d.getWorldPosition ? d.getWorldPosition(new THREE.Vector3()) : d.position; if (!framedOnce) { if (snapAim(p)) framedOnce = true; } else if (mode === 'chase') chaseAim(d, p, THREE, dt); else if (mode === 'los') losAim(p, THREE, dt); else if (mode === 'fpv') fpvAim(d, THREE); } requestAnimationFrame(loop); } window.zySetView = function (m) { if (['free', 'los', 'fpv', 'chase'].indexOf(m) < 0) m = 'free'; var sc = window.scene, cam = sc && sc.camera; if (mode === 'fpv' && m !== 'fpv' && cam) cam.up.set(0, 0, 1); // 離開 fpv 還原 up mode = m; camTgt = null; camPos = null; // 從當下鏡頭狀態開始漸進 var d = findDrone(); if (m === 'chase') { // 沿用當下鏡頭與機體的「距離/高度」(方位改由機體航向決定,不再固定世界方位) if (cam && d) { var off = cam.position.clone().sub(d.position); var hor = Math.hypot(off.x, off.y); chaseDist = (hor > 3 && hor < 30) ? hor : 7; chaseHeight = (off.z > 0.5 && off.z < 15) ? off.z : 2.5; } else { chaseDist = 7; chaseHeight = 2.5; } } else if (m === 'los') { // 觀察者站在無人機「水平 35m 外」的地面(人眼高 1.7m): // 若直接用鏡頭投影點,常落在機體正下方 → 抬頭 85° 只剩天空,毫無地景參照。 var T3 = window.THREE; var dp = d ? d.position : new T3.Vector3(0, 0, 0); var dir = cam ? new T3.Vector3(dp.x - cam.position.x, dp.y - cam.position.y, 0) : new T3.Vector3(1, 1, 0); if (dir.length() < 1) dir.set(1, 1, 0); dir.normalize(); // 距離隨高度縮放:仰角壓在 ~37° 以下,地平線/地景一定入鏡 var dist = Math.max(35, (dp.z || 0) * 1.35); obsPos = new T3.Vector3(dp.x - dir.x * dist, dp.y - dir.y * dist, 1.7); } return mode; }; window.zyGetView = function () { return mode; }; window.zyDroneScale = function (v) { if (v) DRONE_SCALE = Math.max(0.5, Math.min(4, v)); var d = findDrone(); if (d) d.scale.set(DRONE_SCALE, DRONE_SCALE, DRONE_SCALE); return DRONE_SCALE; }; // 舊 API 相容(浮動面板舊版按鈕) window.zyToggleFollow = function () { return window.zySetView(mode === 'chase' ? 'free' : 'chase') === 'chase'; }; var wait = setInterval(function () { if (window.scene && window.scene.scene && window.scene.controls && window.THREE) { clearInterval(wait); requestAnimationFrame(loop); } }, 400); })();