GameMaker Studio 2中实现2D动态光影效果(Part 1)
原文是YoYo官网的教程,共有两篇,这是第一篇:Realtime 2D Lighting In GameMaker Studio 2 - Part 1
第二篇可能要再等等,这两天有点事儿orz
——————————
对于开发人员而言想实现一套 2D 照明引擎是比较高阶的操作,其实现过程通常困难重重。本文我将简单介绍一些光影的基础知识,比如什么是“遮挡物”,以及如何以合理有效的方式来投射阴影。
制作遮挡物最快速的方法(同时也最易于编辑)是使用一个新的 tilemaps 。这允许我们来检测哪些单元格更靠近光源,这比使用实例要方便的多,因为使用实例你必须使用循环来遍历检测哪些单元格最靠近光源。因此,我们坚持使用 tilemaps。
首先,我们创建一个简单的 tileset 和 tilemap 进行测试。(先创建 tileset)
较暗的瓷片(tile)用于铺满第一层我们称其为地面(ground),浅色的瓷片则用来铺设第二层我们称为墙(walls)。
然后我们创建一个对象并将其作为我们的光源,把它放在我们的地图上,然后我们看一下需要处理些什么。如下图所示,红色圆圈是光线的范围,黄色的矩形是我们需要处理的 tilemap 区域。
要实现以上需求,我们需要遍历这些瓷片,并核实其中是否有墙的瓷片。
var lx = mouse_x; // 光源坐标,基于鼠标的位置 var ly = mouse_y; var rad = 96 // 发光半径 var tile_size = 32; // 瓷片的尺寸 var tilemap = layer_tilemap_get_id("walls"); //获取摆放墙的 tilemap id //定位待检测矩形区域顶点坐标 var startx = floor((lx-rad)/tile_size); var endx = floor((lx+rad)/tile_size); var starty = floor((ly-rad)/tile_size); var endy = floor((ly+rad)/tile_size); //绘制黄色矩形区域 draw_set_color(c_yellow); draw_rectangle(startx*tile_size,starty*tile_size, endx*tile_size,endy*tile_size,true);
把以上代码放进对象(光源位置的那个对象)的绘制事件(draw event)中,这将选择我们需要处理的瓷片,并且在范围上绘制一个黄色的矩形,紧贴瓷片的边界。接下来,我们需要遍历这个区域中的瓷片来查找非空的瓷片单元格,如下:
for(var yy=starty;yy<=endy;yy++) { for(var xx=startx;xx<=endx;xx++) { var tile = tilemap_get(tilemap,xx,yy); if( tile!=0 ){ } } }
那么,我们现在已经准备好真正做点什么了!首先,我们在这个对象的创建事件(create event)中添加一些代码来处理绘制阴影的多边形顶点缓冲和我们需要使用的顶点格式
/// @description 初始化阴影构成
vertex_format_begin();
vertex_format_add_position();
vertex_format_add_color();
VertexFormat = vertex_format_end();
VBuffer = vertex_create_buffer();
完成以上工作后,我们就可以准备构建出一些阴影了,但是在我们开始之前,我们要搞清楚我们要如何投射阴影呢?我们先回去看一下之前的光照半径图,从光照中心投射光线到其中一个块儿上——每个角都要很精确。在这个块儿背后的光线范围都是我们稍后要绘制阴影的区域。现在你会注意到,前面的两条边缘跟后面的形状是一样的——只是他们由更靠近光源的点开始。这很方便操作,这意味着我们只需要处理面向光源的边缘。
让我们再试一次,这次我们只处理靠前的两条边——靠近光源的面。我们使用前面两条边作为 QUAD(由两个三角形构成的四边形)的前缘。另外希望它的投影范围更长,因此我们让这两条边尽可能的长——硬件会在 viewport 边缘直接进行裁剪,我们不用担心这两条边会太长。我们先确定好这个块儿各个顶点在场景(room)中的坐标,然后创建一些线条(向量,如下所示)
现在,我们已经清楚投影区域的四条边,让我们重新处理一下循环来构建我们的缓冲区
vertex_begin(VBuffer, VertexFormat);
for(var yy=starty;yy<=endy;yy++)
{
for(var xx=startx;xx<=endx;xx++)
{
var tile = tilemap_get(tilemap,xx,yy);
if( tile!=0 )
{
// get corners of the
var px1 = xx*tile_size; // top left
var py1 = yy*tile_size;
var px2 = px1+tile_size; // bottom right
var py2 = py1+tile_size;
ProjectShadow(VBuffer, px1,py1, px2,py1, lx,ly );
ProjectShadow(VBuffer, px2,py1, px2,py2, lx,ly );
ProjectShadow(VBuffer, px2,py2, px1,py2, lx,ly );
ProjectShadow(VBuffer, px1,py2, px1,py1, lx,ly );
}
}
}
vertex_end(VBuffer);
vertex_submit(VBuffer,pr_trianglelist,-1);
ProjectShadow 中的技巧是获取光源到遮挡物上的每一个点的向量。我们通过计算Point1X-LightX和Point1Y-LightY以及Point2X-LightX和Point2Y-LightY 来做到这一点。这会给我们两个需要用到的向量,接下来我们要实现向量“单元”,这是一个长度为 1.0 的向量,这样可以便于我们将单位向量进行缩放,如果你特别靠近障碍物,其中一条边可能会特别近(在屏幕上),而另一条边则偏远。这会使这两者的投影比例一致并且更均匀,下面是计算单元向量的代码:
Adx = PointX-LightX;
Ady = PointY-LightY;
len = sqrt( (Adx*Adx)+(Ady*Ady) );
Adx = Adx / len;
Ady = Ady / len;
Adx 和 Ady包含了长度为 1 的向量,即 sqrt((Adx * Adx)+(Ady * Ady))== 1.0。单元向量将在计算过程中覆盖所有区域,包含普通的 3D 照明模型的法线及运动方向矢量。比如,如果你想以恒定的速度进行对角线斜向移动(当你使用 x++和 y++时,对角线移动速度将比横纵轴更快)时,你可以使用单位向量乘以你想要的速度。一旦我们得到了这个单位向量,我们可以以大量缩放并将其添加到坐标位置。这将会帮助我们填补“QUAD”的远点,以下是 ProjectShadow 的脚本
/// @description 根据光源位置投射某线段的阴影
/// @param VB Vertex buffer
/// @param Ax x1
/// @param Ay y1
/// @param Bx x2
/// @param By y2
/// @param Lx Light x
/// @param Ly Light Y
var _vb = argument0;
var _Ax = argument1;
var _Ay = argument2;
var _Bx = argument3;
var _By = argument4;
var _Lx = argument5;
var _Ly = argument6;
// 阴影是无限的——至少要完全覆盖整个屏幕
var SHADOW_LENGTH = 20000;
var Adx,Ady,Bdx,Bdy,len
// 获取点 1 的单元向量长度
Adx = _Ax-_Lx;
Ady = _Ay-_Ly;
len = (1.0*SHADOW_LENGTH)/sqrt( (Adx*Adx)+(Ady*Ady) ); // unit length scaler * Shadow length
Adx = _Ax + Adx * len;
Ady = _Ay + Ady * len;
// 获取点 2 的单元向量长度
Bdx = _Bx-_Lx;
Bdy = _By-_Ly;
len = (1.0*SHADOW_LENGTH) / sqrt( (Bdx*Bdx)+(Bdy*Bdy) ); // unit length scaler * Shadow length
Bdx = _Bx + Bdx * len;
Bdy = _By + Bdy * len;
// 构造 QUAD(即投影本体)
vertex_position(_vb, _Ax,_Ay);
vertex_argb(_vb, $ff000000);
vertex_position(_vb, _Bx,_By);
vertex_argb(_vb, $ff000000);
vertex_position(_vb, Adx,Ady);
vertex_argb(_vb, $ff000000);
vertex_position(_vb, _Bx,_By);
vertex_argb(_vb, $ff000000);
vertex_position(_vb, Adx,Ady);
vertex_argb(_vb, $ff000000);
vertex_position(_vb, Bdx,Bdy);
vertex_argb(_vb, $ff000000);
因此,当你把以上内容整合起来,并将创建事件中的 rad 变量设为 256 后,您将得到下图所示的画面,当你移动鼠标时阴影也会随之移动并正确投影。最后,我们来修正一下确保四条边都能得到正常的投影。
如上所述,我们可以通过投影两条边就得到同样的效果。我们需要测试光位于向量哪一侧(障碍物的边缘)来实现这一点:
if( !SignTest( px1,py1, px2,py1, lx,ly) ){
ProjectShadow(VBuffer, px1,py1, px2,py1, lx,ly );
}
if( !SignTest( px2,py1, px2,py2, lx,ly) ){
ProjectShadow(VBuffer, px2,py1, px2,py2, lx,ly );
}
if( !SignTest( px2,py2, px1,py2, lx,ly) ){
ProjectShadow(VBuffer, px2,py2, px1,py2, lx,ly );
}
if( !SignTest( px1,py2, px1,py1, lx,ly) ){
ProjectShadow(VBuffer, px1,py2, px1,py1, lx,ly );
}
如你所见,我们的内部循环调用只需稍稍变化即可,现在可以在正式投影之前检测边缘。这样做边缘检测非常快(你甚至可以直接使用内联),只检测边缘肯定比投影更快,代码如下:
/// @description 检测点位于线条哪一侧
/// @param Ax
/// @param Ay
/// @param Bx
/// @param By
/// @param Lx
/// @param Ly
var _Ax = argument0;
var _Ay = argument1;
var _Bx = argument2;
var _By = argument3;
var _Lx = argument4;
var _Ly = argument5;
return ((_Bx - _Ax) * (_Ly - _Ay) - (_By - _Ay) * (_Lx - _Ax));
对于那些好奇心旺盛的人而言,这是,但你并不需要真的了解——或关心它究竟如何工作——它就是能用!现在,添加这个可能并没有明显的效果,事实上它看上去应该跟之前完全一样,但我们现在只需要绘制一半的量——高效总是值得的。根本而言,你现在已经又了一套基础的 2D 光照引擎。捕获到障碍物的边缘,然后进行投影并绘制阴影。如何创建这些障碍物完全取决于你,但是一旦您确保边缘进入了光照范围并且面朝光源,你就可以使用以上的代码来绘制出阴影。最后…把实例所在的图层移动到墙壁所在的图层下方,你将得到以下画面…
本文内容到此为止,下一次我们的任务是使光源半径外的东西都变黑,然后你会感受到真正的点光源效果。在那之后,你可能会想要添加一些颜色和“淡出”效果,以使得光线在到达光照半径边缘时慢慢消失,然后也许添加更多的光。