Skip to content

Commit caecb34

Browse files
committed
Merge branch 'dev': 3D 导航修复与性能优化
合并 3D 三层根因修复(重复行星/标签可发现性/canvas 点击拦截)与性能分级优化(drei PerformanceMonitor 迁移、tier 滞回)。
2 parents cc59b89 + 2485f8c commit caecb34

12 files changed

Lines changed: 360 additions & 132 deletions

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

Lines changed: 7 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,17 @@
11
/**
2-
* 内存管理器 — Electron窗口失焦时暂停渲染,防止内存泄漏
2+
* 内存管理器 — 定期报告 GPU 内存占用
3+
*
4+
* 说明:窗口最小化/隐藏时浏览器会自动节流 rAF(渲染自然暂停),
5+
* 无需在此手动跳帧。原“每 4 帧跳 3 帧”的实现(在 useFrame 内 return)
6+
* 并不能阻止 R3F 渲染当前帧,属无效死代码,已移除。
37
*
48
* @ai-context: 3D 场景核心(R3F):MemoryManager。
59
*/
6-
import { useThree, useFrame } from '@react-three/fiber';
7-
import { useEffect, useRef } from 'react';
10+
import { useThree } from '@react-three/fiber';
11+
import { useEffect } from 'react';
812

913
export function MemoryManager() {
1014
const { gl } = useThree();
11-
const isVisible = useRef(true);
12-
const frameSkip = useRef(0);
13-
14-
// 监听窗口可见性
15-
useEffect(() => {
16-
const handleVisibility = () => {
17-
isVisible.current = document.visibilityState === 'visible';
18-
};
19-
20-
const handleBlur = () => {
21-
isVisible.current = false;
22-
};
23-
24-
const handleFocus = () => {
25-
isVisible.current = true;
26-
};
27-
28-
document.addEventListener('visibilitychange', handleVisibility);
29-
window.addEventListener('blur', handleBlur);
30-
window.addEventListener('focus', handleFocus);
31-
32-
return () => {
33-
document.removeEventListener('visibilitychange', handleVisibility);
34-
window.removeEventListener('blur', handleBlur);
35-
window.removeEventListener('focus', handleFocus);
36-
};
37-
}, []);
38-
39-
// 窗口不可见时跳过渲染帧(每4帧渲染1帧,节省GPU)
40-
useFrame(() => {
41-
if (!isVisible.current) {
42-
frameSkip.current++;
43-
if (frameSkip.current % 4 !== 0) {
44-
return; // 跳过此帧
45-
}
46-
} else {
47-
frameSkip.current = 0;
48-
}
49-
});
5015

5116
// 定期报告内存使用
5217
useEffect(() => {
Lines changed: 51 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,28 @@
11
/**
2-
* 性能监控 — FPS追踪与动态降级
2+
* 性能监控 — FPS追踪与动态降级(基于 drei 成熟方案)
3+
*
4+
* 重构说明(docs/standards/refactoring.md,外部行为不变):
5+
* - FPS 测量与防抖委托给 drei <PerformanceMonitor>(工业级实现):
6+
* 内置滑动均值(iterations×ms 采样)、bounds 滞回、threshold 比例持续判定
7+
* 与 flip-flop 保护,替代原自研单窗口测量 + 手写滞回/持续逻辑;
8+
* - tier 迁移策略抽取至纯函数模块 tierPolicy(已单测);
9+
* - 本组件仅做装配:把 drei 回调映射为 tier 逐级迁移,并在窗口隐藏时
10+
* 卸载监控以重置测量基线(后台 rAF 节流期 FPS 不可信,卸载重挂即自然
11+
* 清零,避免回到前台被误判降级)。
312
*
413
* @ai-context: 3D 场景核心(R3F):PerformanceMonitor。
514
*/
6-
import { useFrame } from '@react-three/fiber';
7-
import { useRef } from 'react';
15+
import { useEffect, useState } from 'react';
16+
import { PerformanceMonitor as DreiPerformanceMonitor } from '@react-three/drei';
817
import { create } from 'zustand';
18+
import { stepTier, type PerformanceTier } from './tierPolicy';
919

10-
type PerformanceTier = 'high' | 'medium' | 'low';
20+
/** FPS 下界:持续低于此值触发降级(滞回缓冲区下限) */
21+
export const FPS_LOWER_BOUND = 25;
22+
/** FPS 上界:持续高于此值触发升级(滞回缓冲区上限) */
23+
export const FPS_UPPER_BOUND = 50;
24+
/** 升降震荡(flip-flop)允许次数上限,超过则回落稳定档 */
25+
const MAX_FLIPFLOPS = 4;
1126

1227
interface PerformanceState {
1328
tier: PerformanceTier;
@@ -24,27 +39,38 @@ export const usePerformanceStore = create<PerformanceState>((set) => ({
2439
}));
2540

2641
export function PerformanceMonitor() {
27-
const frameCount = useRef(0);
28-
const lastTime = useRef(performance.now());
29-
const { setTier, setFps } = usePerformanceStore();
30-
31-
useFrame(() => {
32-
frameCount.current++;
33-
const now = performance.now();
34-
const elapsed = now - lastTime.current;
35-
36-
if (elapsed >= 2000) {
37-
const fps = Math.round(frameCount.current * (1000 / elapsed));
38-
setFps(fps);
39-
40-
if (fps < 25) setTier('low');
41-
else if (fps < 45) setTier('medium');
42-
else setTier('high');
42+
// 窗口隐藏时卸载监控:后台期间浏览器节流 rAF,FPS 读数不可信;
43+
// 返回前台重挂即自然重置采样基线,避免误判降级。
44+
// 附带收益:同时重置 drei 内部 flipped/fallback 计数,防止 fallback 永久锁定。
45+
const [visible, setVisible] = useState(() => document.visibilityState === 'visible');
46+
useEffect(() => {
47+
const onVisibility = () => setVisible(document.visibilityState === 'visible');
48+
document.addEventListener('visibilitychange', onVisibility);
49+
return () => document.removeEventListener('visibilitychange', onVisibility);
50+
}, []);
4351

44-
frameCount.current = 0;
45-
lastTime.current = now;
46-
}
47-
});
52+
if (!visible) return null;
4853

49-
return null;
54+
return (
55+
<DreiPerformanceMonitor
56+
bounds={() => [FPS_LOWER_BOUND, FPS_UPPER_BOUND]}
57+
flipflops={MAX_FLIPFLOPS}
58+
onIncline={(api) => {
59+
// 持续高 FPS(75%+ 样本 ≥ 上界)→ 逐级升档
60+
const { tier, setTier, setFps } = usePerformanceStore.getState();
61+
setFps(Math.round(api.fps));
62+
setTier(stepTier(tier, 'up'));
63+
}}
64+
onDecline={(api) => {
65+
// 持续低 FPS(75%+ 样本 < 下界)→ 逐级降档
66+
const { tier, setTier, setFps } = usePerformanceStore.getState();
67+
setFps(Math.round(api.fps));
68+
setTier(stepTier(tier, 'down'));
69+
}}
70+
onFallback={() => {
71+
// 震荡超限 → 稳定在中档:保留后处理特效、适度降低 DPR
72+
usePerformanceStore.getState().setTier('medium');
73+
}}
74+
/>
75+
);
5076
}

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
/**
22
* 3D场景全局提供器 — React Three Fiber Canvas容器
3-
* 作为全屏背景层,z-index: -1
3+
* 作为全屏背景层:z-0 使其位于根布局 div(z-auto)之上、可接收点击,
4+
* 但仍低于所有 UI 浮层(标题栏 z-50 / FunctionalOverlay z-10 / 引导 z-40+)。
5+
* 注意:不能用负 z-index,否则会落到根 div 之后被其透明盒拦截点击。
46
*
57
* @ai-context: 3D 场景核心(R3F):SceneProvider。
68
*/
@@ -21,7 +23,7 @@ interface SceneProviderProps {
2123

2224
export function SceneProvider({ children, interactive = false }: SceneProviderProps) {
2325
return (
24-
<div className="fixed inset-0 -z-10" style={{ pointerEvents: interactive ? 'auto' : 'none' }}>
26+
<div className="fixed inset-0 z-0" style={{ pointerEvents: interactive ? 'auto' : 'none' }}>
2527
<Canvas
2628
gl={{
2729
antialias: true,
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/**
2+
* tierPolicy 单元测试 — BDD 风格、AAA 模式
3+
*
4+
* @ai-context: 覆盖 stepTier 的全部迁移路径与边界保持,
5+
* 是 PerformanceMonitor 重构(自研监控 → drei 成熟方案)的行为护栏。
6+
*/
7+
import { describe, it, expect } from 'vitest';
8+
import { stepTier } from './tierPolicy';
9+
10+
describe('stepTier(逐级迁移策略)', () => {
11+
describe('升级(up)', () => {
12+
it('low 应升级为 medium', () => {
13+
// Arrange & Act
14+
const result = stepTier('low', 'up');
15+
// Assert
16+
expect(result).toBe('medium');
17+
});
18+
19+
it('medium 应升级为 high', () => {
20+
const result = stepTier('medium', 'up');
21+
expect(result).toBe('high');
22+
});
23+
24+
it('high 已达上限,应保持不变', () => {
25+
const result = stepTier('high', 'up');
26+
expect(result).toBe('high');
27+
});
28+
});
29+
30+
describe('降级(down)', () => {
31+
it('high 应降级为 medium', () => {
32+
const result = stepTier('high', 'down');
33+
expect(result).toBe('medium');
34+
});
35+
36+
it('medium 应降级为 low', () => {
37+
const result = stepTier('medium', 'down');
38+
expect(result).toBe('low');
39+
});
40+
41+
it('low 已达下限,应保持不变', () => {
42+
const result = stepTier('low', 'down');
43+
expect(result).toBe('low');
44+
});
45+
});
46+
47+
describe('逐级性(禁止跨级跳变)', () => {
48+
it('升级时 low 不得直接跳到 high', () => {
49+
expect(stepTier('low', 'up')).not.toBe('high');
50+
});
51+
52+
it('降级时 high 不得直接跳到 low', () => {
53+
expect(stepTier('high', 'down')).not.toBe('low');
54+
});
55+
});
56+
});
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* tierPolicy — 性能分级(tier)逐级迁移策略(纯函数)
3+
*
4+
* 从 PerformanceMonitor 中抽取:将"tier 如何迁移"(纯逻辑)与"如何测量 FPS"
5+
* (R3F 副作用)物理分离,遵循 AI 编程规范 §1(副作用隔离)与 §6(自底向上:
6+
* 先原子纯函数层并附单测,再由系统层装配)。
7+
*
8+
* @ai-context: 纯函数模块,无副作用,可独立单测与安全重构。
9+
* @ai-context: tier 三级抽象(high/medium/low)被 AuroraDomeWorld / DeepSeaWorld /
10+
* ParticleSystem / QualityController 四处消费,改变其语义影响面大,需同步评估。
11+
*/
12+
13+
/** 性能分级:high=全画质,medium=降分辨率,low=关闭后处理特效 */
14+
export type PerformanceTier = 'high' | 'medium' | 'low';
15+
16+
/** tier 调整方向:up=升级,down=降级 */
17+
export type TierTrend = 'up' | 'down';
18+
19+
/**
20+
* 逐级调整 tier(一次只迁移一级,不跨级跳变)。
21+
*
22+
* 为什么逐级:悬崖式变更(high→low)会立即卸载后处理与云层,
23+
* 造成"动画特效突然消失"的强烈视觉波动;逐级迁移使画质降级/恢复平滑。
24+
*
25+
* @param current - 当前 tier
26+
* @param trend - 调整方向
27+
* @returns 调整后的 tier;已处于边界时保持不变(low 不再降、high 不再升)
28+
*/
29+
export function stepTier(current: PerformanceTier, trend: TierTrend): PerformanceTier {
30+
if (trend === 'up') {
31+
return current === 'low' ? 'medium' : 'high';
32+
}
33+
return current === 'high' ? 'medium' : 'low';
34+
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ export function SpatialNav() {
115115
emissiveColor={config.emissiveColor}
116116
isHovered={hoveredModule === module.id || highlightAll}
117117
isActive={currentModule === module.id}
118-
showLabel={highlightAll}
118+
showLabel={highlightAll || !isInModule}
119119
onClick={() => handleModuleClick(module.id)}
120120
onPointerOver={() => handleHover(module.id)}
121121
onPointerOut={() => handleHover(null)}
@@ -143,7 +143,7 @@ export function SpatialNav() {
143143
orbitSpeed={config.orbitSpeed}
144144
initialAngle={config.initialAngle}
145145
isActive={currentModule === module.id}
146-
showLabel={highlightAll}
146+
showLabel={highlightAll || !isInModule}
147147
onClick={handleModuleClick}
148148
onHover={handleHover}
149149
/>

‎client/src/lib/3d/objects/AuroraModuleEntity.tsx‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import { useFrame } from '@react-three/fiber';
99
import * as THREE from 'three';
1010
import { Float, Html } from '@react-three/drei';
1111
import type { ModuleId } from '../navigation/OrbitalStore';
12-
import { useIsNewbiePhase } from '@/features/onboarding/firstDive/useFirstDiveStore';
1312
import { getModuleSubtitle } from '@/features/onboarding/firstDive/moduleSubtitles';
1413

1514
export interface AuroraModuleEntityProps {
@@ -55,8 +54,7 @@ export function AuroraModuleEntity({
5554
const ringRef = useRef<THREE.Mesh>(null);
5655
const [hovered, setHovered] = useState(false);
5756
const angleRef = useRef(initialAngle);
58-
// 新手期双标签(首潜完成后自动隐去)
59-
const isNewbie = useIsNewbiePhase();
57+
// 功能副标题:隐喻名(深潜/结礁…)无法自解释,常驻直白功能名确保用户能把行星对应到功能
6058
const subtitle = getModuleSubtitle(id);
6159

6260
const config = PLANET_CONFIGS[id];
@@ -173,8 +171,8 @@ export function AuroraModuleEntity({
173171
>
174172
<div className="rounded-lg bg-slate-900/80 px-3 py-1.5 text-sm font-medium text-white backdrop-blur-sm whitespace-nowrap border border-indigo-500/30">
175173
{config.label}
176-
{/* 新手期双标签:隐喻名旁附直白副标题 */}
177-
{isNewbie && subtitle && (
174+
{/* 常驻功能副标题:隐喻名旁附直白功能名 */}
175+
{subtitle && (
178176
<span className="ml-1.5 text-xs text-white/50">· {subtitle}</span>
179177
)}
180178
</div>

‎client/src/lib/3d/objects/ModuleEntity.tsx‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import { useRef } from 'react';
88
import { useFrame } from '@react-three/fiber';
99
import * as THREE from 'three';
1010
import { Float, Html } from '@react-three/drei';
11-
import { useIsNewbiePhase } from '@/features/onboarding/firstDive/useFirstDiveStore';
1211
import { getModuleSubtitle } from '@/features/onboarding/firstDive/moduleSubtitles';
1312

1413
type GeometryType = 'dodecahedron' | 'torus' | 'box' | 'sphere' | 'octahedron' | 'icosahedron';
@@ -82,8 +81,7 @@ export function ModuleEntity({
8281
}: ModuleEntityProps) {
8382
const meshRef = useRef<THREE.Mesh>(null);
8483
const materialRef = useRef<THREE.MeshStandardMaterial>(null);
85-
// 新手期双标签(首潜完成后自动隐去)
86-
const isNewbie = useIsNewbiePhase();
84+
// 功能副标题:隐喻名(深潜/结礁…)无法自解释,常驻直白功能名确保用户能把几何体对应到功能
8785
const subtitle = getModuleSubtitle(id);
8886

8987
// Each entity has a unique rotation axis and speed
@@ -160,8 +158,8 @@ export function ModuleEntity({
160158
>
161159
<div className="rounded-lg bg-slate-900/80 px-3 py-1.5 text-sm font-medium text-white backdrop-blur-sm whitespace-nowrap border border-indigo-500/30">
162160
{label}
163-
{/* 新手期双标签:隐喻名旁附直白副标题 */}
164-
{isNewbie && subtitle && (
161+
{/* 常驻功能副标题:隐喻名旁附直白功能名 */}
162+
{subtitle && (
165163
<span className="ml-1.5 text-xs text-white/50">· {subtitle}</span>
166164
)}
167165
</div>

0 commit comments

Comments
 (0)