Skip to content

Commit 8851a1f

Browse files
committed
perf(3d): 性能分级引入滞回+持续判定+后台重置,消除 tier 抖动;清理无效跳帧死代码
原 tier 判定为单阈值即时切换,FPS 振荡时 tier 来回抖动,导致后处理/云层特效反复卸载(表现为动画有时不显示、需等很久才重新显示),且后台返回时节流期低 FPS 被误判为降级。现引入滞回缓冲区、连续窗口持续判定、逐级调整与后台返回重置;同时移除 MemoryManager 中 return 无法跳帧的无效死代码。
1 parent ff67320 commit 8851a1f

4 files changed

Lines changed: 154 additions & 54 deletions

File tree

‎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(() => {

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

Lines changed: 63 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
11
/**
22
* 性能监控 — FPS追踪与动态降级
33
*
4+
* 降级策略(防止“动画闪烁/长期隐藏”的三项关键设计):
5+
* 1. 滞回:低于 25 记为低、高于 50 记为高,25~50 为缓冲区不触发变更,
6+
* 避免 FPS 在单一阈值附近振荡导致 tier 来回抖动;
7+
* 2. 持续判定:同方向连续 2 个窗口(4s)才逐级调整 tier,
8+
* 防止瞬时卡顿(着色器编译、GC、场景切换)造成立即降级;
9+
* 3. 后台重置:从后台返回时重置测量基线,避免浏览器后台 rAF 节流
10+
* 被误判为性能恶化、回到前台后立即降级。
11+
*
412
* @ai-context: 3D 场景核心(R3F):PerformanceMonitor。
513
*/
614
import { useFrame } from '@react-three/fiber';
7-
import { useRef } from 'react';
15+
import { useEffect, useRef } from 'react';
816
import { create } from 'zustand';
917

1018
type PerformanceTier = 'high' | 'medium' | 'low';
@@ -23,26 +31,71 @@ export const usePerformanceStore = create<PerformanceState>((set) => ({
2331
setFps: (fps) => set({ fps }),
2432
}));
2533

34+
/** FPS 阈值:低于 LOWER 计低、高于 UPPER 计高,中间为缓冲区(滞回) */
35+
const LOWER_BOUND = 25;
36+
const UPPER_BOUND = 50;
37+
/** 触发 tier 变更所需的同方向连续窗口数(持续判定,每窗口 2s) */
38+
const SUSTAIN_WINDOWS = 2;
39+
/** FPS 测量窗口长度(ms) */
40+
const WINDOW_MS = 2000;
41+
2642
export function PerformanceMonitor() {
2743
const frameCount = useRef(0);
2844
const lastTime = useRef(performance.now());
29-
const { setTier, setFps } = usePerformanceStore();
45+
const lowStreak = useRef(0);
46+
const highStreak = useRef(0);
47+
48+
// 从后台返回后重置测量基线:后台期间浏览器节流 rAF,
49+
// 若不重置,返回后的第一个窗口会测出极低 FPS 被误判为降级
50+
useEffect(() => {
51+
const reset = () => {
52+
frameCount.current = 0;
53+
lastTime.current = performance.now();
54+
lowStreak.current = 0;
55+
highStreak.current = 0;
56+
};
57+
const onVisibility = () => {
58+
if (document.visibilityState === 'visible') reset();
59+
};
60+
document.addEventListener('visibilitychange', onVisibility);
61+
return () => document.removeEventListener('visibilitychange', onVisibility);
62+
}, []);
3063

3164
useFrame(() => {
3265
frameCount.current++;
3366
const now = performance.now();
3467
const elapsed = now - lastTime.current;
68+
if (elapsed < WINDOW_MS) return;
3569

36-
if (elapsed >= 2000) {
37-
const fps = Math.round(frameCount.current * (1000 / elapsed));
38-
setFps(fps);
70+
const fps = Math.round(frameCount.current * (1000 / elapsed));
71+
frameCount.current = 0;
72+
lastTime.current = now;
3973

40-
if (fps < 25) setTier('low');
41-
else if (fps < 45) setTier('medium');
42-
else setTier('high');
74+
// 在帧回调内取最新 tier,避免闭包捕获旧值
75+
const { tier, setTier, setFps } = usePerformanceStore.getState();
76+
setFps(fps);
4377

44-
frameCount.current = 0;
45-
lastTime.current = now;
78+
if (fps < LOWER_BOUND) {
79+
lowStreak.current++;
80+
highStreak.current = 0;
81+
} else if (fps > UPPER_BOUND) {
82+
highStreak.current++;
83+
lowStreak.current = 0;
84+
} else {
85+
// 缓冲区:双向计数清零,tier 保持不变
86+
lowStreak.current = 0;
87+
highStreak.current = 0;
88+
}
89+
90+
// 持续同方向才逐级调整一级,避免悬崖式变更(high→low 会直接隐藏后处理)
91+
if (lowStreak.current >= SUSTAIN_WINDOWS) {
92+
lowStreak.current = 0;
93+
if (tier === 'high') setTier('medium');
94+
else if (tier === 'medium') setTier('low');
95+
} else if (highStreak.current >= SUSTAIN_WINDOWS) {
96+
highStreak.current = 0;
97+
if (tier === 'low') setTier('medium');
98+
else if (tier === 'medium') setTier('high');
4699
}
47100
});
48101

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# 知识卡片 · 踩坑记录
2+
3+
## 基本信息
4+
5+
| 字段 | 内容 |
6+
|------|------|
7+
| 标题 | 3D 动画有时不显示/不流畅:性能分级无滞回导致 tier 抖动,特效反复卸载;后台返回误判降级 |
8+
| 日期 | 2026-08-01 |
9+
| 类型 | 踩坑记录 |
10+
| 标签 | #3D性能 #R3F #性能分级 #滞回 #drei #WebGL |
11+
12+
---
13+
14+
## 症状
15+
16+
内测反馈:**① 动画有时不显示,需要等很久才会重新显示;② 3D 动画不够流畅。**
17+
18+
具体表现:
19+
- 场景特效(辉光、云层、景深)时有时无,消失后要过一阵才回来;
20+
- 从最小化/切走切回后,特效经常处于"被隐藏"状态;
21+
- 每隔几秒可能出现一次轻微卡顿。
22+
23+
## 环境
24+
25+
| 项目 | 版本/信息 |
26+
|------|----------|
27+
| 渲染栈 | three `0.185` + @react-three/fiber `8.18` + @react-three/drei `9.122` + @react-three/postprocessing `2.19` |
28+
| 相关文件 | `client/src/lib/3d/core/PerformanceMonitor.tsx`、`QualityController.tsx`、`MemoryManager.tsx`、`scenes/AuroraDomeWorld.tsx`、`scenes/DeepSeaWorld.tsx` |
29+
30+
## 排查过程(按 debug-sop)
31+
32+
1. **分类**:性能问题 + 间歇性渲染异常。
33+
2. **梳理性能链路**:`PerformanceMonitor`(测 FPS → 定 tier)→ `QualityController`(按 tier 调 pixelRatio)→ 场景组件(按 tier 决定粒子数、是否挂后处理/云层)。`tier` 被 4 个组件订阅。
34+
3. **定位 tier 判定缺陷**(原实现):
35+
```
36+
if (fps < 25) setTier('low');
37+
else if (fps < 45) setTier('medium');
38+
else setTier('high');
39+
```
40+
- **无滞回**:单一阈值(25/45),FPS 在阈值附近振荡时 tier 来回跳;
41+
- **无持续判定**:一个 2s 窗口测到低 FPS(着色器编译、GC、场景切换、切后台节流)就立即降级;
42+
- **悬崖式降级**:`high` 可直接掉到 `low`,而 `AuroraDomeWorld`/`DeepSeaWorld` 在 `tier==='low'` 时会**卸载后处理与云层**——特效"消失",需等 FPS 回到 45+ 的窗口才重新挂载("等很久才重新显示")。
43+
4. **后台返回误判**:窗口隐藏时浏览器节流 rAF,返回后第一个 2s 窗口测出极低 FPS → 立即降级 → 用户一回来特效就没了。
44+
5. **顺带发现 `MemoryManager` 死代码**:其"每 4 帧跳 3 帧"靠在 `useFrame` 回调里 `return` 实现——但 R3F 的 `useFrame` 返回值被忽略,**return 并不能阻止当前帧渲染**,该逻辑完全无效(且注释宣称能省 GPU,误导后人)。窗口隐藏时浏览器本就会节流 rAF,无需手动跳帧。
45+
46+
## 根因
47+
48+
**性能分级(tier)的判定与切换缺少滞回(hysteresis)与持续判定(sustained threshold)。** FPS 的瞬时波动被直接、即时、悬崖式地映射为 tier 跳变,进而触发特效的反复卸载/重挂与 pixelRatio 突变——表现为"动画/特效有时不显示、要等很久才回来"以及周期性卡顿。
49+
50+
## 修复方案
51+
52+
1. **`PerformanceMonitor` 重写 tier 判定**(核心修复):
53+
- **滞回**:`fps<25` 计低、`fps>50` 计高,`25~50` 为缓冲区不触发变更;
54+
- **持续判定**:同方向连续 2 个窗口(4s)才调整 tier;
55+
- **逐级调整**:`high→medium→low` 一次一级,避免悬崖式降级直接隐藏后处理;
56+
- **后台返回重置**:`visibilitychange` 变可见时重置测量基线,避免节流期数据被误判。
57+
2. **`MemoryManager` 清理死代码**:移除无效的"跳帧"逻辑与仅服务于它的 blur/focus 监听,保留内存占用上报。
58+
59+
## 市场成熟方案(R3F 生态,均已确认在当前依赖版本可用)
60+
61+
| 方案 | 出处 | 作用 | 本项目适配建议 |
62+
|------|------|------|---------------|
63+
| `<PerformanceMonitor>` | drei | FPS 均值 + 上下界 + **flip-flop 保护**(`onIncline`/`onDecline`/`onFallback`),是"滞回+持续判定"的工业级现成实现 | 可整体替换自研监控;其 `factor`(0~1) 需映射到现有 high/medium/low 三级 |
64+
| `<AdaptiveDpr>` | drei | 性能下降时自动降 DPR、回升时恢复,比"突变 pixelRatio"更平滑 | 配合 PerformanceMonitor 使用,可替代 `QualityController` 的阶跃式 DPR |
65+
| `<AdaptiveEvents>` | drei | 性能下降时降低事件拾取分辨率 | 场景交互对象多时可加 |
66+
| `EffectComposer multisampling={0}` | @react-three/postprocessing | 关闭后处理管线 MSAA(默认 8),显著降 GPU 负载 | 若锯齿可接受或另加 SMAA,是性价比很高的一项 |
67+
| `frameloop="demand"` + `invalidate()` | R3F | 按需渲染,静止时零 GPU | 本场景持续动画,不适用;但"模块内"静态页可考虑 |
68+
| `renderer.compile()` / `<Preload>` | three/drei | 预编译着色器,消除首帧卡顿 | 已用 `Preload`;主题切换瞬时可再补 `compile` |
69+
70+
## 教训
71+
72+
- **动态质量分级必须带滞回与持续判定**:单阈值 + 即时切换 = tier 抖动 = 特效闪烁/反复卸载。这是自适应画质系统的通用铁律(游戏引擎的 dynamic resolution 同理)。
73+
- **降级要逐级、恢复要确认**:悬崖式降级(high→low)会一次性隐藏大量特效,用户感知强烈;逐级降 + 持续达标才升,体验平滑。
74+
- **后台节流的测量数据不可信**:rAF 被节流期间的 FPS 不代表真实性能,返回前台必须先重置测量基线。
75+
- **`useFrame` 里 `return` 不能跳帧**:R3F 渲染不由回调返回值控制;想真正暂停渲染应走 `frameloop`/`setAnimationLoop`,勿写"假跳帧"死代码。
76+
- 排查"特效时有时无"类问题的高效方法:**找到控制其显隐的状态(这里是 tier),再审查该状态的所有写入点的判定逻辑**。
77+
78+
## 相关提交
79+
80+
- perf(3d): 性能分级引入滞回+持续判定+后台重置,消除 tier 抖动;清理无效跳帧死代码(待提交)

‎docs/knowledge/index.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
| 日期 | 标题 | 标签 |
88
|------|------|------|
9+
| 2026-08-01 | [3D 动画有时不显示/不流畅:性能分级无滞回导致 tier 抖动,特效反复卸载;后台返回误判降级](./bugs/2026-08-3d-tier-flapping-animation-hitching.md) | #3D性能 #R3F #性能分级 #滞回 #drei #WebGL |
10+
| 2026-08-01 | [首页 3D 物体点击与功能错位:浅色模式下背景场景重复渲染了一套“只改状态不跳转”的行星](./bugs/2026-08-homepage-3d-duplicate-planets-misaligned-click.md) | #3D导航 #react-three-fiber #职责边界 #单一数据源 #浅色模式 |
911
| 2026-07-31 | [CI 编译原生模块报「Could not find any Visual Studio installation」——旧版 node-gyp 找不到 VS 2022](./bugs/2026-07-ci-node-gyp-vs2022-not-found.md) | #CI #原生模块 #node-gyp #本地能跑CI挂 |
1012
| 2026-07-31 | [课堂助手精细采集三症状:视觉抓页面元数据、ASR 静音幻觉、截断 JSON 泄漏 UI](./bugs/2026-07-classroom-capture-asr-hallucination-json-leak.md) | #课堂助手 #多模态 #ASR幻觉 #prompt工程 |
1113
| 2026-07-31 | [番茄钟"跳过"退化为"取消":一个 onClose 回调承载两种意图,空目标番茄无法启动](./bugs/2026-07-pomodoro-goal-skip-acts-as-cancel.md) | #React #番茄钟 #弹窗交互 #回调语义 #意图区分 |
@@ -31,6 +33,6 @@ _(暂无)_
3133

3234
## 标签速查
3335

34-
- **技术**:#CSS #a11y #React #Vite #Tailwind #Zustand #CI #GitLFS #electron-builder #GitHubActions #CDN #阿里云 #环境变量 #Supabase #DesignTokens #color-mix #认证 #AuthGuard
36+
- **技术**:#CSS #a11y #React #Vite #Tailwind #Zustand #CI #GitLFS #electron-builder #GitHubActions #CDN #阿里云 #环境变量 #Supabase #DesignTokens #color-mix #认证 #AuthGuard #3D导航 #react-three-fiber #3D性能 #R3F #drei #WebGL
3537
- **类型**:#bug #方案 #学习 #复盘
36-
- **模块**:#启动仪式 #reduced-motion #animation #发布 #安装包 #性能诊断 #测量方法 #番茄钟 #主题 #状态管理 #副作用 #数据统计 #路由守卫 #模式管理 #事件去重 #弹窗交互 #回调语义 #意图区分
38+
- **模块**:#启动仪式 #reduced-motion #animation #发布 #安装包 #性能诊断 #测量方法 #番茄钟 #主题 #状态管理 #副作用 #数据统计 #路由守卫 #模式管理 #事件去重 #弹窗交互 #回调语义 #意图区分 #职责边界 #单一数据源 #浅色模式 #性能分级 #滞回

0 commit comments

Comments
 (0)