区块

区块是对一个场景中所有方块的数据组织方式,将所有的方块按照相同的大小进行切分,然后以区块为单位进行资源的管理,包括碰撞,渲染等。

如果不区块化,而是用一个大TileMap来渲染整个世界的方块,会导致摧毁或生成一个方块都会重新绘制整个世界,造成极高的开销。同时也不利于存档,动态加载/卸载资源。

区块化发生在三个层面:逻辑数据区块,Unity表现区块,更新策略区块化。

CU的区块化

值得注意的是,CU的方块数据存储没有分布到每个区块中,而是使用一整个大二维数组来存储方块数据

1
private ushort[,] worldBlocks;

所以档CU更新一个区块的时候,都是遍历整个区块的方块,然后依次SetTile:

1
2
3
4
5
6
7
8
9
10
11
public void UpdateChunk(Vector2Int chunk)
{
for (int i = 0; i < CHUNKSIZE; i++)
{
for (int j = 0; j < CHUNKSIZE; j++)
{
chunks[chunk.x, chunk.y].SetTile(new Vector3Int(i - HALFCHUNKSIZE, j - HALFCHUNKSIZE), tiles[worldBlocks[i + chunk.x * CHUNKSIZE, j + chunk.y * CHUNKSIZE]]);
}
}
ChunkUpdated[chunk.x, chunk.y].Invoke();
}

这意味着更多的Unity原生层调用。

更好的方式是将具体的方块数据分部到每一个区块中,用一维数组存储,然后在更新区块时,一口气提交整个区块的更改。

1
tilemap.SetTilesBlock(bounds, tileBuffer);

CU的区块实际上是一个独立的Unity GameObject,每个区块有独立的Tilemap,具有独立的渲染和碰撞。

CU区块的生成流程参考如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
创建全局数组

创建256个区块GameObject

在worldBlocks中生成地形

生成边界、矿物和结构

UpdateWorld()

把worldBlocks复制到各区块Tilemap

生成实体

关闭全部区块Renderer

开启玩家附近区块

区块可见性管理

CU管理区块渲染的核心方法是UpdateChunkVisibility,其大致逻辑是每帧检测摄像机位置,如果摄像机位置移动了足够远的距离,则调用一次UpdateChunkVisibility,启用周围区块的TilemapRenderer,同时关闭远处区块的TilemapRenderer

可见区块的窗口大小是7×7,但最外围一圈区块的渲染是关闭的,只有内部的5×5区块启用渲染。未被渲染的区块同时还会停止其中的实体模拟,应该主要是生物ai,还有液体之类的。

我使用的是RuleTile,会自动根据周围邻居方块来决定自身外观,但这种模式匹配无法跨TileMap进行。因此区块之间会出现缝隙。

缝隙

因此,需要给每一个区块构建一个隐形的邻居方块代理,这些隐形代理会保存区块外边缘的方块数据,然后重写ruletile的匹配模式,将邻居区块的方块数据纳入匹配模式中。

当然,也可以完全不使用RuleTile,而是自己写一套全局的模式匹配算法。但因为刚开始我并没有做TileMap的区块化,所以直接使用的RuleTile。所以只能一条路走到黑了,这是一个历史遗留问题。。。

因此,每个区块虽然实际上存储的数据是一个ushort[ChunkSize,ChunkSize],但是在渲染的时候,要在外部边缘加一圈:ushort[ChunkSize+2,ChunkSize+2]。每次逻辑更新区块的时候,都读取区块外边缘的真正相邻方块,然后把真正相邻方块映射成对应的代理方块,写入区块的外围格子。

液体模拟

CU的液体

CU的液体是格子液体而不是体素液体,每隔最多只能保存一种液体,液体的模拟使用的是元胞算法,拟真程度低,但实现简单。

CU的液体不是一个具体的GameObject,而是用Particle System批量化生成的粒子,每完成一个模拟步,所有液体粒子系统根据全局存储的液体位置数据触发一次批量粒子绘制。

其具体的数据表示为:

格值 液体 浮力增量 每 FixedUpdate 速度倍率
1 地下水 0.6 0.915
2 发光藻液 2.0 0.8
3 0.5 0.75
4 树液 1.0 0.8
5 脏水 0.6 0.915
6 岩浆 3.0 0.7

CU的液体生成

CU每次会随机选择一个不太接近顶端的位置,调用 FluidManager.StartFill()进行液体填充。其目的是找到一个格子数不超过maxFill的空洞,如果空洞大小超过这个值,则放弃这次填充。

在判断某个地方的空洞大小的时候,采用的算法是深度优先搜索(DSF),其使用的具体算法如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private void FloodFillDown(int x, int y, int maxCount)
{
if (x >= 1 && x < WorldGeneration.world.width && y >= 1 && y < WorldGeneration.world.height && WorldGeneration.world.GetBlock(new Vector2Int(x, y)) == 0 && !HasLiquid(new Vector2Int(x, y)) && !fillPos.Contains(new Vector2Int(x, y)))
{
floodFillCount++;
if (floodFillCount <= maxCount)
{
fillPos.Add(new Vector2Int(x, y));
FloodFillDown(x + 1, y, maxCount);
FloodFillDown(x - 1, y, maxCount);
FloodFillDown(x, y - 1, maxCount);
}
}
}

可以注意到,这里CU使用了递归的形式来完成这个算法,在maxCount较小的情况下是简洁的,但如果maxCount太大,会有栈溢出的风险。最好是使用循环而不是递归。

CU的液体模拟规则

其规则可以具体概况为:

  1. 当前格有液体且下方为空:与下方交换。
  2. 到世界底部附近:删除液体。
  3. 若地下水与发光藻上下接触:两格都变成发光藻。
  4. 不能向下时检查左右:
    • 两边都空,随机选一边;
    • 只有一边空,向该边交换;
    • 两边都堵塞,保持不动。

复现结果

复现结果