相机这东西,玩家从来不会说它好,只会说它坏。996 引擎的相机系统在 logic/gameCameraController.lua,核心代码薄得惊人,但里面藏着一个价值千万元的数值——角色为什么永远不在屏幕正中间。今天把这套跟随逻辑拆开,讲完你就知道那两个偏移数字是怎么来的,以及为什么全服二开都在抄它。
先看主逻辑,掐表读完:
function gameCameraController:FollowMainPlayer()
if not self.mBoundaryB then
return -- 地图边界没初始化,先不干活
end
local mainPlayer = global.gamePlayerController:GetMainPlayer()
if not mainPlayer then
return
end
local playerPos = mainPlayer:getPosition()
-- 角色没动?直接返回,一帧都不多干
if self.mFollowActorPos.x == playerPos.x and self.mFollowActorPos.y == playerPos.y then
return
end
self.mFollowActorPos.x = playerPos.x
self.mFollowActorPos.y = playerPos.y
if self.mCamera then
local tempPos = cc.pAdd(playerPos, self.mViewCentOffset)
self.mCamera:setPosition(tempPos) -- 相机 = 角色坐标 + 中心偏移
end
end
没有缓动,没有弹簧,没有插值——相机就是死死咬住角色坐标加一个偏移。很多人第一反应是"这也太土了,我给它加个平滑"。先别动,传奇式移动本身是逐格插值的(走路状态机那篇讲过 0.5 秒一格的插值),角色坐标已经是连续的了,相机再插一层就是插值的插值,出来的效果是"漂",老玩家管这叫晕船。相机手感的第一原则:输入平滑,跟随就别平滑;输入硬,你才补一层缓动。
另一个值得划线的是那个早退判断:mFollowActorPos 缓存上一帧坐标,没变直接 return。别小看这两行,setPosition 即使值相同也会触发一次变换dirty标记,下游整个渲染树的矩阵全部重算。站桩挂机一晚上,这个 if 省下的计算量比任何"优化大师"都实在。你写自己的跟随逻辑(小地图、Boss血条跟随),把这个早退抄过去,白赚。
Tick 的挂载位置也有讲究:gameCameraController:Tick(dt) 里只干一件事——调 FollowMainPlayer。它挂在场景逻辑的最后一步,等所有角色这一帧的移动都算完了才动相机,保证相机看到的是本帧的最终局面。你要是把它挂到渲染回调或者更早的逻辑节点上,就会偶尔拍到"半帧世界":角色已经走了,怪物还没走,画面撕裂一瞬。相机是每帧的最后一个观众,让它最后一个进场。
Camera 咬住的不是角色本尊,是 mViewCentOffset 偏移后的点。看初始化:
function gameCameraController:InitMapScrollFollow()
local winSize = global.Director:getWinSize()
self.mFullScreenSize = cc.p(winSize.width, winSize.height)
self.mHalfScreenSize = cc.pMul(self.mFullScreenSize, 0.5)
-- PC端默认中心偏移,可被配置表 GAME_DATA.PCMapCenterOffset 覆盖
local pcOffset = cc.p(-22, -80)
if global.isWinPlayMode and SL:GetValue("GAME_DATA", "PCMapCenterOffset") then
local valueList = G_GetSplitArray(SL:GetValue("GAME_DATA", "PCMapCenterOffset"), "|")
if tonumber(valueList[1]) and tonumber(valueList[2]) then
pcOffset.x = tonumber(valueList[1])
pcOffset.y = tonumber(valueList[2])
end
end
local offset = global.isWinPlayMode and pcOffset or cc.p(0.0, 0.0)
self:SetViewCenterOffset(offset.x, offset.y)
end
PC 端偏移 (-22, -80):角色显示在屏幕中心偏上 80 像素、偏左 22 像素的位置。为什么偏上?PC 屏幕下方压着技能栏、聊天框、血球,脚底下还站着宠物和掉落物——把角色抬到中间偏上,角色脚下的地面信息就露出来了,你点地面、看点掉落、看脚下红圈预警,全都看得见。移动端没有这些压屏控件,偏移就是 (0,0),角色站正中间。偏移量是 UI 布局的函数,这就是为什么它做成配置 PCMapCenterOffset,用竖线分隔两个数字,运营想微调不用改代码。
那 -22 这个横向小偏移是干嘛的?我把话说破:修一个老 bug。某些分辨率下角色正中间时,鼠标指针恰好悬在角色身上,点击会被角色挡住变成"选中自己",往左偏 22 像素错开半个身位。就这么点事,一代代传成了默认值。你以后遇到"祖传数字"别急着清理,先问它治过什么病——上一个这么干的人,清掉 (-22,-80) 的第二天就被玩家截图教育了。
偏移的正负号方向也提醒一句,这地方的坐标系坑过不少人。相机 setPosition 是把相机中心搬到那个点,偏移 Y 取负值,视觉上角色在屏幕里向上挪——你可以这么记:相机往上看,世界往下走。当年有个项目组想"角色往下挪一点",把 -80 改成 -40,打包一看角色反而更靠上了,一帮人对着坐标系骂了半小时街。记住口诀:偏移是相机的位移,不是角色的位移,方向永远反着来。 拿不准就在文章里的演示拖一遍滑块,一秒钟的事,比猜快。
顺带讲讲这套设计里我最佩服的一个决定:偏移被抽象成 SetViewCenterOffset 接口,而不是写死在跟随函数里。这一层抽象让后面所有的适配需求——PC 配置覆盖、移动端归零、刘海安全区补偿——全部落在同一个入口上,每一层加自己的数就行。假如当年图省事直接在 setPosition 里硬编码,后来的每个适配需求都是一次对核心函数的开膛破肚。接口留得对,需求来了是加法;接口留错了,每个需求都是手术。
flowchart LR
A[角色坐标 getPosition] --> B{与上帧相同?}
B -->|是| C[直接 return 省一次矩阵重算]
B -->|否| D[缓存 mFollowActorPos]
D --> E[中心点 = 角色坐标 + mViewCentOffset]
E --> F{mBoundary 已初始化?}
F -->|是| G[钳制到地图边界内]
F -->|否| H[跳过钳制]
G --> I[camera:setPosition]
H --> I
相机咬住角色还有个副作用:角色走到地图边缘时,相机跟着出界,屏幕外露出一片黑。引擎的解法是 mBoundaryL/R/T/B 四个边界值,跟随前先钳制。这个数据哪来的?进地图时按地图尺寸减去视口尺寸算出来的,视口又要算缩放:
function gameCameraController:GetViewSize()
if self.mViewSize then
return self.mViewSize
end
local size = global.Director:getWinSize()
if self.mCamera then
local scaleX, scaleY = CalcCameraZoom(self.mCamera)
size.width = math.ceil(size.width * scaleX) -- 缩放后真实视野宽
size.height = math.ceil(size.height * scaleY)
end
self.mViewSize = size
return self.mViewSize
end
注意 CalcCameraZoom:开了缩放的相机,视口尺寸要乘缩放系数才是"真实能看到多少世界"。边界钳制如果拿原始 winSize 去算,缩小镜头时边界外会露黑边,放大时边缘又贴太死。这层换算是新手做相机最容易漏的,症状特别典型:放大镜头没事,一缩小,地图右上角闪黑边。
顺便交代边界值本身的初始化时序:mBoundaryB 没值时跟随函数开头就直接 return——地图尺寸要等资源加载完才知道。这意味着切图的头几帧相机是不动的,等边界就位后第一帧可能跳一下。个别服反馈"进图画面闪一下",根源就在这。治法是在边界就位前先把相机钉在出生点,别用上张地图的残留坐标凑合,凑合的那几帧就是闪感的来源。
下面这个演示把相机拆给你看:角色在 1360×720 的大世界里自己溜达,蓝框是相机视口,黄点是带偏移的视口中心,右上角小地图实时显示视口在世界里的位置。拖偏移 X/Y 滑块感受 (-22,-80) 的用意,关掉边界钳制再把角色走到边上看看黑边怎么露出来,最后点震屏——注意震屏只抖画面、不动相机坐标:
fx-cam-follow-1003b
去年安卓全面屏铺开,工单来了:"角色被前置摄像头挖了个洞"。乍一听是渲染 bug,其实是相机偏移的全局适配问题:刘海占掉视口顶部几十像素,角色还是按老偏移站在中间,视觉重心整体下坠,加上状态栏透明化,头顶血条直接怼进摄像头里。
第一版修法很直接:安卓机全量把 offsetY 改 -110。上线第二天又炸了——部分机型系统 已经把游戏视口避开了刘海,等于偏了两次,角色快贴到屏幕顶了。最后老老实实做了分层:引擎层读系统安全区(safe area inset),换算成视口内的像素补偿,叠加到 SetViewCenterOffset 上;配置表的 PCMapCenterOffset 只管 PC 端审美偏移,两者相加才是最终偏移。修完的规则一句话:平台层管"屏幕缺一块"的补偿,配置层管"好看不好看"的偏好,谁的孩子谁抱走。
这次改造还留了个礼物:把偏移改成了运行时可调,策划拖滑块实时看效果,定稿了把数字填回配置表。相机手感是玄学,玄学就得给调参的人一个滑块,别让他们改一次打包一次。
再补一个和视口尺寸直接相关的案例。某服玩家群体年龄偏大,工单全是"字太小、人太小"。团队的做法是做"老年模式":CalcCameraZoom 把相机拉近 1.25 倍,等效于整个世界放大四分之一。听起来一行代码,落地时连着炸出三个次生 bug,个个跟视口尺寸有关。
第一个,边界钳制失效。 GetViewSize 的缓存 mViewSize 是初始化时算的,缩放改了缓存没失效,边界按旧视口算,地图右下角露黑边。修法是缩放变化时把缓存置空,逼它重算——缓存失效策略,永远是改完缩放后第一个要检查的事。
第二个,点击坐标错位。世界坐标和屏幕坐标的换算散落在鼠标事件层,那边自己维护了一份视口尺寸,没跟着缩放走,玩家点地面,角色走到点位的 1.25 倍远处。两份视口尺寸,两个真相,必炸。修法是把视口尺寸收口到 GetViewSize 一个出口,全引擎只此一份。
第三个,刘海补偿翻倍。安全区补偿是按视口顶边算的,放大后视口变小,同一块刘海占的比例变了,老补偿值把角色顶过了头。补偿逻辑改成按"屏幕物理像素"而非"视口逻辑像素"计算,一次到位。
这三个坑的共同点:都藏在"谁还引用了旧尺寸"里。缩放类改动的验收清单就一条——把 GetViewSize 的调用方全部列出来逐个过,一个都别信缓存。这一单总共四天,三分之二的工时花在找引用上,缩放本身十分钟。
引擎的受击震屏是另起一层实现的,不直接改 camera 坐标——这不是偷懒,是守规矩。相机坐标是语义状态("我在看哪"),震屏是表现效果("这一下很疼"),混在一起写,震屏结束的残值会污染跟随判断:mFollowActorPos 的早退比较发现坐标"变了",相机永久偏移几个像素,越震越歪。表现层效果永远走独立通道叠加渲染,结束归零。这条纪律适用于所有类似功能:飘血、闪白、色盲滤镜,都不许碰语义状态。
我们踩过的真实一跤:早期某个项目让实习生把震屏直接写进 FollowMainPlayer,PVP 团战里十几个角色同时受击,相机像帕金森一样抽,录屏发群里大家笑了一晚上,笑完改了一下午。相机是全屏共享的单点资源,谁都能读,只该一个人写。
问:相机需要平滑跟随吗?做"丝滑运镜"会不会更高级?
网格步进的移动下不要加,前面说过会晕船。坐骑冲刺、传送落点这些大位移想加运镜,单独做短时插值并锁玩家输入,跟常规跟随分开。高级感来自分段处理,不来自全局平滑。
问:小地图怎么跟相机联动?
读 GetViewCamera 的位置除以地图尺寸换算比例,画个矩形就行。注意取的是钳制后的坐标,不然角色站地图边上时小地图的视口框会飘出去。
问:双屏(组队跟随队友镜头)怎么做?
别做。相机的单人语义是刻在整套系统里的,"看队友视角"做成小窗画中画,各自独立相机实例,互不写对方状态。共享一个相机做镜头切换,切回来时各种状态残值够你修一个星期。
问:跟随要不要加死亡死亡瞬间震屏、镜头拉近的仪式感?
可以做,但要走独立表现通道:拉远拉近用临时缩放补间,结束回到正式值;震屏叠加在渲染层。仪式感功能最容易翻车的地方就是直接改语义状态,测试用例里必须有一条"仪式播完,角色继续走,镜头纹丝不差"。
👉 完整课程入口:996 全套课程体系(千余节课录) | 想跟浮生梦老师系统学的,看 LUA 高并发商业架构路径。
全站技术干货持续更新:996 引擎 / Lua 实战帖,语法、参数与示例一篇讲透。进入文章地图 · 查看全部 →
全服喇叭喊话"恭喜 &<PLAYER_NAME & 获得 &<ITEM_NAME/2001 &",这条公告里的两个占位符是怎…
读引擎 UI 代码时你一定会撞见这样的写法: self._quickUI.btnClose 、 SL:GetValue("x…
玩家手机锁屏再解锁,游戏还在原地;地铁过隧道断网半分钟,回连后接着玩——这两件"理所当然"背后是 logic/gameWor…
每个引擎都有一个"心脏":每帧跳动一次、按固定顺序叫醒所有系统的主循环。996 引擎的心脏在 logic/gameWorld…
玩家买一瓶药,背包角标的元宝、商店界面的元宝、充值面板的元宝三处数字同时跳——这个瞬间几乎没有玩家会注意到,但做客户端的人都…
新接手引擎渲染层的人,打开场景会看到一锅粥:地图、角色、特效、血条、UI 全糊在一起。996 引擎的答案是把场景拆成一张"座…