1. 啰嗦前言
做毕设其中需要一个功能就是使用笔刷在地图刷绘制出河流之类的东西。如果只是绘制贴图或者顶点颜色,得到一张图片还是不难实现的。但是根据我后面功能的需求,我需要得到的是一个多边形顶点的数据,而不是一张图片。
第一时间我是想到《城市:天际线》(Cities: Skylines)中绘制地区的时候。就是用笔刷绘制的,然后看起来像是多边形的样子,也许是我想要的东西。(图片上b站随便找了个视频截图的,打开一个存档太费时了)
然后我就一顿搜索,找到了两篇提问,
How does the districts painting work in cities skyline?
How to make field painter like cities skylines districts?
有人说是用布尔运算,有人说使用行进方阵(Marching Squares,我也不知道它的准确翻译是啥)。
然后我想了一下,布尔运算感觉还是不太好实现(真香预警)。然后Marching Squares的话,他的确可以做出在绘制颜色之后,可视化多边形轮廓的功能,但是我想要的是一组连续的顶点数据。这个不太能实现。(或许一顿操作之后,把各个轮廓数据连接起来也是可以)
下图是一个Marching Squares算法视频中的截图。
题外话,之前看过Sebastian Lague大神用 Marching Cubes(看样子是Marching Squares的三维版本)做过一个很有意思的东西:Coding Adventure: Marching Cubes
然后我还不死心,去Unity的论坛上发了个提问,一个好心的哥们回答我说,可以尝试用OpenCV来将一个图形转换成多边形,然后AssetStore中也有一个OpenCV for Unity的包。然后我也问了问我同学,OpenCV的多边形拟合也许是能实现这个功能的。
然后我就打算去试试,可是…AssetStore里面那个免费版的OpenCV的包导进来报错,现在也忘了是啥错了,反正当时也没咋解决。一方面之前还没接触过OpenCV,用的话还得重新学点东西(其实问题也不大)。另一方面感觉一个小功能还要导入一个OpenCV,有点大材小用的感觉。于是这个方法就还是暂时搁置了。
最终我还是打算去尝试一下布尔运算。笔刷作为一个多边形,绘制的结果作为一个多边形,就用这两个多边形不断地做运算就好了。
可是我不想从头写算法了,之前那个路网的算法就几乎花掉了我半个月?而且现在都还没完善。于是上Github上找了找,一开始找到了Geri-Borbas/Unity.Library.eppz.Geometry,看简介感觉适合用的。但是折腾了半天愣是没搞明白怎么用,于是放弃了。
后来又找到BrightBitGAMES/MartinezClipper,这个比较简单明了,稍微研究了他的测试用例就知道怎么用了。
最后在这个基础上实现了我要的功能,下面是一个简单的演示,
视频前半段是直接把原项目改了改的实现的笔刷效果,后半段是我用在毕设项目上之后做的小Demo,当然还是未完成品。
同时还是能看到这个功能是有BUG的,有时候会突然的消失掉一部分的顶点,具体原因是啥我也不知道,也许是算法有问题?或者实现的有错误?还是我用得不对?暂时还没有去研究,凑合着还能用,后面有时间再去翻源码看看。
2. 实现
根据BrightBitGAMES/MartinezClipper的说明,他是参照一篇论文A new algorithm for computing Boolean operations on polygons(原文的连接打开404,我这就换上百度里搜到的)实现的,复杂度是O((n + k) log n)。
然后算法具体怎么实现我就不管了,我这里考虑怎么在这个基础上实现用笔刷绘制多边形的功能。
思路 就是两个多边形,一个是结果,一个是笔刷。当笔刷的位置移动的足够多的时候,将笔刷多边形与结果多边形进行一次布尔运算,运算的结果又作为结果多边形,这样子就可以实现笔刷的功能了。
先放上原项目中,测试脚本里面的核心部分,就是如何计算两个多边形的布尔运算结果。
Triangulator tri = new Triangulator();
MartinezClipping clippingAlgo = new MartinezClipping();
Polygon subject = new Polygon();
Polygon clipper = new Polygon();
SimpleClosedPath subjectContour = new SimpleClosedPath(subject);
SimpleClosedPath clipperContour = new SimpleClosedPath(clipper);
foreach (Vector2 vertex in subjectVerts) subjectContour.Add(vertex);
foreach (Vector2 vertex in clipperVerts) clipperContour.Add(vertex);
subject.Add(subjectContour);
clipper.Add(clipperContour);
subject.ComputeHoles();
clipper.ComputeHoles();
clippingAlgo.subject = subject;
clippingAlgo.clipper = clipper;
Polygon result = clippingAlgo.Compute(operationType);
result.ComputeHoles();
大体思路不难理解理解,大概就是创建一个布尔运算器,创建两个多边形,然后根据运算类型调用函数,得出结果。
然后我就是把上面的两个多边形,一个改为结果,一个改为笔刷,然后再添点别的东西就好了,如下代码所示。
using UnityEngine;
using System.Collections.Generic;
using BrightBit.Geometry;
public class Test : MonoBehaviour
{
[SerializeField] OperationType operationType = OperationType.DIFFERENCE;
public Transform brush;
public List<Transform> brushTrans = new List<Transform>();
private Polygon theResult;
private Vector3 lastPosi;
public float distance = 50;
public bool isPainting;
public float minDistance = 1;
// 在鼠标位置绘制一个多边形并将进行布尔运算
private void DoIt()
{
MartinezClipping clippingAlgo = new MartinezClipping();
Polygon subject = theResult;
Polygon clipper = new Polygon();
SimpleClosedPath clipperContour = new SimpleClosedPath(clipper);
// 获取笔刷的多边形顶点数据
foreach (var tran in brushTrans)
{
clipperContour.Add(new Vector2(tran.position.x, tran.position.y));
}
// 如果已经存在结果多边形 则在此基础上进行运算
if (subject != null)
{
clipper.Add(clipperContour);
clipper.ComputeHoles();
clippingAlgo.subject = subject;
clippingAlgo.clipper = clipper;
theResult = clippingAlgo.Compute(operationType);
theResult.ComputeHoles();
}
// 如果不存在结果多边形 说明这是第一笔,直接将这一个笔刷作为结果多边形
else
{
theResult = new Polygon();
theResult.Add(clipperContour);
theResult.ComputeHoles();
}
}
void Update()
{
// 获取鼠标位置 并应用到笔刷上
brush.position = Camera.main.ScreenToWorldPoint(Input.mousePosition);
// 鼠标控制 左键是绘制,右键是擦除
if (Input.GetMouseButtonDown(0))
{
isPainting = true;
operationType = OperationType.UNION;
}
if (Input.GetMouseButtonUp(0))
{
isPainting = false;
theResult.OptimizePolygon(minDistance);
}
if (Input.GetMouseButtonDown(1))
{
isPainting = true;
operationType = OperationType.DIFFERENCE;
}
if (Input.GetMouseButtonUp(1))
{
isPainting = false;
theResult.OptimizePolygon(minDistance);
}
// 判断是否满足移动的条件,满足就绘制一个笔刷形状
if (isPainting && Vector3.Distance(Input.mousePosition,lastPosi) > distance)
{
lastPosi = Input.mousePosition;
DoIt();
}
}
private void OnDrawGizmos()
{
// 可视化笔刷多边形
Gizmos.color = Color.red;
for (int i = 0; i < brushTrans.Count; i++)
{
Gizmos.DrawLine(brushTrans[i].position, brushTrans[(i + 1)%brushTrans.Count].position);
}
// 可视化结果多边形
if (theResult == null) return;
for (int i = 0; i < theResult.GetNumPaths(); i++)
{
// 是洞就用红色,是外边就用绿色
Gizmos.color = theResult.paths[i].IsHole() ? Color.red : Color.green;
for (int j = 0; j < theResult.paths[i].points.Count; j++)
{
Gizmos.DrawLine(theResult.paths[i].points[j], theResult.paths[i].points[(j+1)%theResult.paths[i].points.Count]);
}
}
}
}
在鼠标抬起的时候,会执行一个教OptimizePolygon(float)
的函数,这个是我自己另外加的,他是在加在Polygon
类里面,实现是:
/// <summary>
/// 优化掉很小的边
/// </summary>
public void OptimizePolygon(float minDistance)
{
foreach (var path in paths)
path.OptimizePath(minDistance);
ComputeHoles();
}
而OptimizePath(float)
又是我写的另一个在SimpleClosedPath
类中的函数,实现是:
/// <summary>
/// 优化掉很小的边
/// </summary>
public void OptimizePath(float minDistance)
{
for (int i = 0; i < points.Count; i++)
{
if (points.Count < 4) return;
// 如果这个点和下一个点的距离果断
if (Vector2.Distance(points[i], points[(i + 1) % points.Count]) < minDistance)
{
points.RemoveAt((i + 1) % points.Count);
i--;
}
}
}
为什么要进行这一步操作呢?这是因为如果单纯的将笔刷不停的与结果进行布尔运算,肯可能得到一个狗啃的边界,如下图。
不太美观是一方面,另一方面是这种边界往往两个顶点相距会太小,变化又多。对于我项目重后续的实现是非常不利的。
我就想了一个及其简单的方法来优化他。就是遍历每一条路径的所有顶点,如果两个顶点之间的距离太近,就把下一个顶点删掉,直到这个顶点和下一个顶点距离达到要求。
优化完了之后大概就是这样的一个效果(下图并不是由上图优化得来的,是参照上图重新画的图)。
这个方法很简单,效果也不错。不过在一些极端情况下是有可能优化完了之后出现两条不同的路径相交的情况。不过几率不高,而且出现了基本肉眼可见,可以再用笔刷涂一涂就好了。
绘制好边界之后,就像上面代码的可视化部分一样,把边界读取出来使用就可以了。(前面视频后半段是又在这个基础上修改,应用到项目中去了,这里就不多说了)
然而这个算法还是有一个小小的缺陷(也许是我用的不对?)就是当我在内部洞中在画一个多边形的时候,内部的多边形也会被判断为洞。如下图所示,中间的红色边界本来也是一个外边。
也就是说多边形内部应该下图中的是蓝色区域。
暂时不知道咋解决,目前还只能是尽量避免这样的状况出现。