从零开始的nes模拟器-[5] PPU渲染精灵
PPU 精灵渲染
参考
精灵
这里再详细讲一下 Sprite (精灵)的概念。
精灵是一种可以在整个屏幕上自由移动的贴图。精灵通常是8×8 ( 可以理解为和1个tile的尺寸是一样的 ),但它们也可以是8×16(稍微复杂一些)。我将使用8×8的例子。精灵由PPU的OAM部分中的256个字节定义。有64个精灵。这是每个精灵4个字节。
但8×8是如此之小。我们如何让马里奥如此之大?我们将多个精灵组合在一起在屏幕上移动。这被称为metasprite。

小马里奥由4个精灵组成,大马里奥由8个精灵组成。
Nes 限制了一个扫描线,最多只有 8 个 sprite .
如果要了解 8 * 8 和 8 * 16 的 Sprite 的各自的优劣 , 可以参考这个文档:Sprite size
精灵的表示方法
OAM 全称 Object Attribute Memory,位于 PPU 芯片中,一共 256 bytes,一个 sprite 用 4 bytes,所以总共能表示 64 个 sprite。
4个字节的属性( 详见 )
Byte 0:
Sprite 的 Y 坐标
Byte 1:
76543210
||||||||
|||||||+- Bank ($0000 or $1000) of tiles
+++++++-- Tile number of top of sprite (0 to 254; bottom half gets the next tile)
该字节类似于 name table,sprite 有 2 种模式:
-
8 x 8
整个 byte 类似于 name table,由PPUCTR的 bit 3 选取 bank 之后,加上自身数据 x 16 得到偏移量因为 pattern table 表示一个 tile 需要 16 byte, 所以要 * 16
-
8 x 16
该模式下PUCTRL的 bit 3 不再起作用,bank 由 bit 0 决定,并且偏移量不再是 x 16,而是 x 32,具体参考.8 * 16 相当于是 两个 tile , 所以要 * 32.
下面是一些例子, 表示该字节的各个值映射到的对应内存位置
- $00: $0000-$001F
- $01: $1000-$101F
- $02: $0020-$003F
- $03: $1020-$103F
- $04: $0040-$005F
[...] - $FE: $0FE0-$0FFF
- $FF: $1FE0-$1FFF
Byte 2:
76543210
||||||||
||||||++- Palette (4 to 7) of sprite
|||+++--- Unimplemented
||+------ Priority (0: in front of background; 1: behind background)
|+------- Flip sprite horizontally
+-------- Flip sprite vertically
bit 0-1 决定高 2 bit 的 palette,类似于 attribute table 的功能
bit 5 决定优先级,如果 sprite 像素和 background 像素都不是透明像素的情况下(即 palette index % 4 != 0),则决定了到底显示 sprite 还是 background
bit 6-7 决定是否翻转像素,比如人物往右走设置为不翻转,往左走则设置为垂直翻转
Byte 3:
Sprite 的 X 坐标
代码定义
// OAM是PPU内部的额外内存。它是
// 有通过任何总线连接。它存储下一帧要被绘制的tile的信息
struct sObjectAttributeEntry
{
uint8_t y; // Y position of sprite
uint8_t id; // ID of tile from pattern memory
uint8_t attribute; // Flags define how sprite should be rendered
uint8_t x; // X position of sprite
} OAM[64];
写OAM
OAM 并不存在于 PPU 或 CPU 总线上,需要 CPU 通过 PPU 寄存器或者 DMA 方式才能写入
但是 OAM 并不存在于 PPU 或 CPU 总线上,需要 CPU 通过 PPU 寄存器或者 DMA 方式才能写入
| 端口地址 | 读写/位 | 功能描述 | 解释 |
|---|---|---|---|
| $2003 | 写 | 精灵RAM指针 | 设置精灵RAM的8位指针 |
| $2004 | 读写 | 精灵RAM数据 | 读写精灵RAM数据, 访问后指针+1 |
| $4014 | 写 | DMA访问精灵RAM | 通过写一个值$xx, 将CPU内存地址为$xx00-$xxFF的数据复制到精灵内存 |
代码:寄存器方式
定义
// 一些辅助变量 , 简单来说就是保存了cpu和ppu的oam通讯地址的寄存器, 对应CPU内存布局的 0x2005 的OAM_ADDR寄存器
uint8_t oam_addr = 0x00;
// 方便DMA使用的指针
public:
uint8_t* pOAM = (uint8_t*)OAM;
cpu写寄存器
// OAM Address
oam_addr = data;
// OAM Data
pOAM[oam_addr] = data;
代码:DMA方式
首先要定义和DMA相关的量
我们要把这些变量放进总线类(Bus class),因为总线掌管时钟信息,DMA又要和时钟协同工作
// 游戏精灵的传送使用DMA机制,精灵一共有64个,每个精灵大小为4个字节
// DMA传送开始时,CPU停止clock,改为DMA传送或者读取一次数据,每个周期还是只能进行读或者写一次操作
// dma_page和dma_addr组成一个16位的CPU地址,
// 通过DMA机制,将CPU中的数据,一个字节一个字节的送到PPU中
// 由于开始时需要在CPU的偶数周期进行第一次读,所以需要513或者514个周期
uint8_t dma_page = 0x00;
uint8_t dma_addr = 0x00;
uint8_t dma_data = 0x00;
DMA 会占用 512 个 CPU 时钟(奇数 CPU 周期还会再加一个时钟,前期可以先不考虑), 所以要加一个标记记录是否有额外周期。
// DMA传输需要精确传送。原则上需要
// 512个周期来读取和写入256字节的OAM内存,一个
// 先读后写。但是,CPU需要处于“偶数”状态
// 时钟周期,因此可能需要一个虚拟的空闲周
bool dma_dummy = true;
DMA传输需要精确计时。从理论上讲,读取和写入256个字节的OAM内存需要512个周期,即先读后写。然而,CPU需要处于一个“偶”的时钟周期,因此可能需要一个虚拟的空闲周期
DMA启用的标记位
// DMA传送发生标志
bool dma_transfer = false;
写DMA
在cpu写接口加入这段语句
if (addr == 0x4014)
{
// A write to this address initiates a DMA transfer
dma_page = data;
dma_addr = 0x00;
dma_transfer = true;
}
可以将DMA理解为独立于CPU,PPU的东西,要在时序上统筹起来,而DMA的工作时序是以CPU的时序为标准的,所以要和CPU同步
void Bus::clock()
{
// 时钟, 模拟器的核心
// 整个模拟器的运行"节奏"
// 其中,ppu时钟通常用来决定整个模拟器速度
ppu.clock();
// CPU的时钟周期比PPU和APU的慢3倍,也就是说,PPU和APU每执行3次,CPU执行一次
// 这是NES模拟器中规定的
// 使用nSystemClockCounter来记录ppu的时钟周期. 除3取余即可得到cpu时钟
if (nSystemClockCounter % 3 == 0)
{
// 判断是否发生DMA传送(将OAM数据传送到PPU中)
if (dma_transfer)
{
// 可能存在cpu的周期bug
// 这里虽然在cpu的周期中,但实际cpu不进行clock,
// 此时DMA占用cpu周期
// 通常占用513或514个周期
// (等待写入完成时为 1 个等待状态周期,如果在奇数 CPU 周期中为 + 1,则为
// 256 个交替读 / 写周期。) dma_dummy的默认是true
if (dma_dummy)
{
// 执行周期必须是偶数,需要等待一个cpu周期
if (nSystemClockCounter % 2 == 1)
{
// DMA开始传送
dma_dummy = false;
}
}
else
{
// DMA can take place!
if (nSystemClockCounter % 2 == 0)
{
// 偶数周期从cpu中读取数据,
// dma_page存储着 0x(dma_page)xx数据
// xx通常从0开始 ,0xFF结束,
// 64个精灵,每个精灵4个字节,所以是256个字节,也就是0xFF大小
// dma_page是地址的高位,dma_addr是地址的低位
// dma_data即为从cpu中读取的数据
dma_data = cpuRead(dma_page << 8 | dma_addr);
}
else
{
// On odd clock cycles, write to PPU OAM
// (我估计是读卡带里的内容)
ppu.pOAM[dma_addr] = dma_data;
dma_addr++;
// dma_addr 是 8 位的, 每次写入 OAM 地址 + 1 , 加到 256 之后 就会回归 0 了
if (dma_addr == 0x00)
{
dma_transfer = false;
dma_dummy = true;
}
}
}
}
else
{
// 没有发生DMA传送时,CPU时钟正常进行。
cpu.clock();
}
}
// PPU的中断通常发生在vertical blanking period,垂直消隐期间
// 也就是scanline大于251?? 的时候,属于在屏幕的下方,
// 此时cpu需要进行下一帧的名称表的准备
// 不可能在绘制的时间进行准备,只能等到绘制结束,否则会产生很多问题
// 唯一的机会就是在垂直消隐期间进行,也是nes设计巧妙之处
if (ppu.nmi)
{
ppu.nmi = false;
cpu.nmi();
}
nSystemClockCounter++;
}
渲染精灵
流程
从宏观上来看,需要几个步骤
- 在
scanline结尾,搜索OAM,确定不多于8个将会在下一个scanline显示的sprites. - 一个
scanline每扫描一个点 ( 1 个 cycle ),sprites的x坐标减1 - 如果某个 sprite 的 x 坐标 减为 0 , 开始绘制这个 Sprite
- 解 决绘制时 (有可能会) 遇到的sprites优先级的问题
代码
定义内部变量
// in class ppu_2c02
private:
sObjectAttributeEntry spriteScanline[8]; // 保存将要绘制的 sprites
uint8_t sprite_count; // 统计要绘制的 sprites 数
准备sprites
在ppu时钟函数里添加渲染的代码, 这里添加渲染精灵的代码不会影响渲染背景的代码。
if (cycle == 257 && scanline >= 0)
{
// 我们已经到达一条可见扫描线的末端。现在是决定的时候了
// 哪些精灵在下一条扫描线上可见,并预加载此信息
// 我们可以在扫描线扫描行时使用缓冲区。
// 首先,清除精灵记忆。此内存用于存储
// 要渲染的精灵。这不是奥姆。
std::memset(spriteScanline, 0xFF, 8 * sizeof(sObjectAttributeEntry));
// 最大支持8个
sprite_count = 0;
for (uint8_t i = 0; i < 8; i++)
{
sprite_shifter_pattern_lo[i] = 0;
sprite_shifter_pattern_hi[i] = 0;
}
// Thirdly, Evaluate which sprites are visible in the next scanline. We need
// to iterate through the OAM until we have found 8 sprites that have
// Y-positions and heights that are within vertical range of the next
// scanline. Once we have found 8 or exhausted the OAM we stop. Now, notice
// I count to 9 sprites. This is so I can set the sprite overflow flag in
// the event of there being > 8 sprites.
uint8_t nOAMEntry = 0;
// New set of sprites. Sprite zero may not exist in the new set, so clear
// this flag.
bSpriteZeroHitPossible = false;
// 在每一行中的257列的周期中,将下一行需要绘制的精灵进行预写入内存中
while (nOAMEntry < 64 && sprite_count < 9)
{
// Note the conversion to signed numbers here
int16_t diff = ((int16_t)scanline - (int16_t)OAM[nOAMEntry].y);
// If the difference is positive then the scanline is at least at the
// same height as the sprite, so check if it resides in the sprite
// vertically depending on the current "sprite height mode" FLAGGED
if (diff >= 0 && diff < (control.sprite_size ? 16 : 8))
{
// Sprite is visible, so copy the attribute entry over to our
// scanline sprite cache. Ive added < 8 here to guard the array
// being written to.
if (sprite_count < 8)
{
// Is this sprite sprite zero?
if (nOAMEntry == 0)
{
// It is, so its possible it may trigger a
// sprite zero hit when drawn
bSpriteZeroHitPossible = true;
}
memcpy(&spriteScanline[sprite_count], &OAM[nOAMEntry], sizeof(sObjectAttributeEntry));
sprite_count++;
}
}
nOAMEntry++;
} // End of sprite evaluation for next scanline
// Set sprite overflow flag
status.sprite_overflow = (sprite_count > 8);
}
**移位寄存器的生成( 配合翻转) **
要确定绘制的精灵的像素点,要用到移位寄存器(具体含义参照上一篇博文),这里会详解移位寄存器的生成。
要理解移位寄存器的生成,首先要理解翻转。翻转本质上就是调整渲染的行序或者列序
8 * 8 的模式

这些8 * 8 的 sprite 存储的信息其实和Name table类似, 都是保存了sprite 对应的 tile 在pattern table上的 id(参考上面介绍的 sprite 的 4 个 byte 的 功能), 只不过 Name Table 还包含了 Attribute Table, 但这些sprite 就没有和 Attribute Table 相关的信息, 所以也只能索引到调色板,没有办法确定具体颜色。
8*16 的模式
和8 * 8 的识别方式类似。但是只能识别前 8 * 8 部分对应的pattern id (因为一个tile 也就 8 * 8 ), 后8 * 8 部分要独立开来计算。

代码
准备内部变量
// 移位寄存器数组,原理和背景渲染的移位寄存器一样
uint8_t sprite_shifter_pattern_lo[8];
uint8_t sprite_shifter_pattern_hi[8];
每一帧的开始,都要重置移位寄存器
if (scanline == -1 && cycle == 1) { // Effectively start of new frame, so clear vertical blank flag status.vertical_blank = 0; // Clear sprite overflow flag status.sprite_overflow = 0; // Clear the sprite zero hit flag status.sprite_zero_hit = 0; // Clear Shifters for (int i = 0; i < 8; i++) { sprite_shifter_pattern_lo[i] = 0; sprite_shifter_pattern_hi[i] = 0; } }
实现翻转的代码框架
// 遍历当前 scanline 会绘制的精灵
for (uint8_t i = 0; i < sprite_count; i++)
{
//临时内部变量
uint8_t sprite_pattern_bits_lo, sprite_pattern_bits_hi;
uint16_t sprite_pattern_addr_lo, sprite_pattern_addr_hi;
// 这里分情况求出地址 sprite_pattern_addr_lo, sprite_pattern_addr_hi
// ctrl 寄存器表明是 8 * 8 模式
if (!control.sprite_size)
{
// 如果是垂直翻转
.....
// 如果不是垂直翻转、
.....
}//表明是 8 * 16 模式
else
{
// 如果是垂直翻转
.....
// 如果不是垂直翻转、
.....
}
// Hi bit plane equivalent is always offset by 8 bytes from lo bit plane
sprite_pattern_addr_hi = sprite_pattern_addr_lo + 8;
// 把位信息提取出来
sprite_pattern_bits_lo = ppuRead(sprite_pattern_addr_lo);
sprite_pattern_bits_hi = ppuRead(sprite_pattern_addr_hi);
// 如果是水平翻转
if (spriteScanline[i].attribute & 0x40)
{
// 水平反转位信息
}
// 最后把位信息保存回移位寄存器数组
sprite_shifter_pattern_lo[i] = sprite_pattern_bits_lo;
sprite_shifter_pattern_hi[i] = sprite_pattern_bits_hi;
}
8 * 8 模式
// 8x8 Sprite Mode - The control register determines the pattern table
if (!(spriteScanline[i].attribute & 0x80))// 0100 0000
{
// 非垂直翻转,直接组装出地址就好
sprite_pattern_addr_lo =
(control.pattern_sprite << 12 ) // Which Pattern Table? 0KB or 4KB offset
| (spriteScanline[i].id << 4 ) // Which Cell? Tile ID * 16 (16 bytes per tile)
| (scanline - spriteScanline[i].y); // Which Row in cell? (0->7)
}
else
{
// 垂直翻转,组装地址和上面稍微有点不同
sprite_pattern_addr_lo =
(control.pattern_sprite << 12 ) // Which Pattern Table? 0KB or 4KB offset
| (spriteScanline[i].id << 4 ) // Which Cell? Tile ID * 16 (16 bytes per tile)
| (7 - (scanline - spriteScanline[i].y)); // Which Row in cell? (7->0)
}
8*16 模式
// 8x16 Sprite Mode - The sprite attribute determines the pattern table
if (!(spriteScanline[i].attribute & 0x80))
{
// 非垂直翻转
if (scanline - spriteScanline[i].y < 8)
{
// 读上半部分
sprite_pattern_addr_lo =
((spriteScanline[i].id & 0x01) << 12) // Which Pattern Table? 0KB or 4KB offset
| ((spriteScanline[i].id & 0xFE) << 4 ) // Which Cell? Tile ID * 16 (16 bytes per tile)
| ((scanline - spriteScanline[i].y) & 0x07 ); // Which Row in cell? (0->7)
}
else
{
// 读下半部分
sprite_pattern_addr_lo =
( (spriteScanline[i].id & 0x01) << 12) // Which Pattern Table? 0KB or 4KB offset
| (((spriteScanline[i].id & 0xFE) + 1) << 4 ) // Which Cell? Tile ID * 16 (16 bytes per tile)
| ((scanline - spriteScanline[i].y) & 0x07 ); // Which Row in cell? (0->7)
}
}
else
{
// 垂直翻转
if (scanline - spriteScanline[i].y < 8)
{
// 读上半部分
sprite_pattern_addr_lo =
( (spriteScanline[i].id & 0x01) << 12) // Which Pattern Table? 0KB or 4KB offset
| (((spriteScanline[i].id & 0xFE) + 1) << 4 ) // Which Cell? Tile ID * 16 (16 bytes per tile)
| (7 - (scanline - spriteScanline[i].y) & 0x07); // Which Row in cell? (0->7)
}
else
{
// 读下半部分
sprite_pattern_addr_lo =
((spriteScanline[i].id & 0x01) << 12) // Which Pattern Table? 0KB or 4KB offset
| ((spriteScanline[i].id & 0xFE) << 4 ) // Which Cell? Tile ID * 16 (16 bytes per tile)
| (7 - (scanline - spriteScanline[i].y) & 0x07); // Which Row in cell? (0->7)
}
}
水平位翻转
//出处: https://stackoverflow.com/a/2602885
auto flipbyte = [](uint8_t b)
{
b = (b & 0xF0) >> 4 | (b & 0x0F) << 4;
b = (b & 0xCC) >> 2 | (b & 0x33) << 2;
b = (b & 0xAA) >> 1 | (b & 0x55) << 1;
return b;
};
// Flip Patterns Horizontally
sprite_pattern_bits_lo = flipbyte(sprite_pattern_bits_lo);
sprite_pattern_bits_hi = flipbyte(sprite_pattern_bits_hi);
这个代码很妙, 举个例子
1110 1001=》
0000 1110|1001 0000=1001 1110=》
0010 0011|0100 1000=0110 1011=》
0001 0101|1000 0010=1001 0111
移位寄存器更新
移位寄存器的作用参照上一篇博客,这里不详细解说了。
上面介绍了移位寄存器的生成,这里介绍渲染过程中移位寄存器的更新的代码(原理和背景渲染的移位寄存器差不多)
简单来说就是每经过一次有效渲染,寄存器就左移一位。
代码
// 精灵的移位寄存器更新
// 确保是 scanline 的 visable 区域
if (mask.render_sprites && cycle >= 1 && cycle < 258)
{
for (int i = 0; i < sprite_count; i++)
{
if (spriteScanline[i].x > 0)
{
// 还没到渲染位置
spriteScanline[i].x--;
}
else
{
// 到渲染位置了
sprite_shifter_pattern_lo[i] <<= 1;
sprite_shifter_pattern_hi[i] <<= 1;
}
}
}
};
计算像素点颜色
OAM 的 Sprite 的 attribute 信息 + 移位寄存器 -> 具体颜色索引。
如果一个像素点出现了几个精灵,那么早出现的精灵的像素会覆盖后出先精灵的像素。(也就是早出现的精灵优先级更高,
代码
// 三个临时变量
uint8_t fg_pixel = 0x00; // 颜色索引的前两位
uint8_t fg_palette = 0x00; // 颜色索引的后两位
uint8_t fg_priority = 0x00; //记录精灵的优先级,和像素颜色无关,但是后续渲染会用到这个量。
if (mask.render_sprites)
{
//遍历此扫描线的所有精灵。这是为了保持精灵优先级。只要我们找到一个精灵的非透明像素,我们就可以中止
bSpriteZeroBeingRendered = false;
for (uint8_t i = 0; i < sprite_count; i++)
{
//扫描线cycle 刚好渲染到精灵的像素
// 移位寄存器接管下面的工作
if (spriteScanline[i].x == 0)
{
// 组成前两位
uint8_t fg_pixel_lo = (sprite_shifter_pattern_lo[i] & 0x80) > 0;
uint8_t fg_pixel_hi = (sprite_shifter_pattern_hi[i] & 0x80) > 0;
fg_pixel = (fg_pixel_hi << 1) | fg_pixel_lo;
// 组成后两位
fg_palette = (spriteScanline[i].attribute & 0x03) + 0x04;
// 检查优先级
fg_priority = (spriteScanline[i].attribute & 0x20) == 0;
//如果像素不是透明的,我们渲染它,但是不检查其余部分,因为列表中较早的精灵优先级更高
if (fg_pixel != 0)
{
if (i == 0) // Is this sprite zero?
{
bSpriteZeroBeingRendered = true;
}
break;
}
}
}
}
背景与精灵的较量
我们知道,一个像素点,有时候既可以按照 background 的颜色信息渲染, 也可以按照 sprite 的颜色信息渲染。那到底要怎么做决定呢?
如果其中一个是透明色,那么结果显而易见。
如果两者都不是透明色,那么就要参照 oam 的 attribute byte 里面的属性。
代码
//现在我们有一个背景像素和一个前景像素。它们需要结合起来。精灵可以隐藏在非“透明”的背景贴图后面,这是PPU的另一个巧妙之处
uint8_t pixel = 0x00; // The FINAL Pixel...
uint8_t palette = 0x00; // The FINAL Palette...
// 都是透明色...
if (bg_pixel == 0 && fg_pixel == 0)
{
pixel = 0x00;
palette = 0x00;
}
else if (bg_pixel == 0 && fg_pixel > 0)
{
//背景像素是透明的
//前景像素是可见的
pixel = fg_pixel;
palette = fg_palette;
}
else if (bg_pixel > 0 && fg_pixel == 0)
{
//背景像素是可见的
//前景像素是透明的
pixel = bg_pixel;
palette = bg_palette;
}
else if (bg_pixel > 0 && fg_pixel > 0)
{
// 如果sprite 优先级更高
if (fg_priority)
{
pixel = fg_pixel;
palette = fg_palette;
}
else
{
pixel = bg_pixel;
palette = bg_palette;
}
精灵0命中
精灵0命中这个特性可以用在 屏幕分割中的 Splite X Scroll上面。 ( 上一篇博文讲了屏幕滚动 ,这里讲的是屏幕分割,相对来说更高级)
split X scroll 需要用到 sprite 0 hit,主要的作用可以用来制作静止的分数,血条等等,举个马里奥的例子:

深色部分是不会像下面浅色部分滚屏的,感觉上面和下面分割开来,且只有水平方向的分割,所以叫做 split X scroll,下面来看看是如何实现的。
先来了解什么叫做 sprite 0 hit,sprite 0 hit 就是说如果第 0 个精灵的不透明像素与背景不透明像素重合的话,就将 0x2002 PPUSTATUS 状态寄存器的 bit 6 置 1,表示触发了 sprite 0 hit。
这有什么作用?它是 PPU 与 CPU 同步的一种手段,当 V_Blank 触发 NMI 时,CPU 只是知道当前帧渲染完了,准备下一帧,这个时间同步不精细。于是创造一个 sprite 0 hit,编程人员将 sprite 0 放在一个特定位置,当触发 sprite 0 hit 时,CPU 就知道,哦,原来渲染到这条 scanline 了。
我们就可以利用这个特性来实现 split X scroll,我们就以超级马里奥为例子,来看 split X scroll 如何实现的。
首先看超级马里奥的 sprite 0 在哪儿:

这是游戏刚开始的“两个”精灵,其中上面那个就是 sprite 0,具体在哪儿?

具体的,就是在金币的下方,金币是背景,且不是使用的通用背景色,sprite 0 也不是透明色,所以每一帧渲染到这一行的 sprite 0 所在的位置时就会触发 sprite 0 hit。
注: 我的理解是这个特性不是必要的,只是为了实现某个功能的时候会用到。
代码
变量定义
// Sprite Zero Collision Flags
bool bSpriteZeroHitPossible = false;
bool bSpriteZeroBeingRendered = false;
这些变量的修改赋值,在其他部分有涉及到(自己往上面看)
实现代码
// Sprite Zero Hit detection
// 精灵0命中在前景和后景都不是透明的颜色的时候发生
if (bSpriteZeroHitPossible && bSpriteZeroBeingRendered)
{
// Sprite 0是前景和背景之间的碰撞 , 所以它们必须都是启用的
if (mask.render_background & mask.render_sprites)
{
//屏幕的左边缘有特定的开关来控制其外观。简单来说,这是用来实现平滑滚动的。
if (~(mask.render_background_left | mask.render_sprites_left))
{
if (cycle >= 9 && cycle < 258)
{
status.sprite_zero_hit = 1;
}
}
else
{
if (cycle >= 1 && cycle < 258)
{
status.sprite_zero_hit = 1;
}
}
}
}

浙公网安备 33010602011771号