Farm Pop 是如何让每一个symbol都成为“生动”的 3D 角色的?

原文作者:Raw Fun Games
原始链接:https://medium.com/@raw.fun.games/how-we-built-farm-pop-a-slot-game-where-every-symbol-is-a-living-3d-character-ae6bca695471?source=rss------webgl-5
说明:本文为编译 + 深度解读,非逐句翻译;文中所有关键代码级参数、设置项与引擎机制保留原文标识。


Farm Pop 是 Raw Fun Gaming 开发的一款 7×7 格消除老虎机。与传统老虎机不同,它的“symbols”不是旋转图标,而是完全动画化的 3D 宠物:狗、猫、小鸡、兔子。它们会待机、在卷轴移动时奔跑、中奖时跳跃、集群消除时燃烧溶解。凑齐 5 个以上相邻symbols即触发消除,上方symbols坠落填补空缺,形成连锁消除。进入免费旋转后,整个农场从白天变为黑夜——灯笼亮起、萤火虫飞舞,直到奖励结束。

它完全运行在浏览器中,为 Stake 平台构建,而大多数玩家使用移动端 Safari。在一块棋盘上运行几十个带蒙皮、动画的角色,使用 WebGL,跑在最差的浏览器上——这就是这个工程所有有趣问题的来源。


1. 客户端是“编舞者”,不是游戏

和 Raw Fun 所有游戏一样,结果在首帧渲染前就已确定。数学引擎预生成完整结果集——“书籍”(模拟回合文件),一个用于基础局,一个用于奖励局。Web 层根据抽中的回合,把一系列类型化事件脚本发给 Unity:

  • reveal:展示棋盘,并携带逐列“预期/ anticipation”标志
  • win:哪些集群、多少金额
  • tumble:移除这些、掉落那些
  • grid boosts:网格乘数提升
  • free spins:免费旋转

Unity 的任务是漂亮地“表演”每个事件,然后回复 done。这个握手就是全部契约:每个事件结束都会向 Web 层发送完成消息,Web 层才发下一个。没有任何东西靠计时器推进,也没有竞态。这让系统具备前向兼容性:如果 Web 层发了一个引擎还不认识的事件类型,接口会立即自动完成返回,而不是让回合卡死。新的数学机制可以先上线,Unity 演示还没做也不会挂起。

更深层的收益体现在动画排序上——这正是老虎机成败的关键。因为每个事件都是离散、可等待的表演,tumble 编排(溶解 → 销毁 → 压缩 → 生成 → 落定)可以内部真正异步,而外部依然严格有序。逐列落定、交错的中奖动画、预期延迟——内部可以随意重叠,但保证在下一事件开始前完成。甚至“预期”也是数学层定义的:reveal 事件标记哪些列需要挑逗,而 Turbo 模式下“全部同时落定”的快速路径在存在标记时被显式禁用。你永远不会跳过挑逗动画。

有一个常被使用的副作用:调试控制器可以直接从磁盘加载“书”,在编辑器里对同一握手协议回放。因此整个游戏可以在完全不需要 Web 前端运行的情况下测试——每个机制、每次连锁。

解读:这种“事件驱动 + 握手确认”的架构把表现层和逻辑层彻底分离。对线上真钱老虎机来说,最怕的是客户端动画和已生成结果不同步;这里用严格串行 + 自动完成兜底解决。


2. 披着羊皮的状态机

symbols引擎的核心是每个symbols上的小型状态机:

Idle、Spinning、Settling、Landed、Anticipating、Win、Dissolving、Clicked

引擎驱动状态;游戏特定子类将状态翻译成美术能做的任何形式。Farm Pop 的翻译层将状态映射到宠物动画器:

  • 待机循环:随机选择,避免一排 7 只猫同步呼吸
  • 滚动与下落时播放跑动循环
  • 中奖时跳起
  • 落地动画只在一个symbols首次落定时播放——下落重新落定的已有symbols不应重新触发它

因为symbols是角色,它们能做角色的事。点击它会播放有独立音效的反应动画。Scatter 是一个小农夫,随机生成服装、发型,50/50 锄头或干草叉道具,因此没有两个一样。在“预期”状态时,如果再来一个 Scatter 就能触发奖励,他会开始挖地。这是平面symbols图集无法提供的叙事能力,而且它的成本为零——它使用的是同一个引擎里每个游戏都会有的“预期”状态。

“活symbols”的代价是:一切必须能经受对象池复用。每次 tumble 有 49 个带蒙皮角色在流转,所以它们全部来自对象池。默认情况下,Unity 的 Animator 对池化角色很昂贵,因为重新启用会触发完整的 Avatar 重绑定。一行设置绕开它:

keepAnimatorStateOnDisable = true;

这个值让 Animator 在禁用时保留状态,避免重新绑定。池化纪律贯穿始终:后端给出位置乘数时,获胜symbols会视觉上复制成纵向堆叠的克隆体,这些克隆体同样池化;出队时重绑 Animator,归还时恢复材质。当一个symbols溶解时,它的克隆共享同一个溶解材质实例,因此整个堆叠完美同步燃烧,无需任何协调代码。

乘法堆叠是团队在游戏中最喜欢的纯粹效果。它不是循环,而是递归波:原体压缩再弹出,在过冲峰值时第一个克隆从它内部升起;该克隆到达后弹出并启动下一个,像体育场人浪沿堆叠向上传播。发光闪烁来自同一运动——边缘光强度根据缩放过冲 1.0 的程度计算。因此一条动画曲线同时驱动弹跳与闪烁,通过共享 MaterialPropertyBlock,零额外材质实例。

由于高堆叠会超出画面顶部,这个效果请求了一个引用计数相机拉远,拉远幅度根据场景中堆叠深度计算——基于固定参考距离计算,而不是当前距离,否则会形成正反馈循环。

解读:keepAnimatorStateOnDisable 是解决 Unity 对象池中 Animator 性能问题的关键一行。如果不是原文明说,很多开发者会误以为池化角色只需处理 Transform 和材质,忽略 Animator 重绑定成本。


3. Safari,还是很糟

在上一款 Highway Rush 中团队学到:Safari 在首次绘制时编译和反射着色器,没有持久缓存——新效果第一次出现时会产生数秒主线程停顿。老虎机游戏是这个问题的极端案例:几乎所有有趣视觉都是条件生成的。中奖爆发、溶解变体、集群高亮、浮动金额、乘数覆盖层——它们在加载画面中不会自然渲染,所以全部会在玩家第一次中奖时卡顿庆祝。最糟糕的时机。

仅溶解着色器——四通道,乘以阴影、光照、雾和溶解关键字组合——在首次使用时向 WebKit 提交了大约 194 个着色器程序做 uniform 反射,WebKit 上每个 uniform 约 3ms,而 Chrome 约 0.1ms。iOS 上第一次集群中奖:冻结三秒。

修复最终成长为一个引擎服务。着色器预热系统把预制体放在隐藏 Transform 下,启动时把它移到真实相机前渲染三帧,并保持 Web 前端启动画面,直到所有预定预热完成。有两个细节比基本思路重要得多:

  1. 预热必须产生与玩法完全相同的管线状态。 Unity 的渲染管线在渲染时根据全局光照状态选择着色器变体——多少附加灯光、哪些级联、雾开或关。如果预热太早或在错误场景位置,会编译出游戏永远不会使用的干净变体,真正需要的仍会卡顿。因此服务等待约一秒让场景光照稳定,并把几何体放在游戏锚点——grid root——下,让预热symbols位于与真实symbols相同的光照体积中,管线解析出相同的光照关键字。

  2. 每个生成系统注册自己的预热。 中奖处理器预热弹出窗口(实际显示一个,使文本描边关键字被切换);溶解效果预热其半溶解状态,让片段着色器两个分支都执行;乘数管理器预热覆盖层。新引擎系统在启动时自动预热,下个游戏无需维护脆弱清单即可获得覆盖。

即使这样仍不够——逐个预制体预热无法覆盖真实中奖产生的状态组合。所以启动序列最后加入一次假旋转:三个真实棋盘symbols在启动画面后完整执行一次“中奖—溶解”循环,并使用预热标志把音效和 Web 事件调用变成 no-op,因此 Web 前端完全不知道发生过这次热身。整局游戏最危险的一帧——第一次集群中奖——在无人看到的地方被预演了一遍。

Safari 还给了更多教训。WebGL 将片段着色器限制为 16 个纹理单元,而功能丰富的卡通着色器会超过它——Unity 记录链接错误但继续渲染,默默丢弃接收阴影的变体。对象看起来正常,只是永不显示阴影,团队曾误以为是光照设置问题。现在所有需要接收阴影的对象都使用精简自定义卡通着色器,只绑定三个纹理。

字体也有自己的故事。TextMesh Pro 的动态图集模式——方便模式,字形按需光栅化——会调用 FreeType,而在 WebGL wasm 构建中 FreeType 是一个空函数指针。编辑器里完全正常,浏览器里崩溃。因此游戏里每种字体都使用静态 SDF 图集,字形全部预烘焙:每个市场的货币symbols都烘焙进拉丁字体;用于本地化奖励文本的中日韩字体从 3–5 MB 原始字体文件精简为精确的 17 个汉字和 14 个日文字符,并切断源字体引用,让构建完全丢弃字体文件。

解读:WebGL 的移动端适配不是“降低画质”那么简单。真正的问题是着色器/字形在目标浏览器上的首帧初始化成本。预热是系统性解决方案,但必须与真实管线状态一致才能生效。


4. 自由的旋转可以将太阳落下

Farm Pop 的奖励局不会切换到另一个场景——就在你面前,同一个农场从白天过渡到黑夜。一个环境控制器同时 lerp 所有内容:

  • 🟢方向光颜色与强度
  • 🟢环境球谐梯度
  • 🟢雾
  • 🟢卡通着色器的阴影色调
  • 🟢每个symbols的自发光与描边颜色,让宠物在黑暗中依然可读

然后夜灯亮起:灯笼、窗户光、萤火虫。

19 个点光源对 WebGL 来说太多了,但能负担得起的原因是一个不常规的渲染器拆分:项目发布三套渲染管线配置:

  • Forward:移动端
  • Forward+:桌面端
  • Deferred:WebGL 构建——大量小光源在这里几乎免费

但灯光仍引发了项目里最有趣的 bug:每当 Wild symbols出现,地面平面会明显闪烁。Wild 狗携带真实光源来体现发光,在大的地面平面上它与其他夜灯竞争每对象光源槽——排序列表每帧重排时,地面就会跳变。修复方式是渲染层:地面获得独立层,十九个夜灯完全排除该层,只有 Wild 的发光光源选择加入。非显然的部分是:光照层是着色器特性,不是引擎免费功能。团队手写的环境着色器必须添加 light-layers 变体,并在自己的光照循环中测试每个灯光的 layer mask 才生效。

// 在自定义环境着色器的光照循环内:
// if ((light.layerMask & objectLayerMask) == 0) continue;

阴影也得到平台专门处理:阴影距离加到 100(竖屏相机拉远曾把相机推到旧范围之外,手机端无声无息丢了阴影);WebGL 渲染器运行三个阴影级联,中间级联精确覆盖棋盘所在世界区域——在不增加 shadowmap 尺寸的前提下提升有效阴影分辨率。

解读:Deferred 在 WebGL 上解决了大量动态光源问题,但光源排序导致的闪烁往往被归咎于 z-fighting 或阴影。实际是光源层能力没有在自定义 shader 中实现。


5. 会自然生长的地块

一个纯 Farm Pop 的机制:Grid Boosts。当获胜落在单元格上,它们会累积“+N”乘数等级,每个提升的单元格会长出一个小生态系统:

  • 一块草皮,随着等级增加长出更多叶片(+1 时 10 片,封顶 55 片)
  • 蝴蝶颜色随数字攀升而升级

叶片位置由该单元格世界坐标哈希得到,因此每个单元格的草皮独一无二,但每次重新生长都完全一样。覆盖层像其他一切一样池化,并在归还时显式 teardown——早期 bug 中一个 9 级覆盖层带着 50 片草叶回到池中,准备在另一个单元格的 +1 上完全长成出现。

解读:这是一个小系统,却是整个游戏个性的缩影:一个数学引擎整数通过桥接到达,变成某种“自然生长”出来的东西,而非被硬显示出来的数字。


6. 一个引擎,每款游戏都更轻量

Farm Pop 是“Pop”系列的第一款,它与“Rush”跑酷系列共享引擎——相同的 Web 桥接、对象池、预热服务、构建强化。整个老虎机层(网格管理器、tumble 编排、预期系统、中奖表现、覆盖 UI、昼夜系统、乘数管理器)都住在共享引擎中。

Farm Pop 真正的特异部分是什么? 十个脚本,约 1,300 行:

  • Animator 适配器
  • 农夫服装随机化器
  • 蝴蝶与草皮
  • Wild 狗的发光

下一款游戏——不同动物、不同主题——其余一切复用。

甚至 2D 美术管线也依赖这一点。一个编辑器工具把真实的 3D symbols预制体渲染成透明 PNG,用于支付表与市场宣传——在编辑模式下采样动画剪辑,摆出跳跃中的姿势,使用隔离图层和一次性相机,不需要按 Play 键。支付表永远不会与游戏实际画面不一致,因为支付表就是游戏的实拍。

这是这套架构下的赌注,Farm Pop 就是证明:该类型的第二款游戏应该主要是美术。


核心代码级参数与实现要点

原文没有贴出完整代码块,以下是文中明确出现的核心代码级设置与机制,保留英文标识:

类别关键项
Animator 池化keepAnimatorStateOnDisable = true
渲染管线Forward / Forward+ / Deferred
阴影级联3 cascades,中间级联覆盖棋盘区域
纹理单元上限16 texture units(片段着色器)
着色器预热隐藏 Transform 下生成预制体,启动时移入相机前渲染 3 帧
光照层着色器循环if ((light.layerMask & objectLayerMask) == 0) continue;
字体静态 SDF atlas,预烘焙字形;移除源字体引用
乘法堆叠共享 MaterialPropertyBlock,一条动画曲线驱动弹跳与边缘光强度
相机拉远引用计数,基于固定参考距离计算

3 个核心优化要点

① 着色器预热必须与真实管线状态严格一致

Safari 在首次绘制时无缓存编译和反射着色器,造成数秒主线程冻结。解决不是“预先加载”这么简单:

  • 预热必须等待场景光照稳定,并在 gameplay anchor(grid root)下执行,使渲染管线解析出与真实游戏完全相同的光照/雾/级联关键字。
  • 每个生成系统注册自己的预热,而不是集中维护清单。
  • 启动结束前用假旋转完整预演“中奖—溶解”序列,覆盖状态组合。

否则会编译一堆永不使用的“干净变体”,真实变体依然卡顿。

② 对象池 + Animator 状态保持 + 共享材质属性块

每局 tumble 有几十个蒙皮角色流转,池化是唯一选择。但 Unity 默认重新启用 Animator 会触发 Avatar 重绑定:

  • 用 keepAnimatorStateOnDisable = true 跳过重绑定。
  • 乘数symbols的克隆堆叠共享同一个溶解材质实例和 MaterialPropertyBlock,实现同步燃烧、光效闪烁,零额外材质实例。
  • 所有池化对象归还时显式 teardown,防止旧状态残留(如 9 级覆盖层带着 50 片草叶回到池)。

③ 平台差异化渲染管线与光照/阴影裁剪

19 个点光源在 WebGL 上负担很重,解决方案是根据平台选择渲染管线:

  • 移动端 Forward,桌面 Forward+,WebGL Deferred。
  • 地面闪烁源于灯光排序竞争每对象光源槽,用渲染层隔离夜灯与地面。
  • 关键认知:光照层是自定义 shader 特性,必须在手写着色器内实现光照层循环,而不能只依赖引擎参数。
  • 阴影距离加倍,并用三阴影级联让中间级联精确覆盖棋盘,避免 shadowmap 尺寸增大。

GLSL / WebGL 专业术语检查与正名

原文术语推荐译法注意事项
shader variants着色器变体不要译成“阴影变体”
uniform reflectionuniform 反射 / uniform 查询不是“统一反射”;指编译后查询 uniform 位置与类型
fragment shader片元着色器避免“碎片着色器”
texture units纹理单元有硬件上限,这里为 16
link error链接错误链接着色器阶段报错,常被静默忽略
SDF atlas有符号距离场图集字形预烘焙,避免运行时 FreeType
spherical-harmonics gradient球谐环境光梯度用于环境光过渡,不译成“球谐坡度”
shadow cascades阴影级联级联阴影映射的级联
Forward+前向+渲染保留英文,不译成“前进”
Deferred延迟渲染不译成“推迟渲染”
light layers光照层是 shader 特性,不是引擎默认开关
pipeline state管线状态编译/渲染时必须一致
dynamic atlas动态图集需 FreeType 光栅化,WebGL 中空指针
static SDF atlas静态 SDF 图集预烘焙字形,WebGL 安全
animator rebindAnimator 重绑定Unity 术语,保持英文
object pooling对象池常用但容易被 Animator 重绑定拖累
material property block材质属性块可共享,避免额外材质实例
WebGL wasm buildWebGL WebAssembly 构建明确是 wasm 构建,不是纯 JS
ref-counted camera pullback引用计数相机拉远多个堆叠同时请求不会重复拉远

所有 GLSL/WebGL 术语均在原文语境下验证,无翻译歧义;关键参数与机制保持英文标识以对接 Unity / WebGL 开发惯用表述。


上线地址:Farm Pop on Stake
技术栈:Unity 6(WebGL)· Raw Fun Gaming engine · Stake Engine