« January 2025 | Main | March 2025 »

February 20, 2025

2D 渲染管线的一点优化

考虑到我想做的独立游戏并不需要以画面效果吸引人,游戏是策略向的,所以 2D 表现就足够了。之前几年做的 3d 引擎对这个需求来说太复杂了,而且这次我也不打算主打移动平台,之前为移动平台做的考虑也没太大意义。所以,最近想花个把月重新搭一个 2d 游戏用的框架。当然,最重要的是:我太久没写代码了,而做这个非常有趣。

前天在 github 上开了一个新坑,具体想法写在项目的讨论区了

虽说 2d 游戏在如今的硬件上,性能那是相当富裕。但在具体写代码时,还是忍不住想想怎么设计,性能会比较好。不然总是重复大家都有的东西也是无趣。

在现代 GPU 上实现一个最简单的 2d 管线,就是把它当成 3d 网格,一堆顶点数据填进去,绑定贴图,提交渲染即可。所谓 2d 图片,就是两个三角形,看成是 3d 世界里的一个面片即可。

所以,每个顶点的数据就是 vec2 pos ,要画一个矩形需要四个顶点,用 vertex buffer 传进去。

但是和 3d 游戏不同,2d 图片形状大多不规整,不是边长为 2 的幂的正方形,尺寸也不大。如果每张小图片(2d 游戏中通常称为 sprite)都构造一张贴图的话,会非常低效。通常我们会把很多 sprite 打包在同一张大的正方形的贴图上。这样,顶点数据中还需要定义绘制矩形对应在贴图上的区域,通常称之为 uv 坐标。至此,常规的实现方法中,每个顶点就是 4 个数据量:vec2 pos 和 vec2 uv 。因为 sprite 都在同一张贴图上,一次图形指令提交只画一个矩形就太浪费了,我们会把多个矩形的顶点放在一起,一次把整个顶点数组提交到 vertex buffer 中。

虽然 2d 游戏的大多数 sprite 只需要指定屏幕(画布)坐标渲染即可,画布可以整体缩放。sprite 单独缩放旋转的机会比较少,但也并非没有。用上面的方法怎么处理旋转和缩放呢?过去常见的方法是在 CPU 中计算好四个顶点,把结果填在顶点数据流中。btw, 很早以前,我在实现 ejoy2d 的初版时就是这么做的。这样最为灵活,CPU 计算一个 2x3 的矩阵也不慢(ejoy2d 使用了定点数,在早期的手机上性能更好)。而且,大多数 sprite 并不需要旋转和缩放,只需要做一次 vec2 的加法即可。

计算该怎么做?我们需要找到 sprite 的基准点。大多数情况下,这个基准点并不是图片的左上角。然后以这个点为坐标原点,对 sprite 的四个顶点依次做旋转和缩放变换再加上 sprite 的绘制位置。这一系列运算相当于乘一个 2x3 的矩阵。如果我们想把这个运算放在 GPU 该怎么做?顶点数据流中就不能直接放顶点计算结果的坐标了,而应该放针对 sprite 的基准点的相对坐标,以及一个 2x3 的变换矩阵。这样,顶点数据就变成了:vec2 offset ; vec2 uv ; mat2 sr; vec2 t; 一共是 10 个数据。

很明显,后面这个 mat2 sr; vec2 t; 在数据流中重复了 4 次(一个 sprite 的 4 个顶点有相同的 2x3 矩阵)。另一方面,绝大多数的 sprite 不需要旋转和缩放变换,这种情况下,mat2 sr 都是单位矩阵;即使有旋转变换,旋转角度也是有限的。整个数据流中必然存在大量重复的 mat2 sr 。怎么优化掉这些重复数据呢?我们可以用一个 storage buffer 保存唯一的 mat2 sr ,在顶点流中保存一个索引 index 即可。这样,顶点数据就剩下 vec2 offset; vec2 uv; index; vec2 t; 7 个数据。最后这个 vec2 t 不放在索引中是因为大多数 sprite 会有不同的位移坐标,而 2x2 的 SR 矩阵更容易合并。

接下来的问题是,index 和 vec2 t 还是重复了 4 次。为了去掉这个重复,我们可以采用 instance draw 或 indirect draw 。理论上用 indirect draw 更合适,但它对图形 api 版本要求高一些(如果想运行在 web 上,还是需要考虑这点),所以我选择用 instance draw 实现。

使用 instance draw 的一个额外好处是可以省掉 index buffer ,使用三角条带描述矩形即可。

但 instance draw 有个问题:它最初是为了把一组顶点数据重复渲染设计的。而这里,我们有很多不同的矩形需要同一批次渲染。即,vb 中每组数据 vec2 offset; vec2 uv; 有很多组。所以,我选择不使用顶点数据流,把这组数据放在另一格 storage buffer 中,然后在顶点着色器(vs)中通过 gl_InstanceIndexgl_VertexIndex 索引它。

做到这里,我注意到:2d 游戏中的 sprite 矩形都是轴对齐的。所以,描述四个顶点并不需要 8 个量,而只需要 4 个,保存两个对角顶点即可。另外,offset 矩形和贴图上的 uv 矩形形状也是一致的,我们只是把贴图上的一个区域完整映射到画布上,这样还可以少两个重复信息。最终,我们只需要 3 对 vec2 就可以表达一个矩形以及 uv 。

而图片是以像素为单位的,贴图尺寸不会有几万像素大。这个坐标量使用 int16 足够了。所以在保存 sprite 元信息的这个 storage buffer 中,每个图元其实只需要 6 个 int16 ,也就是 12 字节足够了。最终,绘制每个 sprite 的数据为 6 short + 3 float ( x,y,index ) = 26 字节。

最终的 vs 是这样的

layout(binding=0) uniform vs_params {
    vec2 texsize;
    vec2 framesize;
};

struct sr_mat {
    mat2 m;
};

layout(binding=0) readonly buffer sr_lut {
    sr_mat sr[];
};

struct sprite {
    uint offset;
    uint u;
    uint v;
};

layout(binding=1) readonly buffer sprite_buffer {
    sprite spr[];
};

in vec3 position;

out vec2 uv;

void main() {
    sprite s = spr[gl_InstanceIndex]; 
    ivec2 u2 = ivec2(s.u >> 16 , s.u & 0xffff);
    ivec2 v2 = ivec2(s.v >> 16 , s.v & 0xffff);
    ivec2 off = ivec2(s.offset >> 16 , s.offset & 0xffff) - 0x8000;
    uv = vec2(u2[gl_VertexIndex % 2] , v2[gl_VertexIndex >> 1]);
    vec2 pos = uv - ( off + ivec2(u2[0], v2[0]));
    pos = (pos * sr[int(position.z)].m + position.xy) * framesize;
    gl_Position = vec4(pos.x - 1.0f, pos.y + 1.0f, 0, 1);
    uv = uv * texsize;
}

再来看 CPU 侧的设计:

我这次使用了 sokol 做底层图形 api 。sokol api 不支持多线程,所有图形指令必须在同一个线程提交。所以我做了一个简单的中间层:绘图时不直接调用图形 api ,而是填充一个内存结构。这个结构被称为 batch ,不同的线程可以持有多个不同的 batch 。所有 batch 汇总到渲染线程后,渲染线程再将 batch 中的数据转换为图形指令以及所需的数据结构。

因为 2d 游戏据大多数情况都在处理图片,使用默认的渲染方式。我对这种默认材质做了特别优化。batch 是由这样的结构数组构成:

struct draw_primitive {
    int32_t x;  // sign bit + 23 bits + 8 bits   fix number
    int32_t y;
    uint32_t sr;    // 20 bits scale + 12 bits rot
    int32_t sprite; // negative : material id
};

其中,用两个顶点 32bit 整数表示 sprite 的画布坐标;一个 32bit 整数表示旋转和缩放量;一个 sprite id 。

渲染层会查表把 sprite id 翻译成对应的元信息(上面提到的 offset 和 uv ),当 sprite id 为负数时,表示这是一个非默认材质,batch 中的下一组数据是该材质的参数。例如,文本渲染就会用到额外材质,文本的 unicode 和颜色信息就放在接下来的数据中。

February 16, 2025

卡牌构筑类桌游核心规则之四

这篇谈谈 PvE (玩家对抗环境)向的卡牌构筑类游戏。

在桌游中,PvP 向(玩家对抗玩家)的游戏数量明显超过 PvE。我认为这是因为桌游需要靠玩家自己驱动游戏规则,扮演环境的同样是玩家,其规则不能设计的太复杂,通常只能靠简单机械的逻辑驱动。或者,起源于桌游的 RPG ,比如 D&D ,则由一个玩家扮演环境(城主),这样就可以增加游戏的深度。但毕竟这种不对称规则下,玩家方和环境方很难调配平衡。RPG 这样的游戏,城主也并非玩家的对立方,大家只是在一起享受游戏过程。而在电脑上,则可以通过程序实现更复杂的环境。所以在电脑游戏中,大量的游戏转向 PvE 。毕竟,找到可以一起玩的游戏搭档并不容易。

所以,对于卡牌构筑这个具体类别,电脑上的游戏几乎一开始就是 PvE 性质的:比如杀戮尖塔,玩家一直在挑战系统而获得乐趣;而桌游中,从领土(Dominion)开始,就是基于玩家对抗设计的规则。

传奇 Legendary 系列是一个比较早的 PvE 向卡牌构筑类桌游系列。最早可以追述到 Legendary: A Marvel Deck Building Game (2012) ,后续几年发布了大量系列作品,并衍生出 Legendary Encounters 系列。关注 Legendary 系列是因为星际孤儿(Stellar Orphans)这个电脑游戏,我特别喜欢。在星际孤儿的玩家社区,有玩家指出这个游戏明显受到了 Legendary 系列桌游的启发。

传奇系列的每个作品都围绕一个题材展开,以最初的漫威系列为例,下面我介绍一下它的核心玩法规则。


游戏是由玩家对抗系统。每个玩家以开始会拿到一叠基础的英雄卡(12 张),系统则由一叠系统卡设定。

每个回合,从系统卡堆中抽出一张卡片出来推动游戏发展。如果从系统卡堆中抽到坏人卡,则表示这张坏人卡会入侵城市(摆放在桌面区);如果抽到旁观者,则绑定在坏人卡上,变成坏人的俘虏;如果抽到事件卡,则引发特殊事件。

而在系统卡结算完毕后,玩家从手牌中打出卡片组合,产生本回合的攻击点、招募点以及特殊能力。攻击点可以用来消灭城市中的坏人卡(并解决俘虏);招募点则从桌面的 HQ 列(从英雄卡堆中翻出)购买新的英雄卡增强自己的卡组。而传统的卡牌构筑类游戏规则一致,每个回合的卡片都必须全部用完,不可保留到下个回合,溢出的攻击点和招募点会作废。购买的新卡片也会先置入弃牌堆。每个回合,玩家从自己的卡组中抽取新的手牌(6 张);一旦卡组抽完,洗混弃牌堆,形成新的抽牌堆。

初始卡组由 8 张基础的招募点卡(每张 1 点)以及 4 张基础的攻击卡(每张一点)构成;英雄卡堆(形成市场)则由玩家选择的几个英雄对应的卡组混在一起。每个英雄卡组有 14 张,其中两组各 5 张相同的普通卡,三张强力卡,以及一张稀有卡。市场上永远展示其中的五张,当玩家购买后会补齐。一旦英雄卡堆用玩,游戏结束。另外,市场上永远有固定的高级招募卡(每张 2 点)可供选购。它类似于 Dominion 中的银币。玩家每个回合还可以花 2 点招募点买到一张 sidekick ,该卡只能使用一次(用完回到市场),效果是抽两张卡。

每局游戏,系统存在一个终极 boss ,在 setup 阶段需要随机选择 boss 对应的一张 scheme 卡。scheme 卡上描述了系统胜利的方法。当系统达成条件,玩家就会输掉。

攻击 boss 他需要大量攻击点,每次成功的攻击会结算一次随机的 Tactics 卡(例如,有些 Tactics 卡会增加下一次攻击 boss 的难度)。每个 boss 对应 4 张 Tactics 卡,四次成功攻击后,玩家就赢得游戏。

系统每个回合翻出的坏人卡会以队列形式在桌面推进。桌面一共有 5 个位置(地点),坏人卡从最右侧进场,在进场时需要结算坏人卡上的 Ambush 效果(若有)。如果玩家一直对翻出的坏人卡置之不理的话,坏人卡会一步步向左推进,直到离开桌面。每离开一张坏人卡,都会摧毁掉市场上的一张卡片(由玩家自己选择);如果坏人带着俘虏离开,则还要弃掉一张手牌。如果逃离的坏人卡上标注有 escape 效果,还需要额外结算。

系统卡堆中存在一些地点卡,翻出后在场上并列一排从右至左一张张排列直到排满。摆满后,新的地点卡会替换掉场上最弱的那张。地点卡会改变存在这个地方的坏人卡的能力。地点卡本身可以作为攻击目标。

系统卡堆还包含一些特殊卡(坏人卡和旁观卡之外),它们不推进坏人卡。其中:

Trap 卡给玩家一个需立刻结算的挑战。

Twist 卡会推进当前 Scheme 卡上系统胜利的某种进度。

Strick 卡会触发 boss 的攻击。

玩家的手牌没有打出费用(类似杀戮尖塔的行动点限制),但受招募费用的限制,强力卡在一开始无法从市场购得。玩家倾向于在每个回合打出所有手牌(不打出的卡也无法带到下个回合)。基本卡片只是一个招募点和攻击点,把这些点数累加起来就是当前回合可以用于购买新卡以及消灭坏人的费用。但强力卡片的打牌次序是有选择的。因为卡片会有专门的特殊能力,比如,有的卡片需要弃掉别的手牌才能使用;有的需要前置打出某些类型的卡,就有额外的能力加成(形成 combo)。

此外,击败的坏人卡、拯救的俘虏(旁观者)、Tactics 卡都附带有 VP 。当以多人协作形式进行游戏时,每个玩家单独计算 VP ,在游戏结束后,可以比较在游戏过程中获得的 VP 总数来觉得谁表现得更好(但玩家在游戏过程中依旧是合作关系,而不应该为 VP 竞争。


在 Legendary 系列之后,同一家公司推出了新的 Legndary Encounters 系列。这是换了新设计师后在 Legendary 规则上的进一步发展。系列的第一作是 Legendary Encounters: An Alien Deck Building Game (2014) 。给我的感受是,Legendary Encounters 更加注重叙事(而不仅仅是围绕一个主题),通过卡片和游戏规则的设计,玩家可以更好的融入游戏故事中。不同的题材会给玩家不同的感受,Legendary Encounters 在今年(2025 年)还会有新作推出,最新的故事看起来会在冰与火之歌的世界中展开。

我在桌游模拟器中尝试了一下最初的异形(Alien)三部曲。和异形电影体验非常接近,代入感很强。和 Legendary 漫威不同的地方是:

敌人(坏人卡)是背面朝上在桌面(场景)中推进的。玩家需要主动 scan 。这和星际孤儿的设定非常相像(应该是它启发了星际孤儿)。玩家对敌人的推进置之不理的话,敌人牌移到头会触发攻击,而受到太多攻击后,玩家会死亡而输掉游戏。

多人协作模式下,不同的玩家会扮演不同的角色。不同角色的能力是不同的。另外,玩家在游戏过程中有可能因为不敌敌人而被感染,当玩家以这种形式被杀死后,会重新以异形的立场加入游戏,变成对抗其它玩家。

每局游戏会有固定的几个阶段,每个阶段有固定的目标。这些阶段性目标被设计成和电影情节一致。玩家需要完成每个阶段的目标推进游戏,直到所有阶段达成获得游戏胜利。btw, 星际孤儿也采用了这种形式,并进一步加长了游戏故事。

February 03, 2025

卡牌构筑类桌游核心规则之三

这一篇,我想先谈一个在中文社区比较小众(没有出中文版),但我个人非常喜欢的卡牌构筑游戏:核心世界 Core Worlds (2011) 。

相对 Dominion 来说,它的规则和体验已经非常不同了。

它的单位牌需要先部署到桌面(战区),而后在从桌面打出,用于征服星球(用于增强能力)。这可以促使玩家做更多跨回合的规划。而不是仅考虑当前回合的手牌怎样打出漂亮的 Combo 。每个回合,玩家可以在回合结束时保留一张手牌(同时也会减少抽牌数量),这点也是为了促进玩家更多的考虑回合间的联系。

游戏采用固定轮数,一共 10 轮 5 个阶段。每个阶段的市场牌堆都是独立的。所以,玩家会在每两轮看到不同的牌进入市场,这样会减少卡组构建的随机性,让强牌逐步出现。游戏节奏被设计的更好。 每个轮次翻到市场的卡很少,只玩一两盘恐怕大多数卡片都不会见到,这加强了重玩性。每局游戏都有不同的变化。虽然游戏有 10 轮,但游戏节奏其实是很快的。尤其是到了终局的第五阶段,节奏被刻意加快了:前 8 轮积累的大量能量,而手牌也增加了。没有新的单位卡,而换成了得分用的声望卡以及核心世界卡需要竞争。终局两轮实际是考验的是玩家前面的组卡和布局成果。

而在前面的回合,玩家每轮可以做的事情并不算太多。市场上的卡片分为星球卡和单位/行动卡。星球卡需要玩家用之前部署的部队去征服,被征服的星球卡直接放到玩家桌面提供永久能力(通常会增加能量点);而单位/行动卡则需要能量点购买,和传统的卡牌构筑规则相同,新购入的卡片需要先进入弃牌堆。市场上的卡片张数是受玩家人数限制的,每轮固定添加新卡片上场。每轮新增加的卡片如果没人搭理,则会增加一点能量奖励下一轮选它的玩家。而两轮不选的卡片则自动弃掉。这样,市场上永远都只有有限几张卡片(根据玩家人数不同而不同),玩家不会陷入选择焦虑。

在战斗征服部分,玩家单位被分为地面战和空中战两种能力。针对不同的星球,需求不同。但这些能力有可以被战术(行动)卡所改变。因为用于征服星球的单位需要花一轮部署在桌面(战区),所以,玩家可以通过观察对手部署的部队,提前了解对手的意图。实际玩的时候,先做什么再做什么,会随着对手的行动而不断变化。加上市场资源(无论是星球还是行动卡)都极为有限,游戏的对抗性就变得很强。

在实际玩游戏的时候,除了自己行动需要决策外,观察对手行动在做什么也相当有意义。这让轮转行动时等待对手行动的时间也不会枯燥。


游戏每轮分为 4 个阶段:抽牌、补充能量、补充市场、行动、结束。前三阶段玩家的选择非常有限,所以进度会很快。游戏的核心时间会花在行动阶段上。

在行动阶段,玩家受行动点和能量的双重限制。每类行动都会花掉一个行动点,玩家以行动为单位轮转。而不同行动的能量点开销各不相同,玩家可以做的是:

  1. 根据市场上的单位/行动卡标注的能量费用购买一张新卡(置入弃牌堆,无法立刻使用)。

  2. 把手上的单位卡部署在桌面战区(支付对应的能量点),只要能量够,一次可以部署多张单位卡。

  3. 使用已经部署好的单位卡去征服一个星球。同时可以利用手牌中的战术行动卡增强能力。

  4. 有部分战术卡可以单独作为一个行动使用。

玩家可以在行动阶段 pass ,能量点不会保留到下个回合,且需要弃掉手牌。和同类游戏相比,核心世界在弃掉手牌时可以保留最多一张卡片。但由于回合开始抽牌阶段的规则时补充手牌到规则上限,所以,保留手牌会减缓卡组轮替。

在最后的结束阶段(所有玩家 pass 行动),不需要玩家做行动。只是清理桌面:玩家在本回合获得的额外能量点(通常是因为选择了上一轮每人要的卡片)会累加在能量条上供下一轮使用。而两轮都没人过问的卡片则被移除游戏。

在 10 轮之后,玩家统计各自的 VP 决定谁最终胜利。大部分的星球会有 VP ,而在最后一个阶段的两轮,市场上会出现大量的 VP 卡。声望卡只需要能量就可以购买,它需要玩家在前面累积大量的能量产能;核心星球则提供多样化的 VP (和玩家卡组里的其它卡片组合算分),核心星球通常需要玩家在前 8 轮部署足够的战力才能拿下。


这个游戏提供了有限的卡组瘦身机制。我们知道,一般的卡牌构筑游戏,卡组越精简,卡组循环就越快,卡组实力便越强。把单位卡部署在桌面是一种临时缩减卡组的办法;而征服星球则可以从征服部队中选一张单位卡殖民到该星球上,这可以让玩家从卡组中永久去掉一张卡不参与循环(但依旧参与计分)。

另外,在熟悉游戏后,初始会有两张不同的单位卡(一张地面战力,一张空中战力)采用轮抽机制替换。这也增加了多局游戏的多样性。

核心世界还有两个扩展,可惜我没买到。这里就无法评说了。


接下来谈的一款游戏在中文社区就比较大众化了:星域奇航 Star Realms (2014) 。

这是一款快节奏的双人对战卡牌构筑游戏。我有一套正版的基础版,它出了很多扩展,但现在都很难买到正版了。所以我只好买了大盒装的盗版体验。

星域奇航上手非常简单。就是抽牌,从市场购买卡片、打牌攻击对手。和 Dominion 不同,它并不靠积累 VP 获胜,而是攻击打掉对手的血获胜。每个人初始有 50 点血(官方称为权威点),在初始手牌中,便有一点攻击力的卡片,抽到即可攻击。初始手牌中大部分卡片是钱币卡。10 张卡片组成的标准起始卡组为 8 (钱币) + 2 (攻击) ,这和 Dominion 的初始卡组非常类似。

市场区和 Ascension 类似,采取买一张补一张的轮换制。但市场区没有怪物卡,全部是单位卡。因为游戏的战斗全部是攻击对手,不靠打怪得分。btw, 星域奇航的作者本身就设计了 Ascension 的很多扩展,所以就不难理解这个游戏和 Ascension 的很多相似之处了。

单位卡除了可以在当前回合发动能力的飞船(提供钱币/攻击/补血等)之外,还有一种要塞卡,可以在打出后永久停留在桌面。要塞卡通常会提供一些持久能力,也可以用于阻挡对手的攻击。星域奇航创造的一条独特规则:部分卡片可以在打出后将自己销毁,提供额外的一次性能力。这种销毁发动一次性能力的卡片也包括要塞卡。所以玩家在打牌时就多了一种选择:要不要触发销毁能力。

(基础)游戏中有四种势力,分属为四种卡牌。通常单一势力的卡组更容易建立起强大的 combo 。组卡方面留下的策略空间不错,堆整个卡池越熟悉,就越容易组出强大的卡组赢得游戏。

总的来说,我还是很喜欢这个游戏的。因为一局时间比较短,规则又简单,可以经常在家里和孩子一起玩。这里再次强调游戏节奏很快,是因为它相比那些卡牌构筑前辈,可以用更短的回合数构筑出攻击引擎,精简卡组也相对容易,往往一个有趣的组合就可以打爆对手。我们玩一局的时间很少超过半个小时。

在加了扩展后,游戏还提供协作模式:由系统制造出一个强大的 boss ,两个人不再相互攻击,而是要协力击败系统。云豆更喜欢协作模式一些,只是把系统设定的几个 boss 都击败后,重玩不够有趣。


在写了这么多对抗类的卡牌构筑游戏后,下一次我想改谈协作类型了。由玩家对抗系统,或许更接近我自己要做的电脑游戏一些。