ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

3D场景实体管理:从散落的entity.add()到EntityManager

3D场景实体管理:从散落的entity.add()到EntityManager 这个系列写到第十篇前面聊的基本都是“看得见”的东西场景怎么搭、模型怎么处理、交互怎么做。今天这篇我想换换口味讲一次代码层面的“大扫除”——把项目里散落各处的entity.add()调用全部收拾干净封装一个专职管理实体的 EntityManager。我们内部管它叫“实体管家”这个叫法挺贴切因为它管的活跟管家一模一样谁进来了、谁挂靠谁、谁被谁找、谁被清退全都得有账本。先交代一下背景。我们项目是一个 3D 场景应用场景里有建筑物模型、设备模型、名称标签、告警光柱、轨迹线、摄像机轨道这些东西我们把它们统称为“实体”。实体数量上来之后最直接的痛点就是每个业务模块都自己往全局注册表里塞实体你add一个、我add一个代码很快就失控了。如果你也在做 3D 可视化、游戏场景或者任何有大量实体需要动态创建和销毁的项目这篇里的设计思路和踩坑记录应该能直接用上。1. 满屏 entity.add() 是怎么一步步失控的1.1 实体少的时候直接 add 真的很香项目刚起步的时候实体就那么几十个几栋楼、几条路、几个标签。那时候全局暴露一个entity模块谁要加东西就调entity.add(xxx)完事。简单直接没有任何学习成本新同事上手也快。但问题就藏在这种“简单”里。entity.add()本身没有任何约束没有幂等保护没有父子关系登记没有统一索引。它只是把一个引用塞进了一个数组或者 Map然后让所有模块自己记住“我加的实体什么时候删、去哪查”。一开始没问题因为实体总量小、生命周期清晰大家都能记住自己那三五个对象。可一旦需求进入快速迭代期这个模式的脆弱性就全面暴露了。1.2 失控的三个信号一个比一个致命第一个信号是重复添加。设备详情面板用户每点开一次就调一次entity.add(deviceModel)但deviceModel是同一个引用。add 方法没有幂等判断于是同一台设备在场景里出现两份模型一开始我们以为是渲染 bug排查了半天才发现是注册表里已经有两个一模一样的实体。第二个信号是查不到实体。告警系统想给发生告警的设备加一个扫光特效但它拿不到设备的实体引用。能拿到的只有一串设备 ID而实体注册表又没有统一的按 ID 查询接口各模块只能自己在全局变量里翻来找去。代码能跑但特别脆——只要某个模块改了内部实现另一个模块立刻“找不到 object”。第三个信号最伤删除不干净。设备下面挂了名称标签、告警灯、闪烁点这些子实体的父级关系完全靠业务代码自觉维护。移除设备时只调了entity.remove(device)子实体全部残留在注册表里。等我们做全场景遍历做统计的时候总会遍历到一堆“幽灵实体”数量对不上、内存也降不下来。我抽空做了一次摸底结果让我下定了重构的决心grep -rn entity\.add( src/ --include*.ts | wc -l43 处调用分布在 21 个文件里。模块entity.add() 调用数实体类型场景初始化12建筑、地面、光照设备管理9设备模型、标签告警系统8光柱、闪烁点轨迹模块7轨迹线、插值点摄像机系统4相机轨道其他3临时调试对象这还只是add等价的remove、find、遍历逻辑散落在更多文件里。那一刻我很确定不能再靠自觉了得有一个“管家”把这些事统一管起来。2. 实体管家要管什么、不碰什么职责边界与 API 设计2.1 管家不是万能的先划清职责边界动手封装之前我最先想清楚的一件事是EntityManager 到底该管什么、不该管什么。说“管家”但它的职责不是无限的。我个人给它划了三条边界第一实体管家只管注册表不管业务逻辑。实体被创建之后内部要怎么 update、怎么渲染、怎么销毁那是业务模块自己的事管家不碰。第二管家不关心实体内部长什么样。在管家眼里实体就是一串元信息唯一 ID、名字、类型、标签、父级 ID。它甚至不需要知道这个实体背后是个 Three.js 模型还是 DOM 节点。任何对象只要你能提供一个 ID就能注册进来。第三管家不直接依赖渲染层。这是很多人在设计管理器时容易犯的错——直接在 Manager 里写死scene.add(entity.mesh)。一旦哪天你把 Three.js 换成别的渲染引擎整个 Manager 就废了。正确做法是管家只维护数据层渲染层通过事件自行同步。这三条边界立住了后面写代码就顺了。2.2 对外 API先定接口再写实现我习惯先把 API 设计出来再去填实现。这样可以逼着自己从使用者的角度思考而不是从内部数据结构反推接口。实体管家最终对外提供的 API 长这样方法作用说明register(meta, options)注册一个实体自动生成 ID、挂父节点、打标签、触发 added 事件unregister(id, reason)注销一个实体递归清理子实体、触发 removed 事件get(id)按 ID 精确查找Map 查找O(1)has(id)判断实体是否存在常用于防重复find(predicate)按条件查询实体列表适合模糊条件getByTags(tags)按标签查实体走倒排索引性能好each(fn)遍历所有实体内部做快照支持安全删除clear()清空所有实体夜间重置场景时用on(event, cb)订阅实体事件added / removed / clearedwhenReady(id, cb)等实体注册完成后回调解决异步时序问题另一个细节我把方法名定成register / unregister刻意避开原来的add / remove。原因有两个一是避免和新旧代码在命名上混淆——如果管家也叫add迁移的时候很容易把一个entity.add()看成manager.add()语义就乱了二是register有“登记在册”的意思更像管家的行为也暗示这是一个更正规的流程。2.3 为什么用 Map 而不是数组老代码里entity.add()的实现用的是数组push所以查实体的方式是找项目经理要全局数组再find复杂度感人。实体管家内部我用 Map 做主存储核心原因有三个查找是 O(1)ID 直接命中不用遍历。保持插入顺序Map 的迭代顺序就是插入顺序这个特性在渲染遍历时非常有用。比如光柱实体先注册、标签后注册遍历时就能保持稳定的层次关系。自带size/has/delete不需要额外维护计数器也不需要担心splice的性能。这个选择本身不复杂但很多人一开始会惯性用数组等到规模上来了才后悔。3. 核心实现一个 100 多行的 EntityManager 长什么样3.1 数据结构与注册流程去掉事件和工具方法EntityManager 的核心逻辑其实 100 多行就能说清楚。我先把数据结构和注册流程写出来。export type EntityId string; export interface EntityMeta { readonly id: EntityId; name?: string; type?: string; tags?: string[]; parentId?: EntityId | null; } interface RegisterOptions { id?: EntityId; parentId?: EntityId; tags?: string[]; } export class EntityManager { private _entities new MapEntityId, EntityMeta(); private _children new MapEntityId, SetEntityId(); private _parent new MapEntityId, EntityId(); private _tagsIndex new Mapstring, SetEntityId(); private _seq 0; register(meta: EntityMeta, options: RegisterOptions {}) { const id meta.id ?? options.id ?? this._generateId(); if (this._entities.has(id)) { console.warn([EntityManager] 实体 ${id} 已注册忽略本次重复注册); return this; } if (options.parentId) { this.attach(id, options.parentId); } this._entities.set(id, meta); this._indexTags(id, meta.tags ?? options.tags); this.emit(added, { id, time: Date.now() }); return this; } private _generateId(): EntityId { return entity_${this._seq}; } private _indexTags(id: EntityId, tags?: string[]) { for (const tag of tags ?? []) { if (!this._tagsIndex.has(tag)) { this._tagsIndex.set(tag, new Set()); } this._tagsIndex.get(tag)!.add(id); } } }注意attach这一步。注册的时候如果带了parentId管家会顺手把父子关系记下来。这一步的价值在于删除父实体时所有子实体可以跟着一起清理彻底告别“孤儿实体”。register返回this支持链式调用在批处理场景下写起来很舒服。3.2 注销与递归清理注销方法是实体管家最体现“管家”价值的地方。设备被移除时它名下挂的标签、告警光柱、闪烁点应当一并清掉而不是靠业务模块自己去各个数组里抠。unregister(id: EntityId, reason manual) { const meta this._entities.get(id); if (!meta) return this; for (const childId of this._children.get(id) ?? []) { this.unregister(childId, reason); } this._children.delete(id); const parentId this._parent.get(id); if (parentId) { this._children.get(parentId)?.delete(id); this._parent.delete(id); } this._entities.delete(id); this._removeTagsIndex(id, meta.tags); this.emit(removed, { id, reason, time: Date.now() }); return this; } private _removeTagsIndex(id: EntityId, tags?: string[]) { for (const tag of tags ?? []) { this._tagsIndex.get(tag)?.delete(id); } }这里有个隐藏的坑_removeTagsIndex很容易被漏掉。实体删了但标签索引里还留着这个 ID之后用getByTags查询就会返回一个已经不存在的“幽灵 ID”。我后来在写单元测试时专门补了这个用例大家如果在自己的实现里加了索引务必记得同步清理。3.3 查询、安全遍历与倒排索引查询接口没有太多花活但each的遍历策略我要多说一句。getT extends EntityMeta(id: EntityId): T | undefined { return this._entities.get(id) as T | undefined; } has(id: EntityId): boolean { return this._entities.has(id); } findT extends EntityMeta(predicate: (entity: T) boolean): T[] { const result: T[] []; this._entities.forEach((entity) { if (predicate(entity as T)) result.push(entity as T); }); return result; } each(fn: (entity: EntityMeta) void) { const ids Array.from(this._entities.keys()); for (const id of ids) { const entity this._entities.get(id); if (entity) fn(entity); } }each用的是快照遍历——先取出所有 ID 存成数组再逐个拿实体执行回调。这样做的好处是回调函数里可以安全地unregister当前实体不会导致迭代器跳过下一个。如果你直接在 Map 的forEach里删当前项迭代器会跳过后面一个元素结果就是“漏杀”。这是一个非常隐蔽的坑后面实战部分我会再展开。标签索引是一个很实用的扩展。getByTags([device, active])可以直接查出所有处于激活状态的设备实体不用每次全表扫描在实体数量过千之后性能优势很明显。4. 老代码迁移不能一把梭要一点一点换4.1 先摸底再定优先级封装好 EntityManager 之后下一步就是把代码里的 43 处entity.add()全部替换掉。这一步我没有选择“周末一把梭全改完”而是按风险分了三个批次每批改完都回归一轮场景。第一批低风险场景初始化的静态实体。这些实体在页面加载时创建一次生命周期简单替换起来最安全。第二批中风险设备管理、轨迹模块里动态创建的实体。它们涉及增删逻辑替换后要看增删是否正常。第三批高风险告警系统里频繁创建和移除的光柱、闪烁点。这些实体生命周期极短出现时序问题的概率最大。4.2 迁移前后对比拿设备展示模块举例。老代码长这样// 迁移前 const device createDeviceModel(data); entity.add(device); entity.add(createLabel(data)); entity.add(createAlarmLight(data));迁移后变成// 迁移后 const manager EntityManager.getInstance(); manager.register(createDeviceModel(data), { tags: [device], }); manager.register(createLabel(data), { parentId: device.id, tags: [label], }); manager.register(createAlarmLight(data), { parentId: device.id, tags: [alarm, light], });和老代码相比最大的变化是每个实体的归属关系和标签都显式声明了。父设备删除时标签和灯会自动跟随清理不用再担心残留。4.3 四个迁移陷阱提前给你们排雷陷阱一注册表更新了但渲染层不买账。老代码里entity.add()直接同步到渲染层页面立刻能看到模型。迁移到管家之后如果只在 Manager 里登记、没有处理渲染逻辑模型就会“消失”。正确做法是让 Manager 对外抛出added事件渲染层监听事件后把模型挂到场景树上。这样 Manager 保持纯净渲染层也能及时响应。陷阱二简单正则替换会漏掉多行调用。我 grep 的时候发现不少这样的写法entity .add(someEntity);简单正则entity\.add\(匹配不到换行后的.add手动漏掉的概率很大。建议替换完再跑一遍搜索确认结果清空。陷阱三重复执行初始化逻辑时幂等保护不够。热重载或者路由切换时有些模块的初始化代码会跑两遍。这时候register必须能识别出重复注册否则同一个实体会被插进注册表两次。我们的处理是重复注册时打 warning 且直接忽略等调试稳定后再考虑是否需要抛错。陷阱四父子顺序颠倒。批量注册时如果子实体先注册、父实体后注册attach时找不到父节点。我们早期就在一个批量注册方法里踩过这坑。解决方式有两种严格保证数组顺序父子按序排或者在attach时发现父实体不存在先把子实体挂到一个待处理队列等父实体注册后再补挂。5. 实战三天后遇到的坑和补救5.1 重复注册警告刷屏被日志淹没了真实错误迁移完成后我把日志级别调到了 warning跑了一整天场景结果发现终端里全是“实体已注册忽略本次重复注册”的警告。有几条确实是热重载导致的误报但大部分是真 bug——某个模块的初始化函数被回调了多次每次都试图重新注册同一个实体。当时我意识到一件事幂等保护可以兜底但不能掩盖调用方的错误。于是我给 Manager 加了一个strict模式。开发环境默认关闭重复注册只打一条 warning生产环境打开之后重复注册直接抛错强制业务侧暴露问题。这个改动看起来很硬核但一周之后重复注册的 bug 基本被清干净了因为错误根本藏不住。5.2 异步加载导致的“查不到实体”这是个很典型的时序问题。设备模型是异步加载的加载完才manager.register(model)但另一边场景初始化时有个模块立刻调manager.get(deviceId)拿不到实体直接走了一段空逻辑界面出现异常。后来我加了一个小的工具方法whenReady思路跟前端里loaded事件类似whenReady(id: EntityId, cb: (entity: EntityMeta) void) { const existing this.get(id); if (existing) { cb(existing); return; } const off this.on(added, (payload) { if (payload.id id) { off(); cb(this.get(id)!); } }); }实体已经注册就直接回调还没注册就挂在 added 事件上等待。这个 API 解决了一大类异步加载时序问题。后来整个项目里遇到类似需求基本都会用到它。5.3 each 遍历中删除实体结果发生了漏杀有一次清理离线设备我写了一段逻辑manager.each((entity) { if (entity.type offline_device) { manager.unregister(entity.id); } });跑完之后统计了一下发现第一批只删了一部分还有一批残留。排查了一会儿才想起来如果each在 Map 迭代过程中直接删除当前实体迭代器会跳过下一个元素。方案我前面已经说过——快照遍历。改完以后each内部先Array.from(keys())再按快照数组逐一处理删除操作就不会污染迭代过程。这里也提醒各位任何对外暴露遍历接口的容器类工具都要认真考虑“遍历中修改容器”这个操作的安全性。这是所有集合类工具最容易踩的坑没有之一。5.4 频繁查询触发的性能问题实体数量刚到四位数的时候find全量遍历还算能扛但业务侧有一个逻辑每秒钟会调用十几次“查询所有标签为 active 的设备”CPU 占用肉眼可见地上涨。优化思路很直接给getByTags加倒排索引按标签快速定位实体 ID 集合再回查实体本身。索引第一版实现很快但也带来了一个之前提到的问题删除实体时忘了从索引里摘除 ID导致索引膨胀、查出来一堆不存在的数据。所以后来我把“从所有索引结构里清理实体”这个动作放在unregister的固定流程里而不是靠调用方提醒。这个问题我们在单元测试里专门写过回归用例之后就再也没复发过。最后说一个我正在做的扩展方向给实体管家加序列化能力。因为有了统一的注册表保存和恢复场景变成了很自然的事——遍历each()导出所有实体的 id、type、tags、parentId恢复时按顺序register回去一套完整的场景快照就搞定了。如果你也想在项目里做类似的实体管理制度我的建议是第一版别想着大而全先把register、unregister、get、each这四个方法做扎实跑通一个完整的添加-查询-删除链路再逐步补事件、标签索引、序列化。实体管家这种东西功能每多一分使用门槛就高一寸够用就好。
返回列表