游戏开发技术论坛

 找回密码
 立即注册
搜索
查看: 1453|回复: 0

手把手教你物理挖洞与涂抹地形

[复制链接]

1万

主题

1万

帖子

3万

积分

版主

Rank: 7Rank: 7Rank: 7

积分
36572
发表于 2020-5-13 15:47:03 | 显示全部楼层 |阅读模式
效果预览

1.gif

实现步骤

整体思路是先使用 PolyBool 计算多边形,接着使用 cc.PhysicsChainCollider 将多边形围起来,最后使用 cc.Graphics 将整个地形绘制出来。

引入 PolyBool

PolyBool是什么?对多边形(并集,交集,差,异或)进行运算。(Boolean operations on polygons (union, intersection, difference, xor).)

2.jpg

前往 https://github.com/voidqk/polybooljs 下载。并作为插件脚本。

3.jpg

这个仓库有个 PR 提供了一个声明文件,因为我用的是 TypeScript ,我就把它拿来改改用了。

4.jpg

参考这个库的示例,里面有一个 regions 三维数组记录多边形的信息。

5.jpg

我们也用个三维数组记录当前多边形的形状的数据,并初始化为一个长方形吧!

  1. private _regions: number[][][] = [];
  2. reset() {
  3.     this._regions = [
  4.         [[-480, -320], [-480, 250], [480, 250], [480, -320]]
  5.     ];
  6. }
复制代码

添加物理链条

先在场景中添加物理节点。

6.jpg

为这个节点初始化一些 cc.PhysicsChainCollider ,并开启物理引擎,顺便开启物理调试模式,方便看效果。

  1. //onLoad() {
  2. cc.director.getPhysicsManager().enabled = true;
  3. cc.director.getPhysicsManager().debugDrawFlags = 1;
  4. for (let index = 0; index < 100; index++) {
  5.     const c = this.node_dirty.addComponent(cc.PhysicsChainCollider);
  6.     c.loop = true;
  7.     c.enabled = false;
  8. }
复制代码

接着根据_regions的数值,把points传给物理链条。

  1. // draw() {
  2. const chains = this.node_dirty.getComponents(cc.PhysicsChainCollider);
  3. chains.forEach((c) => {
  4.     c.enabled = false;
  5. })
  6. for (let index = 0; index < this._regions.length; index++) {
  7.     const pos = this._regions[index];
  8.     let poly = chains[index];
  9.     if (!poly) {
  10.         poly = this.node_dirty.addComponent(cc.PhysicsChainCollider);
  11.         poly.loop = true;
  12.     }
  13.     poly.points.length = 0;
  14.     poly.points = pos.map((v, i) => {
  15.         const v2 = cc.v2(v[0], v[1])
  16.         return v2;
  17.     });
  18.     poly.enabled = true;
  19. }
复制代码

看看效果。

7.jpg

开始挖洞!

监听一个节点的触摸事件。

  1. // onLoad() {
  2. this.node_dirty.on(cc.Node.EventType.TOUCH_START, this._touchMove, this);
  3. this.node_dirty.on(cc.Node.EventType.TOUCH_MOVE, this._touchMove, this);
  4. 在触摸点周围圈一个多边形(类似画一个圈,不清楚的话可以参考上一篇中的把圆围成一个圈),并使用差集的方法计算新的多边形,计算后再重新画物理链条。

  5. // const DIG_RADIUS = 50;
  6. // const DIG_FRAGMENT = 12;
  7. // _touchMove(touch: cc.Touch) {
  8. const regions = [[]];
  9. const pos = this.node_dirty.convertToNodeSpaceAR(touch.getLocation());

  10. const count = DIG_FRAGMENT;
  11. for (let index = 0; index < count; index++) {
  12.     const r = 2 * Math.PI * index / count;
  13.     const x = pos.x + DIG_RADIUS * Math.cos(r);
  14.     const y = pos.y + DIG_RADIUS * Math.sin(r);
  15.     regions[0].push([x, y]);
  16. }

  17. const result = PolyBool.difference({
  18.     regions: this._regions,
  19.     inverted: false
  20. }, {
  21.     regions,
  22.     inverted: false
  23. });
  24. this._regions = result.regions;
  25. this.draw();
复制代码

看看效果。

8.gif

填充颜色

先画一个多边形,只需先移动到起点,然后逐一划线,就可以了。

  1. // private _drawPoly(ctx, poly) {
  2. poly.forEach((pos, i) => {
  3.     if (i === 0)
  4.         ctx.moveTo(pos.x, pos.y);
  5.     else
  6.         ctx.lineTo(pos.x, pos.y);
  7.     ctx.close();
  8. });
复制代码

填充思路是基于 canvas 中的 evenodd 规则。

9.jpg

与上面不一样的地方是,我是计算这个多边形被几个大的多边形包围,当是偶数的时候填充泥土的颜色,当是奇数时,填充背景的颜色。

10.jpg

当然,需要注意的是,计数越大的要越后画,这样才能达到最终效果。

  1. // draw() {
  2. const enabled_chains_points=[]
  3. for (let index = 0; index < this._regions.length; index++) {
  4.     // 省略与上面相同 draw
  5.     enabled_chains_points[index] = poly.points;
  6. }
  7. this.graphics.clear(true);
  8. const enabled_chains_points_sort = enabled_chains_points.map((curPoly, curPoly_i) => {
  9.     const count = enabled_chains_points.reduce((pre, nextPoly, nextPoly_i) => {
  10.         if ((curPoly_i != nextPoly_i)) {
  11.             const length = curPoly.length;
  12.             for (let i = 0; i < length; ++i) {
  13.                 const p0 = curPoly[i];
  14.                 if (!cc.Intersection.pointInPolygon(p0, nextPoly))
  15.                     return pre;
  16.             }
  17.             return pre + 1;
  18.         }
  19.         return pre;
  20.     }, 0);

  21.     return { curPoly, count };
  22. }).sort((a, b) => {
  23.     return a.count - b.count;
  24. })
  25. enabled_chains_points_sort.forEach(({ curPoly, count }) => {
  26.     this.graphics.fillColor = count % 2 === 0 ? cc.Color.ORANGE : cc.Color.BLACK;
  27.     this._drawPoly(this.graphics, curPoly);
  28.     this.graphics.fill();
  29. })
复制代码

看看效果如何!

11.gif

优化

物理引擎

调低物理引擎的步长和处理的迭代次数。

  1. // onLoad() {
  2. // 开启物理步长的设置
  3. cc.director.getPhysicsManager().enabledAccumulator = true;
  4. // 物理步长,默认 FIXED_TIME_STEP 是 1/60
  5. cc.PhysicsManager.FIXED_TIME_STEP = 1 / 30;
  6. // 每次更新物理系统处理速度的迭代次数,默认为 10
  7. cc.PhysicsManager.VELOCITY_ITERATIONS = 8;
  8. // 每次更新物理系统处理位置的迭代次数,默认为 10
  9. cc.PhysicsManager.POSITION_ITERATIONS = 8;
复制代码

多边形的顶点

计算的过程中,可能会带有小数,我们可以把所有的点都优化到整数范围。

  1. //const DIG_OPTIMIZE_SIZE = 1;
  2. private _optimizePoint(point) {
  3.     return [Math.floor(point[0] * DIG_OPTIMIZE_SIZE) / DIG_OPTIMIZE_SIZE, Math.floor(point[1] * DIG_OPTIMIZE_SIZE) / DIG_OPTIMIZE_SIZE];
  4. }
复制代码

DIG_OPTIMIZE_SIZE也可以改大一点,就是把图中红色的点都算作灰色的点。

2.jpg

多边形的边

需要剔除一些长度为0的边。

去除一些共线的边,这边用到了向量的叉乘,关于向量的点乘和叉乘介绍可以参考之前的这一篇文章。

  1. private _optimizeRegions() {
  2.     const regions = [];
  3.     for (let index = 0; index < this._regions.length; index++) {
  4.         const pos = this._regions[index];
  5.         const newPos = [];
  6.         pos.forEach((p, i) => {
  7.             p = this._optimizePoint(p);
  8.             const p_pre = this._optimizePoint(pos[(i - 1 + pos.length) % pos.length]);
  9.             const p_next = this._optimizePoint(pos[(i + 1) % pos.length]);
  10.             const vec1 = cc.v2(p[0] - p_pre[0], p[1] - p_pre[1]);
  11.             const vec2 = cc.v2(p_next[0] - p[0], p_next[1] - p[1]);
  12.             if (vec1.lengthSqr() != 0 && vec2.lengthSqr() != 0 && vec1.cross(vec2) != 0) {
  13.                 newPos.push(p);
  14.             }
  15.         })

  16.         if (newPos.length > 2) {
  17.             regions.push(newPos);
  18.         }
  19.     }
  20.     this._regions = regions;
  21. }
复制代码

触摸平滑连续

当手指滑动时,如果 touch_move 的抓取的两个点距离比较大的话,就会出现不平滑的情况。

3.jpg

这里用到向量的点乘帮助我们解决这个问题,不清楚向量计算参考之前的这一篇文章。

算出两个触摸点和各自边的向量,与移动的方向向量关系,可以确定整个多边形的点。

4.jpg

当两个偏移点距离太小我们就忽略。

  1. // private _touchMove(touch: cc.Touch) {
  2. const regions = [[]];
  3. const pos = this.graphics.node.convertToNodeSpaceAR(touch.getLocation());
  4. const delta = touch.getDelta();
  5. const count = DIG_FRAGMENT;
  6. if (delta.lengthSqr() < 5) {
  7.     for (let index = 0; index < count; index++) {
  8.         const r = 2 * Math.PI * index / count;
  9.         const x = pos.x + DIG_RADIUS * Math.cos(r);
  10.         const y = pos.y + DIG_RADIUS * Math.sin(r);
  11.         regions[0].push(this._optimizePoint([x, y]));
  12.     }
  13. } else {
  14.     const startPos = pos.sub(delta);
  15.     for (let index = 0; index < count; index++) {
  16.         const r = 2 * Math.PI * index / count;
  17.         let vec_x = DIG_RADIUS * Math.cos(r);
  18.         let vec_y = DIG_RADIUS * Math.sin(r);
  19.         let x, y;
  20.         if (delta.dot(cc.v2(vec_x, vec_y)) > 0) {
  21.             x = pos.x + vec_x;
  22.             y = pos.y + vec_y;
  23.         } else {
  24.             x = startPos.x + vec_x;
  25.             y = startPos.y + vec_y;
  26.         }
  27.         regions[0].push(this._optimizePoint([x, y]));
  28.     }
  29. }
复制代码

调用 PolyBool 的优化

在这个库 https://github.com/voidqk/polybooljs 中提到了更高级的用法。

5.jpg

  1. // private _touchMove(touch: cc.Touch) {
  2. const seg1 = PolyBool.segments({
  3.     regions: this._regions,
  4.     inverted: false
  5. });
  6. const seg2 = PolyBool.segments({
  7.     regions,
  8.     inverted: false
  9. });
  10. const comb = PolyBool.combine(seg1, seg2);
  11. const result = PolyBool.polygon(PolyBool.selectDifference(comb));
复制代码

其他

另一种实现思路

首先创建一堆刚体铺满所有泥土,在监听到触摸事件后,移除对应位置的刚体。

算法参考

多边形算法我没有深究其实现,如果要做到更好的优化,可能需要自己去实现其中的算法,可以把上面的优化点融入到算法中。以下是一些相关算法的参考资料。


http://www.cs.ucr.edu/~vbz/cs230papers/martinez_boolean.pdf
https://hal.inria.fr/inria-00517670/document
https://www.sciencedirect.com/sc ... i/S0965997813000379

可能的问题

在web端测试感觉比较流畅,未在native端测试,也没有在微信小游戏端做测试。

小结

可能有其他更好的方案去实现这个功能,如果你有更好的方案,欢迎分享!欢迎加入qq交流群(859642112)一起讨论,群里收集了一些我认为还不错的书籍和资料。

效果预览

1 (1).gif


实现步骤

整体思路是,先用 Clipper 去计算多边形,接着用 poly2tri 将多边形分割成多个三角形,最后用多边形刚体填充。

2.jpg

引入第三方库

Clipper

Clipper 是一个强大的用于多边形计算的运算库。前往下面这个地址下载,并作为插件导入 creator 。

http://jsclipper.sourceforge.net

为什么这次不用 物理挖洞-实现篇 中的 PolyBool 呢?

经测试发现 Clipper 的效率会比 PolyBool 高,并且 Clipper 内置了一个方法可以明确知道哪些多边形是洞。

3.jpg

poly2tri

poly2tri 是一个把多边形分割成三角形的库。下载地址如下:

https://github.com/r3mi/poly2tri.js

poly2tri 的使用有一些要注意的,大致就是不能有重复的点,不能有相交的形状。

4.jpg

初始化准备

先在场景中添加一个物理节点,一个绘图组件(用来画图)。

5.jpg

接着把物理引擎打开,监听触摸事件。

  1. // onLoad() {
  2. // 多点触控关闭
  3. cc.macro.ENABLE_MULTI_TOUCH = false;
  4. cc.director.getPhysicsManager().enabled = true;

  5. this.node_dirty.on(cc.Node.EventType.TOUCH_START, this._touchMove, this);
  6. this.node_dirty.on(cc.Node.EventType.TOUCH_MOVE, this._touchMove, this);
  7. // }
复制代码

扩展多边形碰撞的组件

为了方便管理多边形碰撞组件,新建一个脚本 PhysicsPolygonColliderEx.ts。

初始化

因为物理碰撞体需要物理刚体,我们可以加一些限制,并把这个菜单指向物理碰撞体的菜单中。

  1. const { ccclass, property, menu, requireComponent } = cc._decorator;
  2. @ccclass
  3. @menu("i18n:MAIN_MENU.component.physics/Collider/PolygonEX-lamyoung.com")
  4. @requireComponent(cc.RigidBody)
  5. export default class PhysicsPolygonColliderEx extends cc.Component {
  6. }
复制代码

我们就可以在刚体节点中添加这个插件脚本了。

6.jpg

既然要用到多边形碰撞体,就定义一个多边形碰撞体数组。

  1. private _physicsPolygonColliders: cc.PhysicsPolygonCollider[] = [];
复制代码

因为 Clipper 中计算的结构是 {X,Y}。

所以加个变量记录多边形顶点信息。

  1. private _polys: { X: number, Y: number }[][] = [];
复制代码

因为不同的库用的数据结构不同,所以添加两个转换方法。

  1. private _convertVecArrayToClipperPath(poly: cc.Vec2[]) {
  2.     return poly.map((p) => { return { X: p.x, Y: p.y } });
  3. }

  4. private _convertClipperPathToPoly2triPoint(poly: { X: number, Y: number }[]) {
  5.     return poly.map((p) => { return new poly2tri.Point(p.X, p.Y) });
  6. }
复制代码

加一个初始化数据的接口。

  1. init(polys: cc.Vec2[][]) {
  2.     this._polys = polys.map((v) => { return this._convertVecArrayToClipperPath(v) });
  3. }
复制代码

计算多边形

参考 Clipper 中的使用例子,写一个多边形差集调用。

  1. //polyDifference(poly: cc.Vec2[]) {
  2. const cpr = new ClipperLib.Clipper();
  3. const subj_paths = this._polys;
  4. const clip_paths = [this._convertVecArrayToClipperPath(poly)]
  5. cpr.AddPaths(subj_paths, ClipperLib.PolyType.ptSubject, true);
  6. cpr.AddPaths(clip_paths, ClipperLib.PolyType.ptClip, true);
  7. const subject_fillType = ClipperLib.PolyFillType.pftEvenOdd;
  8. const clip_fillType = ClipperLib.PolyFillType.pftEvenOdd;
  9. const solution_polytree = new ClipperLib.PolyTree();
  10. cpr.Execute(ClipperLib.ClipType.ctDifference, solution_polytree, subject_fillType, clip_fillType);
  11. const solution_expolygons = ClipperLib.JS.PolyTreeToExPolygons(solution_polytree);
  12. this._polys = ClipperLib.Clipper.PolyTreeToPaths(solution_polytree);
复制代码

分割多边形并添加刚体

参考 poly2tri 中的使用,写一个多边形分割成三角形的调用。记得要把上面返回的数据转成 poly2tri 中可以使用的数据格式。

  1. // polyDifference(poly: cc.Vec2[]) {
  2. let _physicsPolygonColliders_count = 0;
  3. for (const expolygon of solution_expolygons) {
  4.     const countor = this._convertClipperPathToPoly2triPoint(expolygon.outer);
  5.     const swctx = new poly2tri.SweepContext(countor);
  6.     const holes = expolygon.holes.map(h => { return this._convertClipperPathToPoly2triPoint(h) });
  7.     swctx.addHoles(holes);
  8.     swctx.triangulate();
  9.     const triangles = swctx.getTriangles();
  10.     // 逐一处理三角形...
  11. }
复制代码

然后再逐一处理分割好的三角形,修改 cc.PhysicsPolygonCollider 的 points 属性。
  1. // 逐一处理三角形...
  2. for (const tri of triangles) {
  3.     let c = this._physicsPolygonColliders[_physicsPolygonColliders_count];
  4.     if (!c) {
  5.         //没有的话就创建
  6.         c = this.addComponent(cc.PhysicsPolygonCollider);
  7.         c.friction = 0;
  8.         c.restitution = 0;
  9.         this._physicsPolygonColliders[_physicsPolygonColliders_count] = c;
  10.     }
  11.     c.points = tri.getPoints().map((v, i) => {
  12.         return cc.v2(v.x, v.y)
  13.     });
  14.     c.apply();
  15.     _physicsPolygonColliders_count++;
  16. }
  17. // 剩余不要用的多边形清空。
  18. this._physicsPolygonColliders.slice(_physicsPolygonColliders_count).forEach((v => {
  19.     if (v.points.length) {
  20.         v.points.length = 0;
  21.         v.apply();
  22.     }
  23. }));
复制代码

绘制泥土

只要在遍历三角形的时候逐点画线就行了。

  1. if (i === 0) ctx.moveTo(v.x, v.y);
  2. else ctx.lineTo(v.x, v.y);
复制代码

添加命令队列

为了不让每帧计算量过多,添加一个命令队列。
  1. private _commands: { name: string, params: any[] }[] = [];

  2. pushCommand(name: string, params: any[]) {
  3.     this._commands.push({ name, params });
  4. }
复制代码

在每次更新的时候,取出几个命令去执行。

8.jpg

  1. lateUpdate(dt: number) {
  2.     if (this._commands.length) {
  3.         // 每帧执行命令队列
  4.         for (let index = 0; index < 2; index++) {
  5.             const cmd = this._commands.shift();
  6.             if (cmd)
  7.                 this[cmd.name](...cmd.params);
  8.             else
  9.                 break;
  10.         }
  11.     }
  12. }
复制代码

涂抹地形

整体思路和 物理挖洞-优化篇 和 物理挖洞-实现篇 差不多。不清楚的话,可以回看这两篇文章。

这次不同的是,加了一个涂抹步长控制,当涂抹间隔太小的时候,就不参与计算。
  1. private _touchStartPos: cc.Vec2;
  2. private _touchStart(touch: cc.Touch) {
  3.     this._touchStartPos = undefined;
  4.     this._touchMove(touch);
  5. }

  6. private _touchMove(touch: cc.Touch) {
  7.     const regions: cc.Vec2[] = [];
  8.     const pos = this.graphics.node.convertToNodeSpaceAR(touch.getLocation());

  9.     const count = DIG_FRAGMENT;
  10.     if (!this._touchStartPos) {
  11.         // 画一个圆(其实是多边形)
  12.         for (let index = 0; index < count; index++) {
  13.             const r = 2 * Math.PI * index / count;
  14.             const x = pos.x + DIG_RADIUS * Math.cos(r);
  15.             const y = pos.y + DIG_RADIUS * Math.sin(r);
  16.             regions.push(this._optimizePoint([x, y]));
  17.         }
  18.         this._touchStartPos = pos;
  19.     } else {
  20.         const delta = pos.sub(this._touchStartPos);
  21.         // 手指移动的距离太小的话忽略
  22.         if (delta.lengthSqr() > 25) {
  23.             // 这里是合并成一个顺滑的图形  详细上一篇文章
  24.             const startPos = this._touchStartPos;
  25.             for (let index = 0; index < count; index++) {
  26.                 const r = 2 * Math.PI * index / count;
  27.                 let vec_x = DIG_RADIUS * Math.cos(r);
  28.                 let vec_y = DIG_RADIUS * Math.sin(r);
  29.                 let x, y;
  30.                 if (delta.dot(cc.v2(vec_x, vec_y)) > 0) {
  31.                     x = pos.x + vec_x;
  32.                     y = pos.y + vec_y;
  33.                 } else {
  34.                     x = startPos.x + vec_x;
  35.                     y = startPos.y + vec_y;
  36.                 }
  37.                 regions.push(this._optimizePoint([x, y]));
  38.             }
  39.             this._touchStartPos = pos;
  40.         }
  41.     }

  42.     if (regions.length)
  43.         this.polyEx.pushCommand('polyDifference', [regions, this.graphics]);
  44. }

  45. private _touchEnd(touch: cc.Touch) {
  46.     this._touchStartPos = undefined;
  47. }
复制代码

作者:lamyoung
来源:微信公众号“白玉无冰”
原地址:https://mp.weixin.qq.com/s/0vqPNDUPWH760INGBNTeXA



您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

作品发布|文章投稿|广告合作|关于本站|游戏开发论坛 ( 闽ICP备17032699号-3 )

GMT+8, 2023-2-4 09:33

Powered by Discuz! X3.4

Copyright © 2001-2021, Tencent Cloud.

快速回复 返回顶部 返回列表