CHUAN2 DEV ENGINE
996 正版授权研发中心 · 360 授权合作教学中心 · 抖音传奇直播合作授权 · 快手推广运营商授权
OFFICIAL LICENSED ACADEMY 查验官方授权证书 →
// 威海旷世互娱教学基地 · 技术文章
进阶实战游戏功能996引擎

人山人海时谁被隐了身:同格视野管理actorInViewController

2026-10-03 07:56 作者:996 技术组 996引擎Lua教程传奇脚本进阶实战游戏功能996引擎

国战活动的经典惨案:两百个玩家挤在比奇城门口对砍,帧率雪崩,人堆里谁是谁全靠缘分。996 引擎对此有一套很刁钻的解法,藏在 logic/actorInViewController.lua——它不砍渲染,不降画质,而是让挤在同一格上的多余玩家集体隐身。这套逻辑一百八十行,思路野得很,值得整篇讲。

先给它定位:这是视野管理体系里的"密度治理"层。引擎的视野体系分三层,最外层是进出视野(actorOutOfView,玩家屏幕外的怪直接冻结,走路那篇提过),中层是本篇的同格密度治理,最内层是序列帧缓存那套按需加载。三层各管一段,出问题时先分清是哪层的病:屏幕外消失是外层,人堆里隐身是中层,图标显示异常是内层。分不清层位,排查就像在三条平行线上同时找一根断掉的线。

一、问题先看准:卡的不是人多,是"叠"

常规直觉是"同屏两百人就卡",但引擎的实测数据不是这么说的。两百个人散在半张地图上,帧率纹丝不动;同样两百人叠在十个格子里,立刻掉帧。根子在于格子制渲染的排序成本:同一格上的角色要按 Y 排序、互相遮挡、贴图混合,十个人叠一格的渲染开销是分散场景的几十倍。性能killer从来不是总量,是密度。

还有一层看不见的成本:同格排序本身是每帧都要做的。渲染层为了画对人形遮挡关系,每个格子内的角色按 Y 坐标排一次序,两个人排一次无所谓,八个人叠一格就是每帧八个元素的多次比较交换,乘上满屏几十个这样的格子,CPU 就在干重复劳动。密度治理把格子人数钉死在两个以内,排序成本变成常数,这才是帧率回来的完整原因——省的不只是贴图混合,还有每帧的排序逻辑。给后来人的启示:分析渲染成本要看"每帧每格在干什么",不要只数"屏上有多少个东西"。

所以引擎的刀口选在密度上:actorInViewController 给每个格子记数,同格叠太多的角色不再渲染成实体。看它的核心判定:

lua
local Max = 3              -- 同一格最多容许2个实体可见
local Min = 0
local ShowMaxNumInView = 200   -- 在场总数低于此值时,全员可见

function actorInViewController:IsVisible(actor)
    if not actor then
        return false
    end
    -- 在场总人数没到高峰线,谁都不少
    if self:GetActorCount() < ShowMaxNumInView then
        return true
    end
    -- NPC、非玩家非怪等,永不隐藏
    if not (actor:IsMonster() or actor:IsPlayer()) then
        return true
    end
    local posID = self:GetActorPosSign(actor)
    if not self._posIDActors[posID] then
        return false
    end
    local count = self._posIDActors[posID][actor:GetID()]
    if not count then
        return false
    end
    -- 同格计数:第1、2个可见,第3个起隐身
    if count > Min and count < Max then
        return true
    end
    return false
end

两级开关读清楚:ShowMaxNumInView 是总闸,平时人不多大家相安无事;总在场破了两百,分格闸门启动,同一格上只留前两个实体露脸,第三个起变成隐身状态。注意隐身不是删除——角色的数据、血条、组队关系全都还在,只是不画出来。你在人堆里给自己加血、对目标放技能,一切照常,只是"看见"这件事被省着用了。

二、格子的身份证:posID 打包术

整套系统的键是一个精心打包的整数:

lua
function actorInViewController:GetActorPosSign(actor)
    return actor:GetMapX() * 65536 + actor:GetMapY()
end

横坐标乘 65536 加纵坐标,两个数拼成一个数当哈希键。为什么不拼字符串 "x_y"?因为字符串键每次拼接要建新串,哈希也慢,整数键在 Lua 里就是数组下标级别的访问速度。65536 这个乘数保证了纵坐标不越界覆盖(地图纵坐标远小于 65536),解码也容易:posID 右移 16 位得 X,低 16 位得 Y。这个打包术在掉落物管理那篇的 KEY_MAP_XY 里见过同款,引擎里凡是"格子 → 数据"的映射全是这一手。坐标打包成整数键,是格子制游戏的第一课,你写任何格子系统都该这么起手。

三张表的分工和掉落管理器是近亲:_actorCounts[posID] 记每格人头数,_actorPosIDs[actorID] 记每个人在哪个格,_posIDActors[posID][actorID] 记这个人在这格的序号。序号就是可见性的判据——进格时领号,1 号 2 号露脸,3 号往后隐身;前面的人走了,DelActor 会刷新计数,后面的人自动补位露脸。发号、销号、补位,一套逻辑像极了餐厅等位。

mermaid
flowchart TD
A[角色动作开始/结束] --> B{是移动动作?}
B -->|是| C[AddActor 入视野控制器]
C --> D{主玩家? 自己宝宝? NPC? 尸体?}
D -->|是| E[豁免: InView=true 永远可见]
D -->|否| F[UpdateActor 三表登记]
F --> G[posID = mapX*65536+mapY]
G --> H[本格计数+1 领号]
H --> I{在场总数 ≥ 200?}
I -->|否| J[可见]
I -->|是| K{格内序号 1~2?}
K -->|是| J
K -->|否| L[InView=false 隐身]
L --> M[前面的人走后自动补位露脸]

三、豁免名单:体贴藏在细节里

AddActor 开头那串判断是这套系统的人情味所在:

lua
function actorInViewController:AddActor(actor)
    ...
    local actorID = actor:GetID()
    if actorID == mainPlayerID then
        actor:SetKeyValue("InView", true)     -- 主玩家永不隐身
        return false
    end
    -- 自己的宝宝
    if SL:GetValue("ACTOR_MASTER_ID", actorID) == mainPlayerID then
        actor:SetKeyValue("InView", true)
        return false
    end
    if not (actor:IsMonster() or actor:IsPlayer()) then
        actor:SetKeyValue("InView", true)     -- NPC/采集物等永不隐身
        return false
    end
    if actor:IsDie() or actor:IsDeath() then
        actor:SetKeyValue("InView", true)     -- 尸体不隐(要挖肉/复活)
        return false
    end
    self:UpdateActor(actor)
end

四类豁免,每一类都对应一种玩家刚需:主玩家隐身了还玩什么;宝宝的隐身会引发"我宠物丢了"的连环工单;NPC 被隐身则传送、买卖全废;尸体隐身会让道士挖不了肉、队友摸不了装备。性能优化最容易翻车的地方就是把"可以省"当成"该省"——引擎把交互刚需逐个列成豁免名单,宁可多渲染这些少数派。你做类似优化时,这张豁免清单就是验收用例:挨个场景点一遍,少豁免一个就是一批工单。

SetKeyValue("InView", ...) 的写法也值得学:可见性不直接操作节点,而是写到角色的通用键值表里,渲染层订阅这个键。谁用谁取,中间不产生耦合——又是"数据广播、各方认领"的那套架构哲学,在通知机制篇里我们专门讲过。

登记的时机也有讲究。看 onRegister 里挂的那两个钩子——AddHandleOnActBegin 和 AddHandleOnActCompleted,玩家和怪物两边都挂:

lua
local function dealAct(actor, act)
    -- 移动中视野处理
    if actor and GUIFunction:IsMoveAction(act) then
        global.actorInViewController:AddActor(actor)
        global.Facade:sendNotification(global.NoticeTable.RefreshMoveInView, actor)
    end
end

global.netPlayerController:AddHandleOnActBegin(dealAct)
global.netMonsterController:AddHandleOnActBegin(dealAct)

只有移动动作才触发重登记——因为只有移动会改变格子归属,站着的角色格子没变,号就不用重发。这个"按需登记"把重算频率压到了最低:一场国战里几百个角色,每帧真正动格子的可能就十几个,其余的全部跳过。RefreshMoveInView 通知是发给补位者的:前一个人走了,队列里下一位收到通知补位露脸。观察者模式在这里不是架构装饰,是补位机制的实际传输线。

下面这个演示把隐身规则做成了可操作的:连点"+10玩家"把主城挤爆,看阈值触发后同一格第三个起的圆点变成红色虚线虚影;拉低 ShowMaxNumInView 滑块体验"总闸"的灵敏度;加个宝宝看看豁免怎么保它永远露脸:

demo
fx-inview-crowd-1003c

四、实战案例:比奇国战的一次调参

某服国战固定三百人同屏,城门口帧率 17。我们在引擎默认参数上做了三步调优,全程没改逻辑只动数字,两周的国战季平稳落地。

第一步,把 ShowMaxNumInView 从 200 降到 150。总闸提前介入,早降早轻松。别小看这个数:在场 150 到 200 之间的区间里,旧参数是"全员硬扛",新参数让密度治理提前起效。实测城门场景 GPU 压力峰值降了 22%。

第二步,Max 从 3 收到 2(同格只留 1 个可见)。这一步有争议:隐身多了玩家会投诉"看不到队友"。我们折中加了组队豁免——同队成员永远可见,再叠 RefreshMoveInView 的补位刷新(引擎自带,前一个隐身者离开时自动唤醒队列里下一位)。玩家实际反馈出奇地好:"人堆里能看到队友了"反而被夸。

第三步,给隐身加渐变。隐身本来是瞬间消失,观感突兀;把 SetKeyValue 的消费端改成 0.2 秒透明度渐隐,视觉上像人潮自然淡去。三步改完帧率 17 到 41,投诉从日均 9 条到 0。

这单的总结陈词:引擎给了机制,参数给了诚意。 同一套 actorInViewController,参数调前调后是两个世界,但机制一行没动。二开拿到这类系统,先别写代码,把参数表摊开和运营聊清楚"你能接受什么、玩家不能接受什么",比什么都强。

补一个那次调参里意外发现的彩蛋:隐身者的选中也受 InView 影响,鼠标点到隐身者身上不会选中他——这本来是渲染省略的副产品,却意外成了国战的人堆保护机制:敌方无法精确点选我方隐身单位,集火只能靠范围技能。策划知道后如获至宝,后续的"人群推挤"玩法全建立在这个"副产品"上。给团队提个醒:性能优化改变的不只是帧率,还有玩法空间,改完参数回头问问策划"这行为变化对你有没有用",有时能白捡一个玩法。

五、两个深坑提前填

坑一:切图没 Cleanup。 Cleanup 清四张表,切图时必须调。某服漏调,上一张图的计数残留,新地图的格子"天生满员",玩家一落地就成片隐身,排查一晚上发现是脏表。切图钩子里挂 Cleanup,写进验收清单。

坑二:死亡复活没走 AddActor 重登记。 尸体是豁免的,但复活瞬间身份从"尸体"变回"玩家",必须重新 UpdateActor 领号。有服复活逻辑自己写了套登记,和引擎的号发重了,一个格子里两个"1号"同时可见、后面全员隐身——表现是"复活点站着两个人,第三个人开始以后全是空气"。复用引擎的 UpdateActor,别自己另立炉灶,三张表的手写同步几乎必错。

坑三:posID 的 65536 假设被打破。 打包术成立的前提是纵坐标永远小于 65536,正常地图天经地义。但有人做"无缝大地图"二开,把 Y 当成了全局世界坐标往上叠,叠到第 65536 格以上,X 和 Y 在 posID 里搅成一团,两个毫不相干的格子算出同一个键,隐身判定互相串门——A 格满了把 B 格的人也隐了。要上大坐标,先把打包位数扩成位移写法 X << 20 | Y,同时确认所有 KEY_MAP_XY 的调用方一起改。数字的隐含假设要写进注释,不然三年后总有人踩一脚。

六、常见疑问

问:隐身的角色能看到我吗?
能。隐身是渲染单向的省略,数据、碰撞、技能目标全在。你以为他没来,他的刀已经在你路上了——这是特性不是 bug,国战的侦察玩法就靠这个信息差。

问:血条和名字也一起隐吗?
跟随 InView 键走,但引擎给了细分:血条强制显示可选。国战服建议开"隐身者显示名字不显示身体"的中间态,找人不难、渲染照省,这个开关在 HUD 层,一份数据两种消费的经典示范。

问:为什么阈值是 200 而不是 100 或 500?
实测的甜点值:现代设备渲染 200 个分散角色毫无压力(问题只在密度),低于 200 触发隐身反而让普通场景出现无谓的隐身投诉。你要改,先在自己最差的机型上跑分,别拍脑袋。

问:隐身者的头顶工会名、称号怎么处理?
和血条走同一个 InView 键,默认一起隐。但大公会战的"找会长"需求真实存在,成熟做法是给特定称号开豁免(和尸体、NPC 同一张豁免表加一条),而不是整体放开。豁免名单是这套系统唯一应该生长的地方,别在判定函数里加特判——名单好维护,特判散出去就收不回来了。

👉 完整课程入口:996 全套课程体系(千余节课录) | 想跟浮生梦老师系统学的,看 LUA 高并发商业架构路径。

作者履历与出处

本文由 996 技术组基于 996 引擎官方知识库与浮生梦老师课程体系整理。团队长期从事传奇类引擎 Lua 后端逻辑、客户端界面与商业版本交付,内容以官方知识库与真实项目为出处,按版本持续修订。

← 返回文章地图返回研学路径

幂尔框架 · 实战干货 · 接口调用

LATEST ARTICLES

全站技术干货持续更新:996 引擎 / Lua 实战帖,语法、参数与示例一篇讲透。进入文章地图 · 查看全部 →

进阶实战游戏功能

一把钥匙开全引擎的配置门:MetaValue元变量系统

全服喇叭喊话"恭喜 &<PLAYER_NAME & 获得 &<ITEM_NAME/2001 &",这条公告里的两个占位符是怎…

2026-10-03 11:55 996 技术组
进阶实战游戏功能

ui.按钮名就能拿到控件:LuaExtend元表的八十行魔法

读引擎 UI 代码时你一定会撞见这样的写法: self._quickUI.btnClose 、 SL:GetValue("x…

2026-10-03 11:30 996 技术组
进阶实战游戏功能

断线是谁先发现的:心跳三兄弟与切后台补发

玩家手机锁屏再解锁,游戏还在原地;地铁过隧道断网半分钟,回连后接着玩——这两件"理所当然"背后是 logic/gameWor…

2026-10-03 11:24 996 技术组
进阶实战游戏功能

全引擎的下一拍在这里敲:主循环Update解剖

每个引擎都有一个"心脏":每帧跳动一次、按固定顺序叫醒所有系统的主循环。996 引擎的心脏在 logic/gameWorld…

2026-10-03 11:17 996 技术组
进阶实战游戏功能

元宝数字为什么全屏同步:CostItemCell消耗格台账

玩家买一瓶药,背包角标的元宝、商店界面的元宝、充值面板的元宝三处数字同时跳——这个瞬间几乎没有玩家会注意到,但做客户端的人都…

2026-10-03 11:10 996 技术组
进阶实战游戏功能

每个东西都有专属座位:sceneGraph功能节点树

新接手引擎渲染层的人,打开场景会看到一锅粥:地图、角色、特效、血条、UI 全糊在一起。996 引擎的答案是把场景拆成一张"座…

2026-10-03 11:02 996 技术组