节奏游戏开发指南 #3:我的内心充满波动

作者:indienova
2016-09-28
2 12 2

引言

译自 Rhythm Doctor (《节奏医生》)开发者 Giacomo 的博客,中文版由开发者授权由 indienova 翻译整理,为介绍节奏类游戏的开发日志,本文为系列的第四篇。这篇日志系统介绍了作者利用 Shaders 来实现波形沿着直线运动效果的经验与心得(美术部分由 WInston Lee 制作完成)。

select_spanish

Rhythm Doctor 是一款深受 Rhythm Heaven 影响的单按键节奏游戏,游戏的试玩版本可以在官网下载试玩,目前开发者以在明年上半年正式发布为目标,正在全力以赴制作游戏的完全版本。

效果展示

我们在游戏中实现的效果如下:

游戏中波形的灵感其实来自 Arctic Monkey 的一部 MV 视频,“Do I Wanna Know?”,其中包含类似的运动形式,观看视频你也能发现,它不仅仅是单纯地让精灵平移的动画,还包含一些更加复杂的运动形式:

背景信息

目前位置,游戏的主要机制依然是,玩家需要在每个小节音乐的第七拍处精确地按下空格。每个节拍在视觉上都会呈现为节拍线上的一个脉冲:

这里的脉冲运动是简单地通过在每一个节拍处垂直方向上放大脉冲精灵图来实现的。

而另一方面,新游戏机制却是这样的:只会在最开始给予玩家提示,接着波形从左向右加速,玩家需要直接在对应位置按下空格。而之间时间段的节拍则全部由护士喊出来,类似 Rhythm Heaven 的风格(这篇开发日志里我不打算深入涉及游戏设计方面的问题)。

我们的游戏设计师兼程序兼作曲在 Matlab 中编写了波形函数,主要使用三角波和正弦函数波片段组成。

三角波如下呈现为锯齿形:

triangle_formula
triangle_grapher

而正弦函数波片段则截取正弦波的一部分,看起来犹如地平线上冒出一座山峰。参看公式,当x位于0和最大宽度之间时,波形函数为正弦函数,而x位于其他范围时,值为0。

truncSine_formula
truncSine_grapher

我们将两种波形相乘就能得到我们所需的新波形函数 rdWave(x),如下图所示:

rdWave_formula
rhythm_doctor

你可能留意到公式中 truncSine 函数的参数 tElapsed,这个变量用于控制波形的水平位置,这样我们就能让波形沿着x轴滑移,目前这个值应当和波形在屏幕上的滑动时间挂钩,但你也可以将它设成任何你希望的值。

如果对上面的内容没有什么理解偏差,我们接下来就来考虑如何在游戏中实现它们。

我们需要完成下面的工作:

  1. 制作一条沿着节奏线平滑移动的持续波形。
  2. 波形线为一个像素宽,这样看起来就和之前的波形不会有任何区别。
  3. 它必须要毫无维和地兼容之前加入的光晕、轮廓和闪光等特效,这些我们已经通过 shaders 实现过了。

我们第一时间确定了用 Shaders 来实现上述的波形效果,我个人觉得用它来制作图像特效体验非常之好,它能让我们精确到像素地控制视觉效果,这在一款像素游戏中非常重要。

此外,因为波形图都会被呈现在一个矩形范围一内,因此可以使用一张画布来绘制它,也很方便在同一个 shader 中能够加入各种其他特效(前面也提到过的光晕、轮廓和闪光等)。并且使它用起来和别的精灵图没什么区别,管理起来也方便了很多。

那么,我们来聊聊 shaders 的话题……

Fragment Shaders 快速科普

在继续讨论 Rhythm Doctor 的波形实现之前,我想有必要简单和诸位读者聊聊 shaders 相关的话题。通常来说,shaders 是运行在 GPU 上的一些小程序。几乎所有的现代游戏都在使用 shaders 来优化图像效果,因为通过它能够实现今天在多数游戏中能看到的光照,模糊等各种酷炫效果。

GPU 相比 CPU 在这些效果上更加擅长,因为它能够并行地运算相当多的进程。而游戏会包含大量的三角形和像素点,要求同时进行处理,恰好适合 GPU 的运算方式。

有各种各样的 Shaders 代码,各有各的功能,我们绘制波形要用到的是 fragment shader(有时也叫 pixel shader)。简单概括地说,fragment shader 是可以精确定义所绘制的每一个像素颜色的绘图函数。那具体怎么实现的呢?通过在每一个像素位置上运行这段 shaders 代码,并返回它们各自的颜色。

编写 shaders 代码有许多可用语言,但多数语法很类似,这里我们使用 GLSL,这种语言语法有点类似 C 语言,也被用于 OpenGL 和 WebGl,这里我们给出它的 shader 代码的示范:

void mainImage(out vec4 fragColor, in vec2 fragCoord)
{
	fragColor = vec4(1.0, 0.0, 0.0, 1.0);
}

上述代码在一个矩形范围内运行会出现什么结果呢?如下所示:

red

嗯,是的,一个红色矩形。我们的代码究竟是什么意思呢?代码的第一行,函数 mainImage 是这段代码的入口,也是它开始运行的地方,我们分别定义了 out vec4 fragColorvec2 fragCoord 两个参数。第一个参数 fragColor 用于以 RGBA 格式设置显示的像素颜色,我们使用 out 标记了这个参数,这意味着它会作为返回变量改变对应像素位置的颜色。而 fragCoord 参数用来指定当前处理的像素点的坐标范围。尖括号中的这行代码:

fragColor = vec4(1.0, 0.0, 0,0, 1.0);

将红色应用到了对应的像素位置上。由于我们没有做任何别的事情,因此最终呈现的效果就是,范围内所有的像素都变成了红色。

看起来上面列举的这段 shaders 不是很有用,除非我们只打算用来画大块的单色矩形。但是,如果结合 fragCoord 来用就能精确到位置地控制所绘制的像素了。顺便,推荐大家使用在线的 shaders 沙盒网站 Shadertoy(http://shadertoy.com/)(很不幸这个网站在国内访问是受限的),这个网站允许你在一个矩形范围内测试 fragment shaders 代码,这恰好是我们现在最需要的功能。

我们来写一段这样的代码:

void mainImage(out vec4 fragColor, in vec2 fragCoord)
{
    float y = fragCoord.y;
    
    if(y > 100.0)
		fragColor = vec4(0.0, 0.0, 0.0, 1.0);
    else
   		fragColor = vec4(1.0, 1.0, 1.0, 1.0);
}

效果如图:

red_black

这段代码将y坐标大于100的像素点绘制为黑色,而y坐标小于100的地方则绘制为红色(如果我们将原点设置在矩形范围的左下角的话)。能够精确知道每个像素点的位置,足以让我们制作我们自己的shader了,那现在开始绘制波形吧!

波形绘制

我们的波形基于数学函数,因此,我们需要先学会怎样在 shader 中绘制函数图像。如果你仔细看上一段示例代码,会看到 y > 100 这样的条件,实际上,这就完美呈现了不等式关系。我们也可以将它用在之后的代码中。这里我们来编写一个范例,下面的函数图像为 y > sin(x) + 100,效果如下所示:

red_black_triangle

实际上它生成了一段正弦波,虽然波幅很小。我在y方向上增加了100像素,这样图像位置可以稍微高一点,看得清楚一些,而非贴在画面最底部。采用类似方法我们也可以编写 triangle(x), truncSine(x)rdWave(x)并将它们绘制到屏幕上。我们分别编写三个 shader 函数来表示这三个数学函数:

float triangle(float x)
{
    // Triangle wave 三角波
    return abs(mod(x * 0.2, 2.0) - 1.0) - 0.5;
}

float truncSine(float x)
{
    // Half sine wave 半正弦波
    const float height = 40.0;
    const float sineWidth = 40.0;
    const float pi = 3.1415;
    
    if(x < 0.0 || x > sineWidth) 
        return 0.0;
    else
    	return sin(x * pi/sineWidth) * height;
}

float rdWave(float x, float t)
{
    return truncSine(x - t) * triangle(x);
}

基本上来说,上述代码就是在呈现文章开头定义的公式。唯一问题是,我们需要获取 rdWavet 参数,这样随着时间推移,我们才能够将波形从左到右移动至指定位置。好在 Shadertoy 提供了一些全局变量用来帮助我们实现类似功能。我们需要的变量是 iGobalTime,它能够告诉我们 shader 开始显示以后经过的时间。现在我们来实现 mainImage 函数:

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
    const float yOffset = 100.0;
    
    float x = floor(fragCoord.x);
    float y = floor(fragCoord.y) - yOffset;

    if(y < rdWave(x, iGlobalTime * 40.0))  
        fragColor = vec4(1.0, 0.0, 0.0, 1.0);
    else
        fragColor = vec4(0.0, 0.0, 0.0, 1.0);
}	

我们对之前的 shader 也需要随之做一些调整:

  1. 增加 yOffset 常量。用于修改y轴标量,方便我们快速地将波形在垂直方向上进行移动。
  2. 使用 floor()fragCoord.xfragCoord.y 进行圆整。这是因为 Shadertoy 有时候会给我提供一些额外加了 0.5 的坐标,需要圆整下将其统一成整数。
  3. iGlobalTime 乘以 40,这个修改只是为了让波形移动得更快,看起来不那么平淡。

接下来我们按既定计划继续对效果进行优化。

单像素宽

目前我们依然还是在使用不等式来显示波形,那如何将其显示为单像素宽的曲线呢?

可以使用下面的逻辑:

  1. 如果某个像素在函数曲线(红色像素部分)的下方,则说明它即为曲线的一部分。
  2. 为精准确定它是否属于波形曲线,我们需要检查它上下左右各个方向是否属于曲线。红黑区域之间的部分需要被渲染。

此外,我们将波形颜色改为绿色,上下部分的颜色都保留成黑白,来符合 Rhythm Doctor 的画面。=)

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
    const float yOffset = 100.0;
    
    float x = floor(fragCoord.x);
    float y = floor(fragCoord.y) - yOffset;
    float t = iGlobalTime * 40.0;
    
    bool center = rdWave(x      , t) >  y;
    bool right  = rdWave(x - 1.0, t) >  y; 
    bool left   = rdWave(x + 1.0, t) >  y; 
    bool up     = rdWave(x      , t) >  y + 1.0;
    bool down   = rdWave(x      , t) >  y - 1.0;

    if(center && !(right && left && up && down))
        fragColor = vec4(0.0, 1.0, 0.0, 1.0);
    else
        fragColor = vec4(0.0, 0.0, 0.0, 1.0);
}	

最后的效果可以到这里查看。

waves

结语

最终我们得到了希望的效果。在完成以上步骤后,我们需要将这段 shader 代码应用到游戏之中,这部分工作在 Unity 中完成。我们随后在这段 shader 之中加入了光晕和轮廓效果。为了让代码更加简单灵活,我们将 shader 函数的参数变量(如波形长度,三角波频率)绑定到了 Unity Inspector 上,这样我们就能够使用滑块和曲线编辑器随心定义我们希望看到的波形了。具体的过程可以看这个视频:

感谢你阅读这篇开发日志!希望你喜欢我们的游戏 Rhythm Doctor!

近期点赞的会员

 分享这篇文章

indienova 

indienova - 独立精神 

您可能还会对这些文章感兴趣

参与此文章的讨论

  1. 至尊小夜猫 2016-10-01

    需要个VR设备?

    • craft 2016-10-02

      @至尊小夜猫:不需要啊。这就是个单按键的节奏游戏。

您需要登录或者注册后才能发表评论

登录/注册