SMBX 2D伪阴影实验
1. 前言
这份代码的模型只是对 2D 光线性状的几何视角下的模拟,它不涉及十分复杂的光学模型。由于 SMBX 38A 孱弱的图形绘制接口,该模型使用了许多在其它开发场景不常用的方法,这些方法在实践中造成了会带来非真实感的瑕疵,这也是开发实践对开发环境的一种让步了。
该工程的核心脚本分为两部分,一是 LibShadow2D,这是计算光线和几何的核心方法库;二是 LibPoolUtils,它提供了一组管理光源实例的资源管理办法。由于时间关系,这里并没有将两个方法库中所提供的方法用到极致:比如扫描物体与光源位置并过滤掉远离光源的物体、回收离开屏幕的物体的阴影实例到缓存池中等等……希望后来的使用者能够在此基础上更进一步。
2. 关于脚本的效果
本用例展示了一个两个光源的场景:



此外,我在实现这个脚本的过程中以此为基础做了一个完成度更高的场景:

据实践,上述场景共申请了阴影约莫 80 左右组,运行仍十分流畅。
3. 关于脚本的使用
为了能够更容易厘清脚本运行的逻辑,也为了能尽量减少外部函数的参数,LibShadow2D 选择了使用类似于设置状态机的方式进行书写。在生成阴影的时候,阴影实例的生命周期大概以“实例化-[设置上下文-刷新]-销毁”的结构书写,其中方括号内的为循环运行部分。对于每个实例,与 TeaScript 中的 bitmap 相同地,它依靠 id 进行索引,且 id 不能与 bitmap id 重复。
而对于 LibPoolUtils,由于涉及到数组的相关操作,难以再使用与阴影方法集相似的构建方式,也没有提供同时创建多个 pool 的方法。不过这并不代表它不能实现,其实如果将 bitmap 与 id 的对应关系也视为一种数组的话,那似乎也完全可以摆脱 TeaScript 所提供的蹩脚的数组类型。不过呢这已经不在本用例的讨论范围之内了。
2.1. LibShadow2D 脚本的生命周期
LibShadow2D 中所设计的阴影实例是提供了相对完整的生命周期管理办法的,其主要结构为“实例化-[设置上下文-刷新]-销毁”,在本小节会展开描述。下面先来看看生成一个 "光源在屏幕中心且以 Player1 为碰撞箱" 的阴影脚本的最简化写法,这个脚本挂载在一个游戏开始时就会自动运行的事件中:
' ====================================================================================
' ================================================================= 人物角色阴影
' 阴影贴图资源设置
Dim resShadow_id As Integer = 45
' 阴影设置
Dim height As Double = 8
Dim resShadow_splitNum As Integer = 50
Dim sampleJump As Double = 5
' 位置信息相关变量声明
Dim pBox_x As Double
Dim pBox_y As Double
Dim pBox_w As Double
Dim pBox_h As Double
' ------------------------------- 阴影图象初始化 其 id 为 1
Call Shadow2D_BmpShadowCreate(1, resShadow_id, resShadow_splitNum, sampleJump, height)
' ---------------------------------------------------------------------------- 主循环
Do
' ------ 先计算玩家位置和碰撞箱大小
pBox_x = Char(1).x - Sysval(Player1scrX)
pBox_y = Char(1).y - Sysval(Player1scrY)
pBox_w = Char(1).pwidth
pBox_h = Char(1).pheight
' ---------------------------------------- 刷新 id 为 1 的阴影
' ------ 设置位置数据
Call Shadow2D_SetLightPos(1, 400, 300) ' 光源位置
Call Shadow2D_SetBoxPos(1, pBox_x, pBox_y, pBox_w, pBox_h) ' 碰撞箱设置
' ------ 阴影刷新
Call Shadow2D_AABBRect_WithAttenRefresh(1) ' 刷新函数
Call Sleep(1)
Loop
2.1.1. 阴影的初始化
类似 TeaScript 中默认的 bitmap,Shadow2D 也是通过一个 Create 方法来构建构建实例的。在运行这个方法时,Shadow2D 会为要生成的阴影生成数个 bitmap 以拼成一个完整的梯形阴影。当然,在刚创建时,这些 bitmap 都是以隐藏的状态存在于游戏中的。若要使其显示,还需要经过后两个生命周期阶段。
创建阴影的方法名为:Shadow2D_BmpShadowCreate(…),在 LibShadow2D 中,外部方法都严格按照“前缀_方法名”来命名,这是为了避免与游戏中的其它方法集重名而引发歧义。该方法有五个参数,它们主要给出了阴影 id 和采样目标贴图的相关信息:
' 创建阴影图形对象
' @param id: 阴影 id
' @param picId: 阴影横纹采样的目标 npcid
' @param count: 阴影横纹条数 其值不能超过 1000
' @param sampleJump: 采样偏移
' @param height: 单条阴影横纹宽度
Export Script Shadow2D_BmpShadowCreate(id As Integer, picId As Integer, count As Integer, sampleJump As Double, height As Integer)
npcid 参数 - 指出了阴影目标贴图贴图所在的 npcid。本用例中使用了一张 512 * 512 的贴图,但实际上在采样阶段只会用到第一列像素的值。也就是说,将其裁切为一张 1 * 512 的贴图也能达到同样的效果。另外,即使单就第一列像素而言,也并非是每个都能采样到,实际上,它的采样方式受到函数的 count 参数和 sampleJump 参数的控制。
count 参数 - 给出了阴影横纹条数,因为 Shadow2D 阴影的梯形效果本质上是由多个长度线性增长的 bitmap(后称横纹)叠加起来的。在设计上来说,每一条阴影横纹都只采样目标贴图中的一个像素。
sampleJump 参数 - 指出了当阴影横纹加一时,对于目标贴图的采样位置的增量。比如当 sampleJump == 1 时,若 count = 500,那么此时所生成的第 1500 条阴影横纹所对应到目标贴图的位置就是第一列的第 1500 个像素。
height 参数 - 给出了单条阴影横纹的宽度。它会直接影响阴影长度。
2.1.2. 阴影的参数设置
在每一帧里,阴影都会随着场景中灯光和物体的变换而变换。也就是说,我们需要在每一帧中刷新我们阴影的状态。为了能够使刷新后的阴影显示在正确的位置,我们需要为我们的阴影实例设置变换需要的信息,下面是 LibShadow2D 在刷新阴影前可以设置的信息:
' 设置阴影的光源
' @param id: 阴影 id
' @param pLightSrc_x: 光源的 x 坐标
' @param pLightSrc_y: 光源的 y 坐标
' @return: 1·设置成功 -1·设置不成功
Export Script Shadow2D_SetLightPos(id As Integer, pLightSrc_x As Integer, pLightSrc_y As Integer, Return Integer)
' 设置阴影的碰撞箱
' @param id: 阴影 id
' @param pBox_x: 碰撞箱左上角的 x 坐标
' @param pBox_y: 碰撞箱左上角的 y 坐标
' @param pBox_w: 碰撞箱宽
' @param pBox_h: 碰撞箱高
' @return: 1·设置成功 -1·设置不成功
Export Script Shadow2D_SetBoxPos(id As Integer, pBox_x As Integer, pBox_y As Integer, pBox_w As Integer, pBox_h As Integer, Return Integer)
' 设置阴影的碰撞箱 (圆形)
' @param id: 阴影 id
' @param pBox_x: 碰撞箱左上角的 x 坐标
' @param pBox_y: 碰撞箱左上角的 y 坐标
' @param radius: 碰撞箱半径:
' @return: 1·设置成功 -1·设置不成功
Export Script Shadow2D_SetCircle(id As Integer, pBox_x As Integer, pBox_y As Integer, radius As Integer, Return Integer)
' 设置阴影透明度
' @param id: 阴影 id
' @param alpha: 透明度
' @return: 1·设置成功 -1·设置不成功
Export Script Shadow2D_SetAlpha(id As Integer, alpha As Double, Return Integer)
' 设置阴影的 zPos
' @param id: 阴影 id
' @param zpos: 阴影的 zpos
' @return: 1·设置成功 -1·设置不成功
Export Script Shadow2D_SetZpos(id As Integer, zpos As Double, Return Integer)
' 设置阴影的间距
' @param id: 阴影 id
' @param spacingFac: 阴影横纹间距
' @return: 1·设置成功 -1·设置不成功
Export Script Shadow2D_SetSpacing(id As Integer, spacingFac As Double, Return Integer)
' 设置阴影衰减参数
' @param id: 阴影 id
' @param attenuateStart: 开始衰减半径
' @param attenuateFac: 衰减速度因子
' @return: 1·设置成功 -1·设置不成功
Export Script Shadow2D_SetAtten(id As Integer, attenuateStart As Double, attenuateFac As Double, Return Integer)
并非是每一帧都一定要将所有数据重新填写一遍。我们只需要刷新需要刷新的部分数据就好。此外,这些设置方法的返回值并没有什么实际作用,只是为了方便 Debug 的留存。对于刚刚新建的阴影实例来说,大部分的数据都会初始化为 0,除了以下列出的部分数据:
2.1.3. 阴影的刷新
在设置完阴影的参数后,刷新就显得尤为简单了,只需要提供需要刷新的阴影 id 即可。刷新方法根据效果的不同一共有四个,如下所示:
' 碰撞箱为 AABB 矩形的 2D 阴影的刷新
' @param id: 索引 id
' @return: 当前阴影透明度 (不受阴影透明度乘数影响的原始值)
Export Script Shadow2D_AABBRectRefresh(id As Integer, Return Double)
' 碰撞箱为 AABB 矩形的 2D 阴影 (带衰减) 的刷新
' @param id: 索引 id
' @return: 当前阴影透明度 (不受阴影透明度乘数影响的原始值) 若返回值小于 -1 则说明当前阴影在光源范围外
Export Script Shadow2D_AABBRect_WithAttenRefresh(id As Integer, Return Double)
' 碰撞箱为圆形的 2D 阴影的刷新
' @param id: 索引 id
' @return: 当前阴影透明度 (不受阴影透明度乘数影响的原始值)
Export Script Shadow2D_HalfCircleRefresh(id As Integer, Return Double)
' 碰撞箱为圆形的 2D 阴影 (带衰减) 的刷新
' @param id: 索引 id
' @return: 当前阴影透明度 (不受阴影透明度乘数影响的原始值) 若返回值小于 -1 则说明当前阴影在光源范围外
Export Script Shadow2D_HalfCircle_WithAttenRefresh(id As Integer, Return Double)
这里需要重要指出的是关于阴影刷新的返回值。该返回值与物体对光源的距离有关,当物体完全遮挡住光源时,它的值将为 0。当物体原理光源时,它的值将为 1。在某些不远不近的位置时,它的值可能会处在 01 之间。该值可用来作为物体挡住光源的判定,如果你想做当物体挡住光源时场景变暗的效果的话……
此外,但从计算量分析,碰撞箱为圆形的阴影,其刷新性能是要远远好于碰撞箱为矩形的阴影的。如果需要为大量且长宽比几乎相等的哦物体赋阴影的话,建议优先采用碰撞箱为圆形的阴影。
2.1.4. 阴影的销毁和隐藏
如果我们有销毁阴影物体的需求,请务必调用 Shadow2D 内部提供的阴影销毁方法:
' 销毁阴影图形对象
' @param id: 索引 id
Export Script Shadow2D_BmpShadowDestroy(id As Integer)
若我们只是想单纯地隐藏阴影,而不像销毁申请的实例(这种做法在内存池中会经常用到)。那可以调用阴影隐藏方法:
' 隐藏阴影图形对象
' @param id: 索引 id
Export Script Shadow2D_BmpShadowHide(id As Integer)
总之,这个阴影方法集还是希望将所有对阴影的操作都封闭在内部方法中的。使用者尽量还是不要通过外部的方法去手动修改阴影的数据。虽然在 TeaScript 中完全封闭的结构似乎是不可能的。
2.2. LibPoolUtils 实例池的使用
实例池相关的方法被封装在 LibPoolUtils 脚本中,但是由于 TeaScript 并不支持在脚本中声明数组,所以若要使用该脚本,请务必在变量列表中加上 PUinstancePool 和 PUidRec 数组,如下图中最后两个变量所示:

与 LibShadow2D 的生命周期类似的,LibPoolUtils 也有自己的生命周期。它并不是一个图形类,这意味着对它来说不存在刷新的概念,我们只需要了解它的实例化和相关使用接口即可。下面这提供了较完整的一个使用流程:
' ------------------------------------- 初始化阴影实例的实例池 (这里的设计是同屏最多四十个阴影)
Call PoolUtils_Init(40)
' ------------------------------- 初始化池内数据
For i = 0 To 39
Call PoolUtils_InitInst(i, i + 100)
Call Shadow2D_BmpShadowCreate(i + 100, resShadow_id, resShadow_splitNum, 5, shadowHeight)
Next
' ---------------------------------------------------------------------------- 主循环
Do
' ------------------------------- 该变量用来控制全屏阴影 本用例中未使用全屏阴影 仅作阴影绘制函数输出值参考
Val(GlobalShadowAlphaAtten2) = 1
' ------------------------------- 迭代遍历已申请的阴影对象并回收
Do
i = PoolUtils_ItrUsageInstance()
If i < 0 Then
Exit Do
End If
Call Shadow2D_BmpShadowHide(i)
Loop
' ------------------------------- 清空池内对象的申请记录
Call PoolUtils_ClcInstance()
' ------------------------------- 遍历 npc 并从池中为其申请阴影对象
For i = 1 To Sysval(ncount)
pBox_x = NPC(i).x - Sysval(Player1scrX)
pBox_y = NPC(i).y - Sysval(Player1scrY)
pBox_w = NPC(i).width
pBox_h = NPC(i).height
pLightSrc_x = Val(pLightSrcX) - Sysval(player1scrx)
pLightSrc_y = Val(pLightSrcY) - Sysval(player1scry)
' --------------------------- 从池内取出空闲的阴影 id, 并刷新对应的阴影对象
shadowId = PoolUtils_GetInstance()
Call Shadow2D_SetLightPos(shadowId, pLightSrc_x, pLightSrc_y)
Call Shadow2D_SetBoxPos(shadowId, pBox_x, pBox_y, pBox_w, pBox_h)
Call Shadow2D_AABBRect_WithAttenRefresh(shadowId)
Next
Call Sleep(1)
Loop
实例池设计的初衷是避免大量频繁的 GC 操作在游戏软件中出现。在这个用例中,并没有真的为所有 npc 都实例化了一个阴影类。而是在一开始先初始化了 40 个阴影类,然后在接下来的帧中随机分配给一些 npc 物体进行刷新。一方面这避免了场景中出现过多的 bitmap,另一方面,当 npc 原理某个光源时,阴影类也不必销毁,而是可以分配给其它需要的 npc 进行刷新,这大大减少了游戏软件中 GC 的频率。
由于设计实例池并非本用例的重点部分,这里就不展开叙述了。本用例中的实例池还有许多可以优化的地方,或许它甚至也能像 LibShadow2D 方法集一样不依赖任何全局数组或全局变量呢?
4. 一些碎碎念:从 SMBX 38A 脚本的软件工程 到 SMBX 圈子的内聚性
诚然,TeaScript 提供了一套图灵完备的解决方案,对于一位专注于打算法比赛的学生来说,甚至可能也能成为一件趁手的兵器。但是当落实到工程中时,它又显得令人难受。不论是内聚也好解耦也好,各种思想在 TeaScript 中都难以找到一个合理的解决方法,以至于 SMBX 38A 的开发上限有些过低了。
不过,开发上限底并不是没有好处的……这得从另一个角度谈起。我想起前段时间和别人的论述中所聊到的那样 —— 局限性限制了素材的过渡发散,但是这反倒促成了 SMBX 圈子及其内部作品的高度统一。这点是普适化的游戏引擎所不能及的。
5. 脚本下载地址