Skip to content

Commit c3be476

Browse files
committed
fix(3d): 相机飞行视角错位与渲染循环冻结修复
- flyTo 增加注视点参数,飞行期间朝向同步转向目标;此前只插值位置不改朝向,叠加 docked 渲染冻结导致停靠后视角错位、正对空白 - 相机飞行统一由相位迁移响应式驱动,覆盖 3D 点击/数字键/路由同步全部入口;点击入口记录行星实时坐标防公转漂移 - 新增 LoopResumer:frameloop 从 never 切回 always 时显式 invalidate 唤醒渲染循环,修复多次切换后画面永久冻结 - 停靠延迟下限 clamp 到 700ms(飞行 600ms),防静谧档飞行中途冻结相机停在半途 - exitModule 清除悬停态;isActive 结合相位判断,Esc 退出后不再残留选中视觉 - 附知识库 bug 复盘文档与索引更新
1 parent 875831c commit c3be476

7 files changed

Lines changed: 292 additions & 21 deletions

File tree

‎client/src/lib/3d/core/SceneProvider.tsx‎

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,16 @@
1111
* 导致 GPU 合成风暴(内测反馈“主页流畅但切换其他页面卡顿”的根因)。
1212
* 暂停后画面冻结为静态背景,blur 仅计算一次即被合成器缓存,视觉设计不变。
1313
* - entering → docked 的停靠延迟由性能档位决定(静谧=0/从容=900ms/澎湃=1300ms),
14-
* 保证相机飞行到位后才暂停渲染,避免生硬冻结。
14+
* 保证相机飞行到位后才暂停渲染,避免生硬冻结;下限 clamp 到 700ms(飞行时长
15+
* 600ms),防止静谧档在飞行中途冻结导致相机停在半途、停靠视角错位。
1516
* - PerformanceMonitor 仅 overview 相位测量:entering 的飞行帧率不代表设备能力,docked 无帧率可言。
17+
* - 帧循环唤醒:R3F 从 frameloop='never' 切回 'always' 时不会自动重启渲染循环
18+
* (循环停止后无帧可检查新值,且 setFrameloop 不调 invalidate),
19+
* 需由 LoopResumer 在相位迁移后显式唤醒,否则多次切换后画面永久冻结。
1620
*
1721
* @ai-context: 3D 场景核心(R3F):SceneProvider。
1822
*/
19-
import { Canvas } from '@react-three/fiber';
23+
import { Canvas, useThree } from '@react-three/fiber';
2024
import { Suspense, useEffect } from 'react';
2125
import { Preload } from '@react-three/drei';
2226
import { PerformanceMonitor } from './PerformanceMonitor';
@@ -34,17 +38,41 @@ interface SceneProviderProps {
3438
interactive?: boolean;
3539
}
3640

41+
/**
42+
* 渲染循环唤醒器 — 修复“多次切换功能后页面无渲染”
43+
*
44+
* R3F 的循环在 frameloop='never' 下会 cancelAnimationFrame 彻底停止;
45+
* 切回 'always' 时 setFrameloop 只改 store 值,不会重启循环,
46+
* 而期间所有 invalidate() 又因 frameloop 尚为 'never' 被早退丢弃。
47+
* 因此在相位/档位迁移后(此时 frameloop prop 已应用)显式 invalidate:
48+
* 'always' 下 render 每帧返回 1,一次唤醒即可让循环永续运行;
49+
* docked('never')时 invalidate 自动早退,无副作用。
50+
*/
51+
function LoopResumer() {
52+
const invalidate = useThree((s) => s.invalidate);
53+
const phase = useOrbitalStore((s) => s.phase);
54+
const mode = usePerformanceModeStore((s) => s.mode);
55+
56+
useEffect(() => {
57+
invalidate();
58+
}, [phase, mode, invalidate]);
59+
60+
return null;
61+
}
62+
3763
export function SceneProvider({ children, interactive = false }: SceneProviderProps) {
3864
const phase = useOrbitalStore((s) => s.phase);
3965
const currentModule = useOrbitalStore((s) => s.currentModule);
4066
const mode = usePerformanceModeStore((s) => s.mode);
4167

42-
// entering → docked 停靠计时器:相机飞行过渡结束后才暂停渲染(相位/模块/档位变化时重置)
68+
// entering → docked 停靠计时器:相机飞行过渡结束后才暂停渲染(相位/模块/档位变化时重置)。
69+
// 下限 clamp 到 700ms(相机飞行固定 600ms):静谧档 dockDelayMs=0 会在飞行中途
70+
// 冻结渲染(frameloop='never'),相机停在半途且朝向插值未完成,停靠后视角错位
4371
useEffect(() => {
4472
if (phase !== 'entering') return;
4573
const timer = setTimeout(
4674
() => useOrbitalStore.getState().dock(),
47-
PERFORMANCE_MODE_CONFIG[mode].dockDelayMs,
75+
Math.max(PERFORMANCE_MODE_CONFIG[mode].dockDelayMs, 700),
4876
);
4977
return () => clearTimeout(timer);
5078
}, [phase, currentModule, mode]);
@@ -82,6 +110,7 @@ export function SceneProvider({ children, interactive = false }: SceneProviderPr
82110
>
83111
{/* FPS 自动降档仅在概览态测量(entering 飞行帧率不代表设备能力,docked 无帧率可言) */}
84112
{phase === 'overview' && <PerformanceMonitor />}
113+
<LoopResumer />
85114
<QualityController />
86115
<MemoryManager />
87116
<ContextRecovery />
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/**
2+
* useCameraFlight 单元测试
3+
*
4+
* 重点覆盖"飞行结束时相机朝向正对注视目标"——此前 flyTo 只插值 position
5+
* 不改朝向,用户用 OrbitControls 旋转过视角后点击模块,相机抵达后仍保持
6+
* 旧朝向,叠加 docked 渲染冻结导致停靠视角错位(正对空白处)。
7+
*
8+
* @ai-context: useCameraFlight hook tests: verifies position flight and
9+
* quaternion slerp orientation toward lookTarget on arrival.
10+
*/
11+
import { describe, it, expect, vi, beforeEach } from 'vitest';
12+
import { renderHook, act } from '@testing-library/react';
13+
import * as THREE from 'three';
14+
15+
// 通过 hoisted 持有可变相机实例,供 vi.mock 工厂(被提升)引用
16+
const { cameraHolder } = vi.hoisted(() => ({
17+
cameraHolder: { camera: null as unknown as THREE.PerspectiveCamera },
18+
}));
19+
20+
vi.mock('@react-three/fiber', () => ({
21+
useThree: () => ({ camera: cameraHolder.camera }),
22+
}));
23+
24+
import { useCameraFlight } from './useCameraFlight';
25+
26+
beforeEach(() => {
27+
const cam = new THREE.PerspectiveCamera();
28+
cam.position.set(0, 0, 10);
29+
cam.lookAt(0, 0, 0);
30+
cam.updateMatrixWorld();
31+
cameraHolder.camera = cam;
32+
});
33+
34+
/** 相机世界前向(-Z 轴经四元数旋转) */
35+
function cameraForward(cam: THREE.PerspectiveCamera): THREE.Vector3 {
36+
return new THREE.Vector3(0, 0, -1).applyQuaternion(cam.quaternion);
37+
}
38+
39+
describe('useCameraFlight', () => {
40+
it('指定注视点时,飞行结束时位置到位且朝向正对目标', () => {
41+
const cam = cameraHolder.camera;
42+
// 模拟用户已用 OrbitControls 旋转视角:朝向偏离 -Z(旧实现会带着该朝向抵达)
43+
cam.rotateY(Math.PI / 2);
44+
45+
const { result } = renderHook(() => useCameraFlight());
46+
// 模块 [4,2,-2] + CAMERA_OFFSET [0,0,4] = [4,2,2],注视模块本体
47+
act(() => { result.current.flyTo([4, 2, 2], [4, 2, -2]); });
48+
act(() => { for (let i = 0; i < 10; i++) result.current.update(0.1); });
49+
50+
expect(cam.position.x).toBeCloseTo(4, 3);
51+
expect(cam.position.y).toBeCloseTo(2, 3);
52+
expect(cam.position.z).toBeCloseTo(2, 3);
53+
54+
const fwd = cameraForward(cam);
55+
const toTarget = new THREE.Vector3(4, 2, -2).sub(cam.position).normalize();
56+
expect(fwd.dot(toTarget)).toBeGreaterThan(0.999);
57+
});
58+
59+
it('飞行中途朝向持续转向目标(非抵达瞬间突变)', () => {
60+
const cam = cameraHolder.camera;
61+
cam.rotateY(Math.PI / 2);
62+
63+
const { result } = renderHook(() => useCameraFlight());
64+
act(() => { result.current.flyTo([4, 2, 2], [4, 2, -2]); });
65+
// 推进约一半时长(0.3s)
66+
act(() => { for (let i = 0; i < 3; i++) result.current.update(0.1); });
67+
68+
const fwd = cameraForward(cam);
69+
const toTarget = new THREE.Vector3(4, 2, -2).sub(cam.position).normalize();
70+
const dot = fwd.dot(toTarget);
71+
// 中途应已明显转向(起点朝向与目标接近垂直,dot≈0);ease-out cubic
72+
// 前半程进度较快,不锁死上界,仅要求未完全到位
73+
expect(dot).toBeGreaterThan(0.5);
74+
expect(dot).toBeLessThan(0.9999);
75+
});
76+
77+
it('未指定注视点时保持原行为:只动位置不改朝向', () => {
78+
const cam = cameraHolder.camera;
79+
cam.rotateY(Math.PI / 2);
80+
const before = cam.quaternion.clone();
81+
82+
const { result } = renderHook(() => useCameraFlight());
83+
act(() => { result.current.flyTo([0, 0, 10]); });
84+
act(() => { for (let i = 0; i < 10; i++) result.current.update(0.1); });
85+
86+
expect(cam.quaternion.equals(before)).toBe(true);
87+
});
88+
89+
it('自定义时长生效(duration 参数不再被忽略)', () => {
90+
const cam = cameraHolder.camera;
91+
const { result } = renderHook(() => useCameraFlight());
92+
act(() => { result.current.flyTo([0, 0, 5], undefined, 1.0); });
93+
// 推进 0.6s:若旧实现硬编码 0.6 则已抵达 z=5,新实现(时长 1.0)应仍在半途
94+
// (ease-out cubic 在 t=0.6 时进度约 93.6%,z≈5.32,仍严格大于 5)
95+
act(() => { for (let i = 0; i < 6; i++) result.current.update(0.1); });
96+
expect(cam.position.z).toBeGreaterThan(5.05);
97+
expect(cam.position.z).toBeLessThan(10);
98+
});
99+
});

‎client/src/lib/3d/hooks/useCameraFlight.ts‎

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
/**
22
* 相机飞行动画hook — 用于模块间导航
33
*
4+
* 位置与朝向同步插值:flyTo 可指定注视点 lookTarget,飞行期间相机朝向
5+
* 从起始四元数 slerp 到"终点位置注视 lookTarget"的四元数。此前只动 position
6+
* 不动朝向,相机抵达后仍保持概览态旧朝向(尤其用户用 OrbitControls 旋转过
7+
* 视角时),叠加 docked 相位 frameloop='never' 无法由 CameraController 纠正,
8+
* 导致停靠后视角错位、正对空白处。
9+
*
410
* @ai-context: 3D 相机飞行动画 Hook,模块间导航的视觉过渡。
511
*/
612
import { useThree } from '@react-three/fiber';
@@ -12,19 +18,47 @@ export function useCameraFlight() {
1218
const isFlying = useRef(false);
1319
const startPos = useRef(new THREE.Vector3());
1420
const endPos = useRef(new THREE.Vector3());
21+
const startQuat = useRef(new THREE.Quaternion());
22+
const endQuat = useRef(new THREE.Quaternion());
23+
const hasLook = useRef(false);
24+
const duration = useRef(0.6);
1525
const progress = useRef(0);
26+
// 复用的临时对象,避免每帧/每次飞行分配
27+
const lookMat = useRef(new THREE.Matrix4());
28+
const lookVec = useRef(new THREE.Vector3());
1629

17-
const flyTo = useCallback((target: [number, number, number], duration = 0.6) => {
30+
/**
31+
* 启动一次相机飞行
32+
* @param target - 终点位置
33+
* @param lookTarget - 可选注视点:飞行期间朝向平滑转向该点;省略则保持原行为(不改朝向)
34+
* @param flightDuration - 飞行时长(秒),默认 0.6
35+
*/
36+
const flyTo = useCallback((
37+
target: [number, number, number],
38+
lookTarget?: [number, number, number],
39+
flightDuration = 0.6,
40+
) => {
1841
startPos.current.copy(camera.position);
1942
endPos.current.set(...target);
43+
duration.current = flightDuration;
2044
progress.current = 0;
2145
isFlying.current = true;
46+
if (lookTarget) {
47+
startQuat.current.copy(camera.quaternion);
48+
lookVec.current.set(...lookTarget);
49+
// 与 Camera.lookAt 同一算法(Matrix4.lookAt(eye, target, up))计算终点朝向
50+
lookMat.current.lookAt(endPos.current, lookVec.current, camera.up);
51+
endQuat.current.setFromRotationMatrix(lookMat.current);
52+
hasLook.current = true;
53+
} else {
54+
hasLook.current = false;
55+
}
2256
}, [camera]);
2357

2458
const update = useCallback((delta: number) => {
2559
if (!isFlying.current) return;
2660

27-
progress.current += delta / 0.6;
61+
progress.current += delta / duration.current;
2862
if (progress.current >= 1) {
2963
progress.current = 1;
3064
isFlying.current = false;
@@ -33,6 +67,10 @@ export function useCameraFlight() {
3367
// Ease out cubic
3468
const t = 1 - Math.pow(1 - progress.current, 3);
3569
camera.position.lerpVectors(startPos.current, endPos.current, t);
70+
// 朝向与位置按同一缓动同步插值,确保抵达时正对目标
71+
if (hasLook.current) {
72+
camera.quaternion.slerpQuaternions(startQuat.current, endQuat.current, t);
73+
}
3674
}, [camera]);
3775

3876
return { flyTo, update, isFlying: isFlying.current };

‎client/src/lib/3d/navigation/OrbitalStore.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,9 @@ export const useOrbitalStore = create<OrbitalState>((set, get) => ({
7373
});
7474
},
7575
exitModule: () => {
76-
// 保留 currentModule:覆盖层保持挂载(不可见),页面状态存活,重入时不重播动画
77-
set({ phase: 'overview', overlayVisible: false, isInModule: false });
76+
// 保留 currentModule:覆盖层保持挂载(不可见),页面状态存活,重入时不重播动画;
77+
// 清除悬停态:退出后实体不应残留悬停视觉(覆盖层显示期间指针事件已关闭,hovered 可能滞留)
78+
set({ phase: 'overview', overlayVisible: false, isInModule: false, hoveredModule: null });
7879
},
7980
dock: () => {
8081
if (get().phase === 'entering') {

‎client/src/lib/3d/navigation/SpatialNav.tsx‎

Lines changed: 52 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,12 @@
77
* - entering 模式:useCameraFlight 独占相机,CameraController 暂停
88
* - docked 模式:CameraController 接管(渲染已暂停,实际不执行 lerp)
99
*
10+
* 相机飞行统一由相位迁移响应式驱动(覆盖 3D 点击/数字键/路由同步等全部入口);
11+
* 点击入口额外记录行星实时坐标供飞行使用(行星在公转,固定坐标会飞向错误位置)。
12+
*
1013
* @ai-context: 3D 空间导航组件——键盘/手势驱动的模块间轨道跳转,与 OrbitalStore 状态联动;渲染于 R3F Canvas 内,禁止使用 DOM API。
1114
*/
12-
import { useCallback } from 'react';
15+
import { useCallback, useEffect, useRef } from 'react';
1316
import { useNavigate } from 'react-router-dom';
1417
import { useFrame } from '@react-three/fiber';
1518
import { OrbitControls } from '@react-three/drei';
@@ -60,6 +63,9 @@ const AURORA_ORBIT_CONFIG: Record<ModuleId, {
6063
/** 相机飞入模块时的偏移(从模块位置向相机方向偏移) */
6164
const CAMERA_OFFSET: [number, number, number] = [0, 0, 4];
6265

66+
/** 概览态相机全景位(退出模块时相机飞回此位置) */
67+
const OVERVIEW_CAMERA_POS: [number, number, number] = [0, 0, 10];
68+
6369
function addVectors(a: [number, number, number], b: [number, number, number]): [number, number, number] {
6470
return [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
6571
}
@@ -71,46 +77,76 @@ export function SpatialNav() {
7177
const { flyTo, update } = useCameraFlight();
7278
const { shouldDegrade3D } = useRuntimeEnv();
7379

80+
// 点击入口记录的行星实时世界坐标(飞行 effect 优先消费),点击后才有效
81+
const clickFlightPosRef = useRef<Partial<Record<ModuleId, [number, number, number]>>>({});
82+
// 上一次已处理的飞行键(entering:模块 / overview / docked),防止重复飞行
83+
const flightKeyRef = useRef<string | null>(null);
84+
7485
// 每帧更新相机飞行
7586
useFrame((_, delta) => {
7687
// 防止浏览器节流导致的帧时间尖峰
7788
const safeDelta = Math.min(delta, 0.1);
7889
update(safeDelta);
7990
});
8091

81-
// 点击模块实体(深海模式使用固定坐标)
92+
// 响应式相机飞行:统一覆盖全部入口(3D 点击/数字键/返回首页/路由同步)。
93+
// 此前 flyTo 仅在点击回调中命令式调用,数字键等入口进入时无镜头移动;
94+
// 退出(Esc)时相机也需从模块停靠位飞回全景位。
95+
useEffect(() => {
96+
const flightKey = phase === 'entering' ? `entering:${currentModule}` : phase;
97+
if (flightKeyRef.current === flightKey) return;
98+
const prevKey = flightKeyRef.current;
99+
flightKeyRef.current = flightKey;
100+
101+
if (phase === 'entering' && currentModule) {
102+
const module = MODULE_POSITIONS.find((m) => m.id === currentModule);
103+
if (!module) return;
104+
// 优先使用点击时记录的实时坐标,fallback 到固定坐标
105+
const basePos = clickFlightPosRef.current[currentModule] ?? module.position;
106+
delete clickFlightPosRef.current[currentModule];
107+
// 注视点传入模块实时坐标:飞行期间朝向同步转向模块,
108+
// 否则相机抵达后仍保持概览态旧朝向,叠加 docked 渲染冻结导致视角错位
109+
flyTo(addVectors(basePos, CAMERA_OFFSET), basePos);
110+
} else if (phase === 'overview' && prevKey && prevKey !== 'overview') {
111+
// 退出模块:返回全景位并转向场景中心(初始挂载即 overview 时 prevKey 为 null,不飞行)
112+
flyTo(OVERVIEW_CAMERA_POS, [0, 0, 0]);
113+
}
114+
}, [phase, currentModule, flyTo]);
115+
116+
// 点击模块实体:飞行由相位迁移 effect 统一触发(深海模式使用固定坐标)
82117
const handleModuleClick = useCallback((id: ModuleId) => {
83118
const module = MODULE_POSITIONS.find(m => m.id === id);
84119
if (!module) return;
85120

86121
soundPlayer.play('ui_module_enter');
87122
enterModule(id);
88-
// 相机飞向模块位置(加偏移)
89-
flyTo(addVectors(module.position, CAMERA_OFFSET));
90123
navigate(module.route);
91-
}, [enterModule, flyTo, navigate]);
124+
}, [enterModule, navigate]);
92125

93-
// 点击 Aurora 模式行星:使用行星实时世界坐标作为 flyTo 目标,而非 MODULE_POSITIONS 固定坐标
126+
// 点击 Aurora 模式行星:记录实时世界坐标供飞行 effect 消费,而非使用 MODULE_POSITIONS 固定坐标
94127
// 因为行星在轨道上持续公转,固定坐标会导致相机飞向错误位置(漂移根因之一)
95128
const handleAuroraClick = useCallback((id: ModuleId, currentPosition?: [number, number, number]) => {
96129
const module = MODULE_POSITIONS.find(m => m.id === id);
97130
if (!module) return;
98131

99132
soundPlayer.play('ui_module_enter');
133+
// enterModule 在"同模块已停靠且覆盖层可见"时为无操作(不产生飞行),此时不记录,避免陈旧坐标被后续进入误用
134+
const s = useOrbitalStore.getState();
135+
const willNoop = s.currentModule === id && s.phase === 'docked' && s.overlayVisible;
136+
if (!willNoop && currentPosition) {
137+
clickFlightPosRef.current[id] = currentPosition;
138+
}
100139
enterModule(id);
101-
// 优先使用行星当前实时世界坐标,fallback 到固定坐标
102-
const basePos = currentPosition ?? module.position;
103-
flyTo(addVectors(basePos, CAMERA_OFFSET));
104140
navigate(module.route);
105-
}, [enterModule, flyTo, navigate]);
141+
}, [enterModule, navigate]);
106142

107143
// 悬停音效(移动端降级时不播放,仅在指针事件回调中触发)
108144
const handleHover = useCallback((id: ModuleId | null) => {
109145
if (id && !shouldDegrade3D) soundPlayer.play('ui_hover_3d', { throttleMs: 200 });
110146
setHovered(id);
111147
}, [setHovered, shouldDegrade3D]);
112148

113-
// 计算相机目标位置与注视点:非概览相位(entering/docked)时飞向并注视当前模块,否则全景位/场景中心
149+
// 计算相机目标位置与注视点:非概览相位(entering/docked)时注视当前模块,否则全景位/场景中心
114150
const inModuleView = phase !== 'overview' && currentModule;
115151
const activeModulePos = inModuleView
116152
? MODULE_POSITIONS.find(m => m.id === currentModule)?.position
@@ -165,7 +201,8 @@ export function SpatialNav() {
165201
color={config.color}
166202
emissiveColor={config.emissiveColor}
167203
isHovered={hoveredModule === module.id || highlightAll}
168-
isActive={currentModule === module.id}
204+
// 选中态需结合相位:Esc 退出后 currentModule 保留(页面状态存活),但视觉选中必须解除
205+
isActive={phase !== 'overview' && currentModule === module.id}
169206
showLabel={highlightAll || !overlayVisible}
170207
onClick={() => handleModuleClick(module.id)}
171208
onPointerOver={() => handleHover(module.id)}
@@ -211,7 +248,9 @@ export function SpatialNav() {
211248
orbitRadius={config.orbitRadius}
212249
orbitSpeed={config.orbitSpeed}
213250
initialAngle={config.initialAngle}
214-
isActive={currentModule === module.id}
251+
// 选中态需结合相位:Esc 退出后 currentModule 保留(页面状态存活),
252+
// 若不解除,行星将持续保持放大/光环/停止公转的"选中"表现
253+
isActive={phase !== 'overview' && currentModule === module.id}
215254
showLabel={highlightAll || !overlayVisible}
216255
onClick={handleAuroraClick}
217256
onHover={handleHover}

0 commit comments

Comments
 (0)