【YH】 如何做一条丝滑的绳子
大家在日常开发中,一定碰到过类似的需求。
比如说
-
1.做一条绳子,能够随手拖动,或者说具有物理效果,如《割绳子》
-
2.我想要做一个赛道,或者说路径图,如《祖玛》
-
3.我想让这个物品能弯曲,如《垫锅炒香肠》
-
4.扭曲的激光,雷电,扭曲的光线,如《雷电》,《God of Light》
图示例:
我之前就被这些需求,困扰过,且找不到解决方案。
今天回过头,再看这些,于是想整理一下,给自己交个卷。
需求拆分
以绳子为例,所有渲染效果其实都是可以分割为两块,数据与渲染。
-
1.获取并处理绳子的数据点
-
2.根据轨迹点,绘制出绳子
1.获取并处理绳子数据点
这里其实有很多种方案,
-
你可以手动指定一组坐标作为绳子的轨迹
-
也可以通过物理系统,通过刚体与关节,模拟出带有物理属性的绳子
-
如果不想使用物理,反向动力学模拟出的绳子效果,也是非常不错的
但是这里需要注意的是,绳子的数据点,需要足够平滑,不然绳子会非常生硬。
举一个极端的例子,你这个绳子只有4个点,并组成了一个正方形,绘制出来的效果肯定不会很好,很生硬。
这中间我们需要做一些数据处理,因为你是以一组点连起来,就算你需要就是个正方形的绳子,但是别忘记,绳子是有宽度的,这点很关键。
就比如 为什么拖尾在移动转折特别快的时候,效果很很怪,就是因为曲线不够平衡,采样重叠
所以,简单一点处理这个,我们可以在这中间进行插值,以达到曲线的效果。
虽然贝塞尔曲线同样也能达到这个效果,且控制更精准,但是这里只需要保证平滑性,所以这里我们选择使用插值。
这里我选用了 CatmullRomSpline (Catmull-ROM样条),会在生成平滑曲线的同时,也经过原有的点。
关键代码(CatmullRomSpline):
//获取插值
function cardinalSplineAt(p0, p1, p2, p3, tension, t) {
var t2 = t * t;
var t3 = t2 * t;
/*
* Formula: s(-ttt + 2tt - t)P1 + s(-ttt + tt)P2 + (2ttt - 3tt + 1)P2 + s(ttt - 2tt + t)P3 + (-2ttt + 3tt)P3 + s(ttt - tt)P4
*/
var s = (1 - tension) / 2;
var b1 = s * ((-t3 + (2 * t2)) - t); // s(-t3 + 2 t2 - t)P1
var b2 = s * (-t3 + t2) + (2 * t3 - 3 * t2 + 1); // s(-t3 + t2)P2 + (2 t3 - 3 t2 + 1)P2
var b3 = s * (t3 - 2 * t2 + t) + (-2 * t3 + 3 * t2); // s(t3 - 2 t2 + t)P3 + (-2 t3 + 3 t2)P3
var b4 = s * (t3 - t2); // s(t3 - t2)P4
var x = (p0.x * b1 + p1.x * b2 + p2.x * b3 + p3.x * b4);
var y = (p0.y * b1 + p1.y * b2 + p2.y * b3 + p3.y * b4);
return cc.v2(x, y);
};
// 根据index 获取数据点
function getControlPointAt(controlPoints, pos) {
var p = Math.min(controlPoints.length - 1, Math.max(pos, 0));
return controlPoints[p];
};
// 根据输入点数组,获取曲线
function catmullRomSpline(points: cc.Vec2[], alpha: number): cc.Vec2[] {
const result: cc.Vec2[] = [];
result.push(points[0]);
let _minSeg = 10
for (let i = 0; i < points.length; i++) {
let start = points[i]
let end = points[i + 1]
if (!end) continue
let dis = start.sub(end).mag()
let count = dis / _minSeg
let p0 = getControlPointAt(points, i - 1)
let p1 = getControlPointAt(points, i - 0)
let p2 = getControlPointAt(points, i + 1)
let p3 = getControlPointAt(points, i + 2)
for (let t = 0; t <= 1; t += 1 / count) {
const x = cardinalSplineAt(
p0,
p1,
p2,
p3,
alpha, t);
result.push(x);
// console.log(result.length, x)
}
}
// Add the last point
return result;
}
这样,我们就能拿到一条光滑的曲线了。
如果你还不满足,你可以看看参考文档,里面有更多的曲线生成方式,下载exe支持实时预览哦。
参考文档:
https://www.cnblogs.com/WhyEngine/p/4020294.html
渲染(绘制)绳子
我们拿到了一组光滑的曲线后,现在就来到了渲染环节了。
简单一点的话,直接使用Graphics,就可以直接出效果了,这也是之前我用的一种替代方案。
为了让绳子更逼真,我们需要使用各种不同的纹理,与cc.Sprite不同,这里的纹理是需要弯曲的。
cocos其实有个组件很贴近这个需求,MotionStreak (拖尾组件),以前我也确实是魔改MotionStreak去做的~~~
渲染本身是通过传递顶点数据与顶点绘制顺序给gpu,所以我们只需要自己组装一下数据就好。
这里需要将曲线数据再处理一下,因为现在只有一条线,而绘制图片,最少是需要4个顶点2个三角形,就比如 cc.Sprite。
这里我的做法是,
-
1.将曲线数据中的每个点,根据前后两个点计算当前点的方向
-
2.再根据方向与绳子宽度,获得绳子左右边界点的坐标。
曲线中的每个点,我们都能拿到3个数据,中心点,左右边界点。
这样就能保证绳子的宽度始终一致。
PS: 其实中心点(曲线的轨迹),已经失去作用了,绘制三角形采样只需要左右就行了,但我还是保留了。
好了,这样子我们就拿到了完整的顶点坐标数据了。
当然拿到这些,还不够,还需要去对纹理采样,需要计算采样的UV坐标。
这里,我提供了两种模式
-
1.拉伸: 按绳子长度,拉伸整个纹理
-
2.重复: 按绳子长度,重复整个纹理,与cc.Sprite的Tiled模式类似,理论上可以提供无限长的绳子
纹理的重复方式,将纹理重复到整个绳子长度,保证纹理不拉伸。
在处理重复模式的时候,需要记录当前点到初始点的距离,以确定采样的位置。
仅仅需要这两张图,就能达到效果,当然你也可以尝试更好的。
这里有个小坑, 比如当前点,采样的位置是 0.9,下一个点,因为已经超过了纹理长度,采样的位置是 0.1
这里采样就会很奇怪,在这个区间绘制的效果,就是反过来绘制回去。
所以这里需要特殊处理下,加两个中间点,采样为 1 与 0,且坐标要一样, 0.9 - 1 - 0 - 0.1,这样子中间1~0的部分就不会绘制了。
关键代码:
/** 创建绳子数据,根据绳子轨迹 */
createRopePointList(points) {
let repePointList: RopeLinePoint[] = []
for (let i = 0; i < points.length; i++) {
const curRopePoint = points[i] || cc.v2(0, 0);
const lastRopePoint = points[i - 1] || cc.v2(0, 0);
let nextRopePoint = points[i + 1] || cc.v2(0, 0);
let radian = Math.atan2(nextRopePoint.y - lastRopePoint.y, nextRopePoint.x - lastRopePoint.y);
if (i == points.length - 1) {
radian = Math.atan2(curRopePoint.y - lastRopePoint.y, curRopePoint.x - lastRopePoint.x)
nextRopePoint = curRopePoint
}
const dir = nextRopePoint.sub(lastRopePoint)
const dis = dir.mag()
const unitDx = dir.x / dis;
const unitDy = dir.y / dis;
// 获取绳子左右两点位置,保持宽度一致
let left = cc.v2(curRopePoint.x + this.ropeWidth * unitDy, curRopePoint.y - this.ropeWidth * unitDx)
let right = cc.v2(curRopePoint.x - this.ropeWidth * unitDy, curRopePoint.y + this.ropeWidth * unitDx)
// 计算当前绳子距离
let ropePoint = new RopeLinePoint(curRopePoint, left, right)
ropePoint.calculateDis(repePointList[repePointList.length - 1])
let index = Math.floor(ropePoint.len / this.ropeTexture.height)
let lastIndex = repePointList[repePointList.length - 1] ? Math.floor(repePointList[repePointList.length - 1].len / this.ropeTexture.height) : 0
repePointList.push(ropePoint)
if (index != lastIndex) {
ropePoint.repeatEnd = true // 这里是为了在uv采样时,0.9 ~ 0.1,中间加一个 1 与 0,过渡
let tempRopePoint = new RopeLinePoint(curRopePoint, left, right)
tempRopePoint.calculateDis(repePointList[repePointList.length - 1])// 计算距离
repePointList.push(tempRopePoint)
tempRopePoint.repeatStart = true // 这里是为了在uv采样时,0.9 ~ 0.1,中间加一个 1 与 0,过渡
}
}
return repePointList
}
总结
其实效果也不是那么完美,比如说 180 度转折的时候,处理还是有点问题。
如果精益求精的话,可以再考虑在,边界点重叠的时候,对边界点进行去重,再重新组装数据。
或者说,对左右点组成的曲线数据,再做一次曲线生成,保证光滑。
这里尝试过对边界点进行处理,但是还是不太满意,而且会多出几步处理步骤。
不过,这个方案,已经能满足大部分需求了。
大家再遇到类似的需求,可以试试这个方案,看看能不能帮到你哦
这里我也提供自己已经实现的一个示例,有需要的可以看看:
体验链接:https://cdn.yy0001.com/Game/Show/RopeDemo/1.0.0/index.html