OCC 星球漫游 · 性能优化方案
审计日期:2026-10-13 | 范围:public/src(前端 24 模块)·server(4 模块)·deploy/prod方法:源码逐行审计 + 本机实测(Node/浏览器同量级)· 所有数字均可复现,行号对应当前工作区 目标运行环境:生产为 阿里云 1.6G 单核(PM2 单实例 fork,max_memory_restart 500M);客户端为桌面浏览器(含核显) ⚠️ 时间基线说明:本文档写于 2026-10-13,当时前端是 24 个模块、服务端 4 个模块。 之后两天又相继接入了世界 Boss、NPC 精英坦克、雷达、掉落物、基地建筑(20 种 GLB)、矿产资源、星图、科技树、随身制造等系统 —— 因此 §6.2 的实测数字(draw call 51 / 三角形 507,653)应理解为 "那一轮优化后的基线",不是今天的现值。要拿现值就重跑node tools/e2e/perf.cjs。 本文档的方法论、禁区与四维框架仍然有效,是后续一切性能改动的判据来源。
0. 结论先行
四维体检结果:
| 维度 | 现状 | 判定 |
|---|---|---|
| GPU 渲染 | 正常帧 draw call ≈ 1000+,其中 NPC 独占 ≈750;地形 25 万三角形;植被 10.2 万实例 / 最多 192 个实例网格 | 🔴 首要瓶颈(NPC 是可合批却逐 Mesh 画的典型) |
| 并发 | 状态中继是 O(N²) 逐事件转发:N=100 时单项吃掉 1118ms/秒事件循环、出站 244Mbps | 🔴 人数上量即崩;但当前线上人数下不是痛点 → 见 §3 的优先级判断 |
| 资源加载 | 首屏 raw 11.2MB / gzip 9.4MB,其中 GLB 单文件 9.64MB 占 86%;且它在启动链第 12 步(登录+地形生成之后)才开始下载 | 🔴 体感最差、改动最小 |
| 内存占用 | 显存 100–140MB(4K 主贴图 + 3 张 2K 机甲贴图);构建期 10 万次 Matrix4/Color.clone 峰值;scryptSync 阻塞、每 2s 全量同步写盘 | 🟡 桌面尚可,1.6G 云主机与移动端吃紧 |
一句话策略:
先做「零风险数值 + 首屏 GLB」拿下 80% 体感收益(阶段 A/B,1–2 天); 再做「NPC 实例化 + 植被剔除」拿 GPU(阶段 C,2–4 天); 服务端 先补稳定性防线再做协议重构,快照广播等真实并发接近 30 人再上(阶段 D)。
Top 5(按 收益/成本 排序):
| # | 动作 | 预期收益 | 成本 |
|---|---|---|---|
| 1 | GLB 瘦身:删掉已被代码 normalMap=null 丢弃的 normal 贴图 + 2048²PNG → 1024² | 首屏 -8.5MB(-90%),弱网省 5–9s | 低(devDeps 里 @gltf-transform/cli + meshoptimizer 已就位) |
| 2 | GLB 提前到启动早期并行下载(main.js:91 发起,:273 才 await) | 等于省掉整个 GLB 下载时长 | 极低(~3 行) |
| 3 | NPC 车辆实例化 | draw call 750 → ≈11 | 中(需重构 npc.js:114-178) |
| 4 | HUD 去掉每帧 innerHTML + 物理停止每帧 broadphase.dirty | 主线程每帧尖刺消失 | 低 |
| 5 | 静态资源 immutable 长缓存 + 启动 API 并行化 | 回访近 0 字节;关键路径 RTT 6→2 | 低 |
1. 基线实测数据(优化前的标尺)
1.1 前端渲染
| 指标 | 实测值 | 位置 |
|---|---|---|
| 植被实例总数 | 101,720(草 90,000 / 灌木 3,200 / 小石 3,600 / 针叶 2,600 / 阔叶 1,900 / 大石 420) | config.js:135-145 |
| 植被 InstancedMesh 数 | 6 物种 × 8×4=32 扇区 = 最多 192 个(其中 5 物种投影 → ≈160 个进阴影 pass) | flora.js:164, 273-294 |
| NPC | 50 辆 × 15 个独立 Mesh = 750 draw call;每辆 3 个专属材质(共 150 个) | npc.js:125-175, config.js:112-121 |
| 正常帧 draw call 合计 | ≈ 1000+ | — |
| 地形 | SphereGeometry(820, 500, 250) = 250,000 三角形 / 125,751 顶点,无 LOD | planet.js:24 |
| 显存(贴图) | 主贴图 4096×2048 + 粗糙度 2048×1024 + 云 2048×1024 + 星云 2048×1024 + 机甲 2×2048² → 约 100–140MB(含 mip) | terrain.js:38-110, planet.js:162-202, sky.js:9-76 |
| 阴影 | 2048² PCF,正交范围 ±190,near 1 / far 2600,每帧全量重绘(autoUpdate 未关) | main.js:136-163, config.js:148-150 |
| 渲染器 | antialias:true,maxPixelRatio 1.75,ACES 1.10,FogExp2 0.00042 | main.js:127-140, config.js:153-154 |
| 光照 | 5 盏:1 投影方向光 + Ambient + Hemisphere + 2 常驻 PointLight(仅改强度,做法正确) | main.js:150-167, effects.js:117-124 |
| 后处理 | 无 EffectComposer / RenderTarget(这点很好,别加) | 全项目 0 命中 |
1.2 每帧 CPU 热点(O(n) 清单,按代价排序)
| # | 热点 | 量级 | 位置 |
|---|---|---|---|
| 1 | world.step(1/90, dt, 5) + 每帧 refreshAABBs 全量标脏 + 每帧 broadphase.dirty=true 全量重排 | 活跃刚体 ≈300–350 | main.js:1070-1074, physics.js:299-302 |
| 2 | hud.update 每帧 stats.innerHTML = [...].join('<br>') 全量重写 DOM + 每帧新建对象 | 每帧 1 次解析+布局 | hud.js:352-363, main.js:1216-1230 |
| 3 | obstacleStream.update 全量角度筛选 | ≈7,600 个障碍物,移动每 ≥14 单位触发一次(步速下约 0.5s),每次 new Set() | physics.js:154-185 |
| 4 | effects.update 全池遍历 + 无条件上传 4 个 attribute | 1,600 粒子槽,即使 0 存活也上传 ≈51KB | effects.js:226-254 |
| 5 | 动画 CLIP.* 每次分配新 pose 对象 + 9 个数组 | 每远程玩家每帧;自身同理 | anim.js:52-138, player.js:326-335 |
| 6 | 零碎分配:planes.map()、getLatLon()、getMoveAxis()、aliveList()、radar 的 2 个 gradient + filter/sort/slice | 每帧 6–10 个对象 + 2 数组 | main.js:1212, player.js:342, input.js:173-185, npc.js:416, radar.js:273-390 |
1.3 服务端(并发)
| 指标 | 实测值 | 位置 |
|---|---|---|
| 上行 | 每客户端 10Hz state(载荷 226–317B) | public/src/main.js:1204-1209, server.js:420-447 |
| 广播模型 | 逐事件中继:每条 state 触发一次 broadcast,发 N-1 人 → N(N-1)×10 send/秒 | server.js:270-275, 420-447 |
| 事件循环占用 | N=20 → 43ms/s | N=50 → 244ms/s | N=100 → 1118ms/s(饱和) | 本机实测 |
| 出站带宽 | N=50 → 62Mbps | N=100 → 244Mbps | 本机实测 |
npcMove | 5Hz,1212B/帧(50 台全量,不裁剪距离) | server.js:323-330, 605-607 |
| 同步阻塞 | scryptSync 28ms/次(云主机 100–300ms),登录/注册路径且无限流 | store.js:51-53, 217, 227 |
| 存档 | 每 2s JSON.stringify + writeFileSync 全量;5000 用户 stringify 144ms、20000 用户 588ms(当前存档 110KB) | store.js:16, 83-93 |
| 地形阻塞 | 新 seed 首次 /api/world/spawn 要标定水位:6144 次采样 15.9ms;fieldCache 无上限 | terrain-field.js:81-95, server.js:218-232 |
| 防护 | WS maxPayload 未设(默认 100MiB)、无 bufferedAmount 背压、无 ws.on('error')、无 uncaughtException 兜底 | server.js:268, 273, 360-390 |
| 索引 | findClient / socketsOf 是 O(N) 全表扫 | server.js:281-294 |
1.4 资源加载
| 指标 | 实测值 |
|---|---|
| 首屏必须下载 | raw 11.21MB / gzip 9.37MB,共 ≈41 个请求 |
| 其中机甲 GLB | aegis07_voxel_pbr.r004.flat.glb = 9,643,008B(占 raw 86%、占 gzip 96%);gzip 只省 2.3% |
| GLB 内部构成 | 网格仅 19,998 三角形(<1%);99% 是 3 张 2048² PNG:diffuse 3.84MB + normal 2.89MB(代码已 normalMap=null 丢弃) + metallicRoughness 2.09MB;无 Draco/KTX2 |
| vendor | 共 1,272KB:three.core 384K + three.module 365K(两者必下,互相 import)+ cannon 346K + GLTFLoader 115K + BufferGeometryUtils 35K + SkeletonUtils 11.5K;另有 2 个死文件(OrbitControls 40.5K、RoomEnvironment 5K) |
| 启动串行链 | 6 个 RTT(config → restore → login → npcs → wallet → spawn),GLB 排在第 12 步才开始下载(main.js:273) |
| 主线程生成阻塞 | 地形 768×384 = 294,912 次 elevation = 225.1ms + 云层 110ms + 植被 ≈150ms + 贴图烘焙/上传 ≈200–400ms → 合计 0.7–1.5s(低端移动端 2–5s) |
| 静态缓存 | public/ → max-age=0(每次导航都 304 往返);/assets → max-age=3600(无 immutable);Node 侧无压缩中间件 |
| nginx | gzip on(level 5)✅,但 gzip_types 含 model/gltf-binary(实测只省 2.3%,纯烧 CPU);无 HTTP/2、无 brotli、无 expires |
| 部署包 | assets/ 共 273MB 全量 tar+scp;其中 49MB 参考图 + 200MB 旧 GLB 已无运行时引用,且通过 /assets 路由公网可 GET |
2. 优化方案(四维 × 优先级)
约定:P0 = 零/低风险速赢(不动架构,可当次回归);P1 = 结构优化(改模块,需 e2e 回归);P2 = 规模化改造(改协议/多进程,需专门排期)。 所有「改哪」都精确到文件:行号;「验收」都给可测指标。
2.1 GPU 渲染
| # | 优先级 | 优化项 | 改哪 | 预期收益 | 风险/注意 | 验收 |
|---|---|---|---|---|---|---|
| G1 | P0 | 收紧阴影相机 | main.js:158-159 far 2600→1600、near 1→200;config.js:150 shadowExtent 190→130 | 深度精度提升、阴影更锐;阴影 pass 顶点量下降 | 已 updateProjectionMatrix(),无 | 近处阴影无漏光/闪烁;renderer.info.render.calls 阴影 pass 下降 |
| G2 | P0 | 降低像素比 | config.js:153 maxPixelRatio 1.75→1.25 | 填充率 -49%(1.75²→1.25²) | 高分屏观感略软;建议配自适应(见 G7) | 集显机 FPS +30% 以上 |
| G3 | P0 | 主贴图/云层降分辨率 | config.js:17-18 4096×2048→2048×1024;terrain.js:100-104 粗糙度 →1024×512;planet.js:205, 257 云/大气球段 64×48→48×32、84×56→48×32 | 省 40–50MB 显存 + 上传/采样带宽 | 第三人称俯视下肉眼差异很小;必须同步检查 terrain.js 贴图 uv 公式不动 | 显存监控下降;地面无接缝/错位 |
| G4 | P1 | NPC 车辆实例化 | 重构 npc.js:114-178:10 个部件几何各建 InstancedMesh(50),车轮单独一个实例网格承担自转,beacon 单独实例网格 + 共享发光材质;颜色走 instanceColor | draw call 750 → ≈11;材质 150 → 5;每帧只写 50 个矩阵 | 需要 config.js:112-121 的 renderDist 距离剔除继续生效(写 count 或矩阵缩 0);npc.js:330-332 的 per-car dispose 逻辑要重写 | renderer.info.render.calls 正常帧 <200;NPC 外观/自转/尾灯与现状一致 |
| G5 | P1 | 植被按扇区距离剔除 | flora.js:273-294:每个扇区 InstancedMesh 记录 centerDir,每 10 帧做 dot(player.normal, centerDir) 判定,超 600(草 400)→ visible=false,回落 700(草 500)恢复 | 远处草/树停止绘制,顶点负载显著下降 | 扇区索引被碰撞体复用(flora.js:238 ob.sector)——只改可见性,不改实例数据;滞回门限避免边界闪烁 | 高速奔跑 FPS 提升;远景无"成片消失"(用 700/600 滞回) |
| G6 | P2 | 地形分段 500×250 → 256×128 | config.js:11-12 | 三角形 250k → 65k | ⚠️ 必须保持 lon:lat = 2:1(否则格子变长方形);网格线层与雷达底图索引都要复核(见 §4) | 轮廓无可见折线;buildGridLines() 网格仍为正方形 |
| G7 | P1 | 自适应分辨率 | main.js:1016-1022 已有 FPS 统计 → 连续 30 帧 <50fps 降 pixelRatio 至 1.0,回升需滞回 | 低端机自动保帧率 | 需要防抖,避免来回跳 | 人为降频/核显下自动降档 |
| G8 | P1 | 草材质降级 | flora.js:256-265:草换 MeshLambertMaterial(或 Basic + 顶点色),alphaTest 0.42→0.5 | 9 万株 DoubleSide 片元的 Standard 光照是填充率大头 | 草偏亮/偏平,需目视调 | 画面可接受;FPS 提升 |
| G9 | P1 | 阴影分级 | npc.js:371-381 已有距离计算 → 距玩家 >250 的车 castShadow=false;main.js:1038-1041 的 L 键切换改为只调 shadow.mapSize(避免整批 needsUpdate 触发全量 shader 重编译) | 阴影 pass 顶点量下降;切换不再卡顿 | 远处无影,肉眼难以察觉 | 按 L 键无卡顿尖刺 |
2.2 内存占用 / GC
| # | 优先级 | 优化项 | 改哪 | 预期收益 | 风险 | 验收 |
|---|---|---|---|---|---|---|
| M1 | P0 | HUD 停止每帧 innerHTML | hud.js:352-363 改固定 <span> 池 + 变更检测,只写变化项 textContent;main.js:1212 + hud.js:118-141 的 states.map().join(',') 改为「以 ammo+capacity 为签名,变化时才读 states」 | 消除每帧 DOM 解析+布局,主线程显著变顺 | 需保证数字格式与原一致 | 帧时间下降;HUD 数值显示正确 |
| M2 | P0 | 物理停止每帧全量标脏 | main.js:1073 的 broadphase.dirty=true 改为仅障碍物增删时置位;physics.js:299-302 refreshAABBs 改为只传动态体(玩家/NPC/飞机/远程) | 去掉每帧全量 broadphase 重排 + ≈300 次 AABB 重算 | 需确保流式加载 flush 后仍置位(physics.js:154-185) | 撞树/NPC 碰撞仍准确;diag.cjs 逐秒状态正常 |
| M3 | P0 | 物理筛选按扇区分桶 | physics.js:154-168:复用现成的 ob.sector(flora.js:238),只扫玩家所在扇区 ±1 圈(≈9/32 桶 ≈2.1k 个);wanted Set 改 clear() 复用 | 去掉每 0.5s 的 7.6k 扫描尖峰与 Set 分配 | 边界跨越要留冗余圈,否则障碍物弹出/消失 | 障碍物无闪断;尖峰消失 |
| M4 | P0 | 粒子系统空闲短路 | effects.js:226-254:加 aliveCount,为 0 时直接 return,跳过 1600 次遍历与 4 次 buffer 上传 | 消除常驻 51KB/帧上传 | 无 | 空闲时 GPU 上传为 0 |
| M5 | P1 | 消除每帧对象分配 | player.js:342 getLatLon(out)、input.js:173-185 传复用对象、main.js:1161-1166 血条用对象池、effects.js:150-151 临时向量提到模块级、npc.js:416 aliveList() 改复用数组 | 每帧 6–10 对象 + 2 数组归零 | 复用对象别跨帧持有 | DevTools 分配采样:每帧堆分配 ≈0 |
| M6 | P1 | 动画姿态对象池 | anim.js:52-138 的 CLIP.* 结果写入共享 pose 缓冲,mecha.js:376-383 复用 | 每远程玩家每帧省 1 对象 + 9 数组 | 远程玩家共享会串数据 → 每实例 1 份缓冲 | 动作与现状一致 |
| M7 | P1 | 构建期 clone 削减 | flora.js:206-220:矩阵/颜色写进复用临时对象后直接 setMatrixAt,仅障碍物(7.6k)才克隆持久矩阵 | 省 10 万次 Matrix4/Color 分配,削掉启动期 GC 峰值 | 需要区分"要长期持有"的障碍物实例 | 启动内存峰值下降 |
| M8 | P1 | 补齐 dispose 链 | 新增 effects.dispose()(几何/材质/贴图/2 灯);把 npcs/radar/effects/net/input/renderer 的 dispose 串进世界销毁流程(现在 main.js 从未调用) | 为"重开局/换世界/热重载"兜底,当前无泄漏但迟早踩 | 无(只加不删) | 重开局后 renderer.info.memory 回到基线 |
| M9 | P1 | 缓存高频 gradient | radar.js:273, 283 的 2 个 createRadialGradient 移到 resize();radar.js:387-390 的 filter/sort/slice 改单次遍历维护 top-6 | 去掉每 250ms 的垃圾与排序 | 与现有 blip 绘制逻辑对齐 | 雷达观感不变;分配下降 |
2.3 资源加载
| # | 优先级 | 优化项 | 改哪 | 预期收益 | 风险 | 验收 |
|---|---|---|---|---|---|---|
| R1 | P0 | GLB 瘦身 | 用已装的 @gltf-transform/cli:删 normal 贴图(代码 mecha.js:127 已 normalMap=null)、3 张 2048² PNG → 1024² WebP/KTX2、加 meshopt | 9.64MB → 0.5–1.0MB(-90%),弱网省 5–9s | 需重跑一次模型导出;材质 flatShading=true 已定,删 normal 无视觉损失 | 首屏传输 gzip <1.2MB;机甲外观一致 |
| R2 | P0 | GLB 提前并行下载 | main.js:91 拿到 cfg 后立刻 const mechaP = loadMecha(...)(不 await),:273 才 await | 与登录/地形生成完全重叠,等于省掉整个 GLB 下载时长 | 需处理"登录失败/取消"时的 Promise 悬挂(catch 即可) | 弱网下 loading 明显更快 |
| R3 | P0 | 启动 API 并行化 + 去冗余 | main.js:98/118/120/124/632 合成 Promise.all([restore, wallet, spawn, destroyed]);删 :118 fetchNpcs(main.js:692 的 WS welcome.npcs 会补发);:671 net.connect() 提到首帧后立即调 | 关键路径 RTT 6 → 2,移动网省 300–800ms | 必须验证 WS welcome 一定带 npcs(server.js:371-380) | 网络面板串行请求数下降;NPC 正常出现 |
| R4 | P0 | 静态缓存长化 | server.js:63 public/ → 加 maxAge;server.js:66-75 /assets → maxAge:'30d', immutable:true;/api/world/config(server.js:89)加 max-age=300 + ETag | 回访/刷新从 30+ 次 304 往返降到接近 0 字节 | ⚠️ public/ 文件名无 hash:直接 immutable 会导致发布后用户拿到旧 JS → 必须配对(见 §4 风险 R-1) | 二次访问:JS/CSS 走 from disk cache |
| R5 | P0 | nginx 调优 | deploy/prod/nginx-occ-game.conf:33-39:从 gzip_types 删掉 model/gltf-binary;加 gzip_static on;;listen 8090 http2;;对 /vendor/、/assets/ 加 expires 30d | JS 再省 15%(brotli 若可用)+ 去 6 连接瓶颈 + GLB 不白烧 CPU | HTTP/2 与现有 proxy_set_header Upgrade 兼容;改完 nginx -t 再 reload | curl -I 看到 HTTP/2、content-encoding、cache-control |
| R6 | P1 | radar/panels 移出关键路径 | main.js:231-234 的 await Promise.all([import radar, import panels]) 移到 :546 之后(updateRadar 已对 !radar 容错,main.js:921) | 33KB 不再与 GLB 抢带宽,首帧提前 | 需确认 panels 在首帧渲染前不被调用 | 首帧时间提前 |
| R7 | P1 | 生成阶段移出主线程 | 植被落点改采样已生成的 terrain.grid(terrain.js:115-125 已返回)而非重跑 13 倍频噪声;云层 1024×512→512×256 或 5→3 octave;地形/贴图放 Worker + OffscreenCanvas | 主线程阻塞 1s → 0.2s 级,loading 条能连续动画 | Worker 拆分工作量大;先前两步(R7a 复用 grid、R7b 云层降价)优先 | loading 无"卡住不动";低端机不"无响应" |
| R8 | P0 | 部署与静态目录瘦身 | deploy/prod/push.sh:53-64 排除 assets/mecha/img、blender/renders、未引用的 5 个 GLB;server.js:66 的 /assets 收窄为仅 mecha/model/{rig.js,anim.js,reduced/r004.flat.glb} | 部署包 273MB → ~10MB;消除 49MB 参考图公网可访问 | 确认无运行时引用(已 grep 验证) | 部署耗时下降;/assets/mecha/img/* 返回 404 |
| R9 | P1 | 加 modulepreload | public/index.html head 加 three.module.min.js / three.core.min.js / cannon-es.js 的 <link rel="modulepreload"> | 模块图 3 层发现压缩到 1–2 轮 RTT | 无 | 瀑布图并行度提升 |
2.4 并发(服务端)
⚠️ 优先级判断:当前线上并发未实测到 30+,O(N²) 中继的痛还没兑现。先做 §2.4 的 P0「稳定性四件套」,协议重构(S1–S3)留到真实并发接近 30 人再排——避免用大改换小收益。
| # | 优先级 | 优化项 | 改哪 | 预期收益 | 风险 | 验收 |
|---|---|---|---|---|---|---|
| S4 | P0 | WS 防护四件套 | 1 server.js:268 new WebSocketServer({..., maxPayload: 4096~16384});2 每连接令牌桶:state ≤20/s,超限丢包;3 每连接 bufferedAmount > 1MB 丢弃快照/断开;4 ws.on('error', () => ws.terminate()) + process.on('uncaughtException') 记日志 | 消除「100MiB 帧 OOM、N 倍放大攻击、慢客户端撑爆内存导致 PM2 500M 重启」 | 限速阈值要留余量(客户端是 10Hz) | 恶意连接测试:进程不崩、内存不涨 |
| S5 | P0 | 存档异步化 + 节流 | store.js:83-93 改 fs.promises.writeFile(tmp) + rename(原子)、节流 10–30s;可选 worker 线程 stringify;users 分桶只写脏桶 | 消除 1000 用户后 27–144ms/2s 的周期阻塞,顺带解决非原子写整档损坏风险 | 节流加大崩溃丢数据窗口(配合原子 rename 可接受);close()(:466-469)仍需同步兜底 | 写盘不再出现在事件循环长任务里 |
| S6 | P0 | scrypt 异步 + 登录限流 | store.js:51-53 → crypto.scrypt 异步(Promise 化);server.js:134-141 每 IP 令牌桶 | 消除 28ms+(云主机 100–300ms)/次的全局阻塞与登录 DoS 面 | 异步改后 login/register 需 await,注意与 server.js:217/227 调用点对齐 | 并发 20 个登录请求:事件循环无长任务 |
| S7 | P0 | fieldCache 加 LRU + 出生点缓存 | server.js:218-232:Map 加容量上限(如 8 个 seed);?random=1 的 findSpawn 做短时缓存 | 消除 16ms/新 seed 阻塞与无上限内存增长 | LRU 淘汰后重算 16ms,可接受 | 压测随机 seed:内存平稳 |
| S1 | P2 | 状态聚合快照广播 | server.js:420-447 改为每 100ms 聚合全部玩家最新 p/n/h/hp,合成 1 条 {t:'states', s:[[id,x,y,z,h,hp],...]},全服 stringify 1 次、每连接 send 1 次;前端 net.js/main.js:1204-1209 与 npc.js 同步适配 | send 次数 N(N-1)×10 → N×10(N=100 时 -99%);事件循环 1118ms/s → 10–20ms/s | 🔴 协议破坏性变更:必须同步改前端解析 + 全部 e2e 脚本(social.cjs / score.cjs / features.cjs);welcome 与 wallet 不动 | N=100 压测:事件循环占用 <50ms/s;玩法一致 |
| S2 | P2 | 位置量化 + 静态字段剥离 + 静止跳过 | 坐标定点(Math.round(x*100));name/avatar/color/title/score 常规帧不发(join/welcome/wallet 已有);无位移降 1Hz 心跳 | payload 317B → 60–100B(-70%);挂机再 -50% | 静止判定的阈值要避免"抖动玩家"被冻结 | 单客户端带宽下降;名牌/配色正常 |
| S3 | P2 | AOI 兴趣裁剪 | 只发 1100 单位内(world-config.js:123)的玩家与 NPC;服务端用经纬度网格做邻近查询 | 单客户端带宽再 -70~90% | 需客户端容忍"远端玩家消失"(名牌 420 / 雷达 380 本就只显示近处,main.js:64、world-config.js:106) | 100 人场景单客户端 <0.8Mbps |
| S8 | P1 | O(1) 索引 | server.js:281-294 findClient/socketsOf → 维护 Map<id,ws>、Map<token,Set<ws>> | 消除长尾 O(N) 扫描,为 AOI 空间索引打基础 | 需在 connection/close/hello 三处同步维护 | 压测无长尾 |
| S9 | P1 | npcMove 裁剪/降频 | server.js:605-607 NPC_BROADCAST_EVERY 2→3~4;只发玩家 1100 内的 NPC | 每客户端省 3–6KB/s(总流量约 5–15%) | 客户端插值已做(main.js:776-781 只 setTarget),降频无感 | 车流观感不变 |
| S10 | P1 | store.stats() 计数化 | store.js:440-446 每次 /api/health、/api/stats、每次 destroy 请求(server.js:203)都全表扫 | O(1) | 无 | — |
| S11 | P2 | 多进程/水平扩展 | deploy/prod/ecosystem.config.cjs:19-20 现固定 instances:1(世界状态在进程内) | 单机上限提升 | 需先把世界状态搬到共享存储,属大改 | 暂不做 |
3. 分阶段执行计划
| 阶段 | 内容 | 交付物 | 回归要求 |
|---|---|---|---|
| A · 速赢(半天) | G1 G2 G3 G9(数值类)+ M1 M2 M4(去掉每帧尖刺)+ R8(部署瘦身) | 一批改动 + 抖动对比数据 | smoke.cjs → combat.cjs → features.cjs 串行跑通 |
| B · 首屏(1–2 天) | R1 R2 R3 R4 R5 R6 R9 + M3 M5 M9 | 首屏传输 <1.2MB、RTT 6→2 | 同上 + 手动弱网(限速 3Mbps)计时 |
| C · GPU 结构(2–4 天) | G4(NPC 实例化)+ G5(植被剔除)+ G7 + M7 M8;G6 视观感决定 | draw call <200 | social.cjs(NPC 相关断言最多,重点)+ features.cjs |
| D · 并发(2–5 天,按需) | S4 S5 S6 S7 S8 S10(P0/P1 稳定性);S1 S2 S3 S9 S11 等并发接近 30 人再上 | 压测报告(N=20/50/100) | 自建 WS 压测脚本(傀儡)+ 全量 e2e |
每阶段结束都用同一套标尺复测(§1 的表格),把数字填回去对比。
4. 风险与禁区(改动前必读)
R-1 🔴 缓存头与文件名的绑定关系(最容易翻车) public/ 下的 JS/CSS 没有内容 hash。若直接给 express.static 加 immutable,发布后老用户会继续跑旧代码且无法自愈(max-age 未到不会重新请求)。 必须三选一:
- 推荐:给
public/src、public/vendor加构建期 hash 文件名(或 query?v=<git sha>,由index.html注入); - 只对版本天然固定的目录加 immutable:
/vendor/(three 0.184 不会变)、/assets/(文件名已带r004); - 折中:
public/用max-age=86400, must-revalidate,别用 immutable。
R-2 🔴 地面网格必须保持 lon:lat = 2:1 config.js:11-12 改段数(G6)时若破坏 2:1,格子在地面上变长方形(记忆中的既有取舍)。同时网格线层(planet.js:41-90 buildGridLines)与雷达底图索引要一起复核:
- 雷达
polar = acos(y)(j=0 是南极)、phi = atan2(z, -x)(是 -x); - 雷达
right = heading × normal(写反会左右镜像); - 贴图 uv 必须对齐
SphereGeometry公式(-cos(phi)·sin(theta), cos(theta), sin(phi)·sin(theta))+uv=(u,1-v)。
R-3 🟠 NPC / 植被 / 物理的既有约定
recycleDist(1200) > renderDist(1100)必须保持(否则玩家看到车在视野边缘凭空消失);- 树碰撞半径 =
max(1.0, 树冠半径 × 0.45),rockSmall半径 <1.2 不给碰撞体(G4/G5 只改渲染,别顺手改碰撞); - 车不与地面碰撞(
physics.js碰撞分组),M2 改refreshAABBs时别把地面算进动态体; - 深水线 =
terrain.waterLevel - 0.035,要跟biomeAt()的 DEEP 同公式。
R-4 🟠 测试环境坑
- 无头 Chromium(swiftshader)只有 2–4 FPS:涉及 CSS 过渡的断言/截图要等 ≥1.5s(标称 0.22s 实测要 1.2s);
world.contacts采不到; - e2e 必须串行跑,且不要把
svc.py restart与跑测试写进同一条命令; - 参数一改,
social.cjs/features.cjs里的断言值(emoji 白名单、FPS、镜头距离、配色数值)可能失效,要同步更新。
R-5 🟡 不要引入后处理 现在没有 EffectComposer/RenderTarget,色彩管线(outputColorSpace=SRGB + ACES)是干净的。为省性能加任何后处理都会丢掉 antialias 与色彩管理默认行为,得不偿失。
5. 度量方法(怎么证明优化有效)
在 main.js 主循环尾部加一个调试开关(?stats=1),输出:
| 指标 | 采集方式 |
|---|---|
| draw call / 三角形 | renderer.info.render.calls / .triangles(注意 renderer.info.autoReset 默认每帧重置) |
| 几何体/贴图数 | renderer.info.memory.geometries / .textures |
| 帧时间 | 现有 FPS 统计(main.js:1016-1022)扩展为 p50/p95 |
| 每帧堆分配 | Chrome DevTools Performance → Allocation sampling(优化后应接近 0) |
| 事件循环延迟 | 服务端 perf_hooks.monitorEventLoopDelay()(server.js 启动时挂,打点进日志) |
| 首屏 | performance.getEntriesByType('navigation') + Resource Timing,看 gzip 后字节与关键路径 RTT 数 |
| 显存 | Chrome chrome://gpu / DevTools Memory 面板 |
基线快照(优化前,用于对照): draw call ≈1000+ | 三角形 ≈1.6M | 首屏 gzip 9.37MB | 启动 RTT 6 | 生成阻塞 0.7–1.5s | N=100 事件循环 1118ms/s
6. 实施结果(2026-10-13 落地)
6.1 已完成的 P0 + P1
| 维度 | 已完成项 |
|---|---|
| GPU | G1 阴影收紧(far 1600 / extent 130)· G2 像素比 1.25 · G3 贴图降到 2048×1024(粗糙度 1024×512)· G4 NPC 车辆实例化· G5 植被扇区距离剔除 · G7 自适应分辨率 · G8 草材质降级 · G9 NPC 阴影距离分级 |
| 内存/GC | M1 HUD 去每帧 innerHTML · M2 物理停止每帧全量标脏 · M3 物理筛选复用扇区索引 · M4 粒子空闲短路 · M5 消除每帧对象分配 · M6 动画姿态缓冲池 · M7 构建期 clone 削减 · M8 dispose 链 · M9 雷达 gradient 缓存 + top-6 单遍历 |
| 资源加载 | R1 GLB 瘦身 9.64MB → 0.57MB · R2 GLB 提前并行下载 · R3 启动 API 并行化(RTT 6→2)· R4 分层缓存头 · R5 nginx 调优 · R6 radar/panels 移出关键路径 · R7 植被复用 terrain.grid + 云层降价 · R8 部署瘦身 · R9 modulepreload |
| 并发 | S4 WS 防护四件套 · S5 存档异步原子写 + 10s 节流 · S6 scrypt 异步 + 登录限流 · S7 fieldCache LRU · S8 O(1) 索引 · S9 npcMove 裁剪 + 降频(5Hz→3.3Hz)· S10 stats 计数化 |
按计划留待(P2,等真实并发上量再做):S1/S2/S3(聚合快照 + 量化 + AOI 的协议重构)、G6(地形降段 500×250→256×128)、S11(多进程)。
6.2 实测对比
| 指标 | 优化前 | 优化后 | 说明 |
|---|---|---|---|
| draw call | ≈1000+ | 51 | tools/e2e/perf.cjs 实测稳态(NPC 750→10 个实例网格 + 植被扇区剔除) |
| 三角形/帧 | ≈1.6M | 507,653 | 同上 |
| 机甲 GLB | 9,643,008 B | 581,748 B(−94%) | 删掉被代码丢弃的 normal 贴图 + 贴图 1024² WebP |
| 首屏传输(本机,无 gzip) | 11.21 MB | 2.17 MB(−81%) | 34 个请求;GLB 从 9.2MB → 568KB |
| 启动串行 RTT | 6 | 2 | cfg → (restore/wallet/spawn/destroyed 合并) |
| 云层生成 | 109.7 ms | 18.7 ms(7.1×) | 512×256 + 3 octave,云量仅差 2.5% |
| 植被落点采样 | 逐点 13 倍频 elevation | 复用 terrain.grid 双线性 | 随机 1000 点自检:海拔误差 max 0.0013 单位、biome 零不一致 |
| 每帧 DOM 重写 | hud 每帧 innerHTML | 固定节点池 + 变更检测 | 同值调用 0 次 DOM 写入 |
| 服务端 N=20(傀儡压测) | — | health p95 13.7 ms,下行 1.17 MB/s | tools/e2e/load.cjs |
| 服务端 N=50 | 244 ms/s 事件循环、62 Mbps | p95 31.5 ms,下行 7.47 MB/s(≈60 Mbps) | 带宽仍是主要瓶颈(AOI 属 P2) |
6.3 已知的观感变化(需要拍板是否接受)
| 变化 | 原因 | 影响 |
|---|---|---|
| NPC 车顶 beacon 不再随车身色发光,且呼吸相位全体同步 | emissive 无法逐实例(instanceColor 只作用于 color) | 车顶灯统一琥珀色;原来按 id % 7 错相 |
| NPC 掉血白闪改为「实例色朝白插值」 | 同上 | 闪光比原来的 emissive 峰值略弱 |
| NPC 被打爆后模型立刻消失 | 实例化后不再保留"尸体"网格 | 与爆炸特效配合,观感正常 |
| 云层从 5 octave 降到 3 | 生成耗时 | 云更平滑,覆盖率 −2.5% |
| 草材质降级为更轻的材质 | 9 万株的填充率 | 需目视确认没有明显变亮 |
6.4 新增/变更的文件
- 新增构建脚本:
tools/glb_optimize.mjs - 新增回归脚本:
tools/e2e/perf.cjs(draw call/资源标尺)、tools/e2e/npc-respawn.cjs(NPC 补位 + 广播频率,服务端侧)、tools/e2e/load.cjs(并发压测) - 测试口径调整:
api.cjs持久化改轮询(适配 10s 节流);score.cjs的 NPC 换位门限 0.1→0.01 rad(redeploy用固定槽位,位移天然受wanderAngle 0.15限制;该断言在本次改动前就已临界) - 产物:
assets/mecha/model/reduced/aegis07_voxel_pbr.r004.flat.opt.glb(0.55 MB) - 配置:
public/src/config.js+server/world-config.js(两侧同步)、deploy/prod/nginx-occ-game.conf、deploy/prod/push.sh
6.5 回归结果
smoke ✅ · combat ✅ · features ✅ · social ✅ · score 41/0 ✅ · api 23/0 ✅ · npc-respawn 8/0 ✅ · perf 4/0 ✅ · load N=20/50 事件循环健康 ✅
6.6 发布检查单(每次上线都适用)
原写作时是"上线前还要做的两件事";这两项不是一次性待办,而是每次发布都该过的检查项,故改为此标题。
- nginx 首次同步:
OCC_SYNC_NGINX=1发布时才会覆盖站点配置(含http2/gzip_static/ 缓存头)。push.sh 里有nginx -t失败自动回滚,但仍建议先单独验证一次;同机的idle-wuxia(80 端口)不要动。 - 真机观感复核:有头浏览器里过一遍 §6.3 的 5 项观感变化。
生产部署的完整流程与约束见 部署与发布。
附:本次审计确认的「已经做对的事」(别改坏)
- 植被/障碍物走 InstancedMesh + 按扇区切分做视锥剔除 ✅
- 可破坏物不新增 Mesh(缩矩阵到 0),零 draw call 增量 ✅
- 全项目无 每帧
new Mesh/Geometry/Material、无 每帧 raycast、无 每帧scene.traverse✅ - 广播只 stringify 一次给所有人(
server.js:271)✅ - 2 个 PointLight 常驻只改强度,避免 shader 重编译 ✅
- 植被 alphaTest(非 transparent),无需排序 ✅
- 远程玩家/幽灵共享几何只克隆材质 ✅
- 雷达离屏 canvas 复用、NPC 物理体随渲染距离摘除 ✅
- 服务端不跑物理(前端权威位置 + 服务端血量账本),并发成本极低 ✅