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 前端启动画面,直到所有预定预热完成。有两个细节比基本思路重要得多:
-
预热必须产生与玩法完全相同的管线状态。 Unity 的渲染管线在渲染时根据全局光照状态选择着色器变体——多少附加灯光、哪些级联、雾开或关。如果预热太早或在错误场景位置,会编译出游戏永远不会使用的干净变体,真正需要的仍会卡顿。因此服务等待约一秒让场景光照稳定,并把几何体放在游戏锚点——grid root——下,让预热symbols位于与真实symbols相同的光照体积中,管线解析出相同的光照关键字。
-
每个生成系统注册自己的预热。 中奖处理器预热弹出窗口(实际显示一个,使文本描边关键字被切换);溶解效果预热其半溶解状态,让片段着色器两个分支都执行;乘数管理器预热覆盖层。新引擎系统在启动时自动预热,下个游戏无需维护脆弱清单即可获得覆盖。
即使这样仍不够——逐个预制体预热无法覆盖真实中奖产生的状态组合。所以启动序列最后加入一次假旋转:三个真实棋盘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+,WebGLDeferred。 - 地面闪烁源于灯光排序竞争每对象光源槽,用渲染层隔离夜灯与地面。
- 关键认知:光照层是自定义 shader 特性,必须在手写着色器内实现光照层循环,而不能只依赖引擎参数。
- 阴影距离加倍,并用三阴影级联让中间级联精确覆盖棋盘,避免 shadowmap 尺寸增大。
GLSL / WebGL 专业术语检查与正名
| 原文术语 | 推荐译法 | 注意事项 |
|---|---|---|
shader variants | 着色器变体 | 不要译成“阴影变体” |
uniform reflection | uniform 反射 / 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 rebind | Animator 重绑定 | Unity 术语,保持英文 |
object pooling | 对象池 | 常用但容易被 Animator 重绑定拖累 |
material property block | 材质属性块 | 可共享,避免额外材质实例 |
WebGL wasm build | WebGL WebAssembly 构建 | 明确是 wasm 构建,不是纯 JS |
ref-counted camera pullback | 引用计数相机拉远 | 多个堆叠同时请求不会重复拉远 |
所有 GLSL/WebGL 术语均在原文语境下验证,无翻译歧义;关键参数与机制保持英文标识以对接 Unity / WebGL 开发惯用表述。
上线地址:Farm Pop on Stake
技术栈:Unity 6(WebGL)· Raw Fun Gaming engine · Stake Engine
