最新网址:www.00shu.la
5月3日,立夏前三天,洛阳的气温毫无预兆地窜到三十度。宿舍的电扇坏了,叶片有气无力地转着,搅动一室闷热。李君宪盯着屏幕上那个诡异的Bug,额角的汗滑到下巴,滴在键盘的空格键上。
Bug描述很简单:当玩家在“无事可做”状态下静止超过两分钟,然后移动,时间系统理应恢复正常流速。但测试时,有四分之一的概率,世界时间会卡在某个随机倍率——可能是0.5倍慢放,也可能是10倍快进,再也回不到1.0。
更诡异的是,这个Bug无法稳定复现。李君宪测试了二十次,只出现了三次。陈末在北京测试了三十次,出现了八次。林薇用自己的电脑测试十次,一次都没出现。叶晚测试五次,出现了两次。苏语没装开发环境,没法测。
“像是时间系统的状态机在某个边缘情况下死锁了。”陈末在语音会议里说,背景是清脆的键盘声,“我打了日志,发现Bug出现时,world.timeScale的值会被写入一个非法的浮点数,有时候是NaN(非数字),有时候是Inf(无穷大)。但不知道触发条件。”
“和渲染线程的同步有关吗?”李君宪问。他的代码里,时间系统和渲染更新在两个不同的线程里跑,靠锁同步。这是为了性能,但也埋下了隐患。
“有可能。我加了更细粒度的日志,今晚跑通宵测试,看能不能抓到现场。”陈末顿了一下,“但即便找到原因,修复也可能需要重构时间系统。距离5月10日的节点只剩七天了。”
压力像一层透明的膜,贴在皮肤上。宿舍里更热了,李君宪能闻到机箱散热口喷出的焦糊味——那台三千块攒的老爷机,在连续四十八小时高负载后,终于开始抗议。
“先不管这个Bug。”林薇的声音进来,背景是画笔在纸上的沙沙声,“遮罩图的Alpha通道我做好了,但导入工程后,窗框边缘的渐变在有些机器上会出现锯齿。叶晚,你那边显示正常吗?”
“我……我这里正常。”叶晚的声音有些犹豫,“但我电脑配置低,可能看不出来。林薇姐,你把图发我,我用我的电脑再试试。”
“好。另外,磨损素材的随机组合系统,我写了简单的测试程序。”林薇继续说,“但发现一个问题:如果每次开局场景的磨损程度都随机,会破坏‘积累感’。玩家今天擦干净的桌子,明天开局又脏了,就没有‘经营’的实感了。我建议改成:磨损程度在第一次开局时随机生成,之后存档,每次读档沿用同一套磨损。这样,这个世界会‘老’下去。”
“同意。”李君宪记录,“但存档系统还没做,这是个远期目标。现阶段,就随机吧,增加重玩价值。”
“苏语那边呢?”他问。
“门轴声的第二个版本我优化过了,去掉了空白段落的杂音。”苏语的声音很轻,背景有细微的电流声,像是在用不太好的麦克风,“但更大的问题是,环境音的分层。我做了三轨:远处市声、中景风声、近处室内音。在‘无事可做’状态下,市声和风声应该加速,室内音应该冻结。但我用测试程序跑,加速后的声音会变调,像磁带快进,很假。我需要知道时间加速的具体倍率,好做相应的音频处理。”
“目前是5倍。”李君宪说,“但Bug出现时,可能是任意值。你能处理动态倍率吗?”
“可以,但需要实时重采样。我的笔记本性能不够,会卡顿。除非……”苏语犹豫了一下,“除非在加载时预生成几个常用倍率(1x、2x、5x、10x)的音频版本,运行时切换。但这样内存占用会翻几倍。”
“陈末,音频内存预算还有多少?”李君宪问。
“我看看……目前音效占12MB,环境音占8MB,总共20MB。如果预生成四个倍率,环境音部分会到32MB,总占用44MB,超了我们设的40MB红线。”陈末回答得很快,“而且这只是‘冲淡’,如果以后做‘纤秾’,牡丹花开的声音、花瓣飘落的声音,内存会更吃紧。”
又是妥协。开发就是不断妥协的过程,在理想和现实之间,在艺术和技术之间,在“想做”和“能做”之间。
“先做2倍和5倍两个预生成版本。”李君宪做出决定,“10倍加速很少触发,暂时不管。苏语,这样可以吗?”
“可以。我今晚就做。”苏语顿了顿,“另外……我买了那个话筒。”
群里安静了一瞬。
“古琴爱好者捐的那两百块?”林薇问。
“嗯。二手的,但比学校琴房的好。我试录了一段,发给你们听听。”苏语发来一个音频文件。
李君宪点开。是古琴的泛音,几个清冷的单音,在空气里振动,尾音很长,长到几乎消失时才接下一个音。录音质量明显好了,能听到手指离开琴弦时细微的摩擦声,能听到琴弦本身的金属余韵。最后一个音结束后,有两秒绝对的安静,然后,一声极轻的、几乎听不见的叹息——不知道是苏语的呼吸,还是话筒的底噪。
“这是‘冲淡’主题旋律的动机。”苏语说,“只有五个音。我想用这五个音,变奏出整个游戏的音乐。煮汤时,慢速变奏。客人进门时,加一个装饰音。下雨时,用泛音模拟雨滴。打烊时,拉长,淡出。”
“很好。”李君宪说,“就用这个方向。但注意内存,别做太复杂的变奏。”
“明白。”
会议结束。李君宪看着记满三页的待办事项,感觉太阳穴在跳。时间、内存、性能、兼容性、Bug……每个问题都像一根绳子,慢慢绞紧。而他们手里只有一把生锈的剪刀。
他站起来,走到水房,用凉水冲了把脸。镜子里的人,眼睛里有血丝,下巴冒出胡茬,T恤领口有汗渍。二十一岁的外表,三十岁的疲惫。
回到座位,他打开邮箱。有一封新邮件,来自“IGF China组委会”,标题是“关于作品提交流程的补充说明”。
他心里一紧,点开。
邮件很长,主要是技术规范:可执行文件不能超过50MB,必须能在Windows XP SP2上独立运行,不能依赖任何第三方库除非自带,必须提供卸载程序,等等。最后一段用加粗字体写着:
“特别注意:学生组作品,必须由在校学生完成。团队中如有已毕业人士参与,需提供详细分工说明,并确保核心创意和主要工作量由在校学生完成。组委会保留审核资格的权利。”
他反复读了三遍。核心成员里,陈末大四,即将毕业,但还算在校生。叶晚大三,林薇大三,苏语大三,他自己大三。没问题。
但“主要工作量由在校学生完成”——如果组委会认为陈末的渲染框架工作量太大,算不算“主要”?如果叶晚的母亲帮忙绣了某个纹理(虽然不太可能),算不算“非学生参与”?这些模糊地带,都可能成为被拒的理由。
他把邮件转发到群里,附言:“大家看看最后一段。注意规避风险。陈末,你的渲染框架,能提供详细的代码注释,证明是你独立完成的吗?”
陈末几分钟后回复:“能。我写代码习惯好,每个模块都有文档。另外,我可以提供学生证扫描件和在读证明。”
“好。大家也都准备好学生证明,以防万一。”李君宪敲下这行字,忽然觉得有点荒谬。他们还没做出像样的Demo,就开始担心参赛资格的问题了。
但这就是现实。理想需要现实铺路,哪怕这条路布满碎石。
他关掉邮箱,继续对付那个时间Bug。加了更多日志,在可能出问题的锁同步处埋了十几个断点,重新编译,运行测试程序。
这一次,Bug在第三次测试时就出现了。世界卡在0.3倍慢放,李师傅的动作像在水里走路,一帧一帧地挪。日志文件滚屏,他一行行看,眼睛发酸。
忽然,他注意到一行奇怪的日志:
[TimeSystem] Thread conflict detected at timestamp 120.5s.
[RenderThread] Acquired lock at 120.5001s.
[TimeThread] Acquired lock at 120.5001s.
时间戳完全一样。两个线程,在同一毫秒内,获取了同一把锁。理论上不可能,除非系统时钟精度不够,或者锁的实现有漏洞。
他查代码。用的是标准的CRITICAL_SECTION锁,Windows自带的,不应该有问题。除非……他想到一个可能性:在“无事可做”状态下,时间系统会分裂成两条时间轴,每条时间轴都有自己的锁。当玩家退出静止状态,两条时间轴要合并时,需要同时获取两把锁。如果获取顺序不对,可能死锁。
他翻到合并逻辑的代码。果然,写成了:
lock(timeLock_室内);
lock(timeLock_窗外);
// 合并逻辑
unlock(timeLock_窗外);
unlock(timeLock_室内);
而另一个地方,渲染线程更新窗外光影时,顺序是:
lock(timeLock_窗外);
lock(timeLock_室内);
// 更新逻辑
unlock(timeLock_室内);
unlock(timeLock_窗外);
经典的死锁条件:线程A锁了1,等2;线程B锁了2,等1。平时很难触发,因为两个线程很少同时卡在这个点上。但在“无事可做”状态下,时间系统频繁分裂合并,渲染线程又要频繁更新窗外光影,撞上的概率就大了。
他修改代码,强制统一锁的获取顺序:永远先锁室内,再锁窗外。重新编译,运行测试程序。
跑完十次,没出现Bug。二十次,没出现。五十次,还是没出现。
他长舒一口气,把修复方案提交到SVN,在群里@陈末:“时间Bug可能解决了,是锁顺序的问题。你那边跑一下压力测试看看。”
陈末半小时后回复:“跑了二百次,零复现。应该是修了。但合并逻辑我优化了一下,减少了锁的持有时间,性能提升15%。新代码提交了。”
李君宪看着那条消息,忽然笑了。这就是团队的感觉:你解决一个问题,队友把它变得更好。像接力赛,一棒传一棒,朝着同一个终点。
窗外的天黑了。宿舍楼响起喧闹声,晚课的学生回来了。王浩推门进来,拎着两份炒面:“李哥,给你带了饭。别饿死了。”
“谢了。”李君宪接过,塑料饭盒还烫着。他掰开一次性筷子,扒了两口,油重盐也重,但很香。他边吃边看群,林薇发了遮罩图的最终版,叶晚回复说锯齿问题在她电脑上也不见了,苏语说预生成的音频做好了,内存占用28MB,没超预算。
一切都在向前走。虽然慢,虽然难,但在走。
吃完饭,他打开博客。好几天没更新了,后台有读者留言催更:“博主还活着吗?”“是不是放弃了?”“募捐了八千多,可别跑路啊。”
他新建文章,标题:
“5月3日:Bug,锁,以及一碗炒面”
他写道:
“还活着。没放弃。在修Bug。
“今天遇到一个诡异的Bug:时间系统会随机卡在奇怪的倍率。查了半天,发现是锁顺序的问题。两个线程,两把锁,获取顺序不一致,在极端情况下会死锁。改了就修了。
“开发就是这样,99%的时间在对付这些看不见的敌人:一个像素的锯齿,一声音频的变调,一行代码的死锁。它们很小,但能让你卡几天。你必须很有耐心,像在黑暗里摸钥匙,一把一把试,直到听见‘咔嗒’一声。
“但也有好消息。
“叶晚的磨损素材系统通过了测试,每次开局小店都会有些微不同,像真的被岁月打磨过。林薇的遮罩图解决了边缘锯齿,现在窗里窗外的光影过渡自然得像呼吸。苏语用新话筒录了古琴动机,五个音,却能变奏出整个世界。陈末优化了时间系统的性能,提升了15%。
“而我,在修完Bug后,吃了室友带的炒面。油很大,但很香。
“你看,开发不只是痛苦。也有炒面,有五个音的古琴,有像素的裂纹,有性能提升的百分比。这些细小的、具体的东西,像散落的珠子,我们一个个捡起来,串成一条叫‘进度’的链子。
“距离5月10日的节点,还有七天。
“距离IGF截稿,还有二十八天。
“链子还差很多珠子,但我们在捡。
“慢慢捡。
“夜深了。该去测试新的版本了。
“祝各位晚安。
“——李君宪,于炒面味的宿舍。电扇还在转,虽然没什么风。”
点击发布。
他关掉博客,运行集成后的新版本。游戏启动,李师傅站在店里。他让小人静止。窗外的光影开始加速流动,午后的阳光在墙上飞速滑过,像快进的电影。室内,灶台的火光凝滞,灰尘停在半空。他戴上耳机,苏语的环境音流进来:远处加速的市声像模糊的河流,近处冻结的室内音只有自己呼吸的底噪。
然后他移动。时间合并,世界恢复正常。窗外的影子恢复慵懒的移动,室内火光重新跳动。门轴发出悠长的“吱……嘎……”声,一个像素小人推门进来,头上冒出对话气泡:“一碗胡辣汤。”
他走到灶台,按空格。进度条开始走,五秒,完成。他端起看不见的汤,放到客人面前。客人头上冒出笑脸,留下一个铜钱像素,离开。
左上角的收入,从0变成5。
整个流程,三分十七秒。什么都没有发生,但又好像发生了什么。
他截了一张图:李师傅站在灶台前,窗外是黄昏的光,室内是凝滞的暖。然后他打开画图工具,在图片右下角,用像素字体写了一行小字:
“拾芥工作室《洛阳小店》v0.3 | 距离IGF还有28天”
他把图发到群里。
林薇第一个回复:“这个画面……有点意思了。”
叶晚回了一个笑脸。
苏语回:“音画同步还需要微调,但感觉对了。”
陈末回:“帧数61.3,稳定。内存占用31.2MB,达标。”
李君宪看着那张图,看了很久。然后他最小化所有窗口,打开一个空白的记事本。
他在第一行写下:
“第二品:纤秾。待启动。”
下面,他开始列大纲:
• 核心玩法:牡丹培育,实时生长系统
• 技术难点:粒子系统(花瓣),生长算法,光影变化
• 美术需求:牡丹生长各阶段像素图,庭院场景,天气系统
• 音乐需求:主题旋律变奏,花开音效,采摘音效
• 目标:在“冲淡”投稿后,立即启动预研,六月出可玩原型
他写得很快,像在追赶什么。窗外的夜很深了,远处传来火车经过的汽笛声,悠长,孤独,向着不可知的远方。
而在这个闷热的宿舍里,一个年轻人正在为一朵尚未存在的像素牡丹,写下最初的生长规则。
世界很大,但有些东西,可以从一个像素开始。
最新网址:www.00shu.la