Cocos Creator中贝塞尔曲线绘制:从数学原理到TypeScript组件封装

发布时间:2026/7/9 21:56:52

Cocos Creator中贝塞尔曲线绘制:从数学原理到TypeScript组件封装 1. 项目概述为什么要在Cocos Creator里画贝塞尔曲线如果你正在用Cocos Creator开发游戏尤其是那些需要华丽技能特效、丝滑的引导线、或者自定义UI动效的项目那么“画线”这个功能你肯定绕不开。引擎自带的cc.Graphics组件能画直线、矩形、圆形但当你需要一条有弧度、能弯曲、看起来更自然的线条时直接画直线就显得生硬了。这时候贝塞尔曲线Bezier Curve就成了你的不二之选。我最近在一个卡牌游戏的技能连线特效里就深度用到了这个功能。需求是要在两张卡牌之间画一条带有流光效果的动态连接线这条线不能是直来直去的得有点弧度看起来才像“能量流动”而不是“钢筋焊接”。引擎没提供现成的drawBezier方法但这恰恰是锻炼我们底层图形API理解和数学应用能力的好机会。所以我花时间封装了一个稳定、易用的DrawBezierLine组件今天就把从原理到踩坑的全部经验用TypeScript的方式分享给你。这篇教程的目标很明确让你彻底掌握在Cocos Creator中使用TypeScript动态绘制任意阶贝塞尔曲线的方法。不止是给个代码我们会拆解贝塞尔曲线的数学原理讲清楚cc.Graphics的绘图机制并封装成一个可以挂在节点上、通过属性面板轻松配置的组件。无论你是想实现技能弹道、绘制飘带、还是创建自定义的图表这套方案都能直接拿去用。2. 核心原理与设计思路拆解2.1 贝塞尔曲线从数学公式到屏幕像素在动手写代码之前我们得先搞明白要画的是什么。贝塞尔曲线本质上是一系列控制点所定义的一条光滑参数曲线。对于游戏开发我们最常用的是二次贝塞尔曲线3个点和三次贝塞尔曲线4个点。二次贝塞尔曲线由起点P0、控制点P1和终点P2定义。你可以把控制点想象成一块磁铁它把原本应该是直线的线段“拉”弯了。它的公式是B(t) (1-t)² * P0 2*(1-t)*t * P1 t² * P2其中t从0到1变化。 在代码里我们就是通过循环改变t的值计算出曲线上一个个的点然后用直线段把这些点连起来。当分段足够多时看起来就是一条光滑的曲线。三次贝塞尔曲线更强大由起点P0、控制点1P1、控制点2P2和终点P3定义。它有两个控制点能创造出更复杂的S形或波浪形曲线。公式也更复杂一些B(t) (1-t)³ * P0 3*(1-t)²*t * P1 3*(1-t)*t² * P2 t³ * P3。Cocos Creator的cc.Graphics组件提供了moveTo,lineTo,quadraticCurveTo,bezierCurveTo等方法。但注意bezierCurveTo接受的是两个控制点和一个终点它画的是三次贝塞尔曲线。如果你需要画二次的得用quadraticCurveTo。但我们的目标是封装一个更通用的方法能够通过传入任意数量的控制点理论上支持任意阶来绘制曲线这就需要我们自己实现那个核心的插值计算函数。2.2 方案选型为什么选择扩展cc.Graphics看到需求你可能会想几种方案使用Spine或DragonBones动画画一条静态曲线当然可以但如果是需要动态改变起点终点、实时生成的曲线这条路就走不通了不够灵活。使用粒子系统模拟通过调整发射器和重力可以模拟出流动的线效果。这在表现“流光”、“轨迹”时非常出色但如果你需要一条精确的、可碰撞检测的、节点属性明确的“线”粒子系统就有点杀鸡用牛刀且控制精度不够。使用cc.Graphics动态绘制这是最直接、最灵活、性能也相对可控的方案。cc.Graphics是引擎底层的绘图API封装它允许你在每一帧清除并重画从而实现线条的动态变化。我们可以完全掌控曲线的形状、颜色、宽度甚至纹理通过fill和stroke。所以我们的核心设计思路就是创建一个TypeScript组件它继承自cc.Component内部持有一个cc.Graphics实例。组件暴露一系列属性如起点、控制点、终点、分段数、线条样式然后在onLoad或update中根据这些属性调用我们实现的贝塞尔曲线计算函数驱动cc.Graphics进行绘制。这样做的好处是高内聚所有绘图逻辑封装在一个组件内干净利落。易使用开发者只需要在编辑器里拖拽节点配置几个cc.Vec2或cc.Node类型的属性就能看到曲线。可复用组件可以做成Prefab在任何需要的地方实例化。性能可控通过控制分段数segments可以在视觉效果和性能之间取得平衡。分段数越多曲线越光滑但计算和绘制的顶点也越多。2.3 TypeScript在其中的优势为什么强调用TypeScript因为在处理向量计算、参数传递时类型系统能极大减少错误。比如当你声明一个属性property(cc.Vec2) startPos: cc.Vec2 cc.v2(0, 0);编辑器会给你强类型的提示和自动补全。在计算贝塞尔点时如果你错误地传了一个cc.Node给期望cc.Vec2的函数TypeScript编译器会在你写代码的时候就报错而不是等到运行时才出现诡异的画面或者直接崩溃。这对于图形计算这种细节繁多的任务来说是巨大的效率和安全提升。3. 核心工具函数贝塞尔曲线点计算在开始写组件之前我们先实现最核心的数学工具函数。这个函数不依赖任何Cocos Creator的运行时API是纯数学计算因此我们可以把它放在一个单独的工具类里比如BezierUtils.ts。3.1 实现通用贝塞尔曲线计算函数这里我们采用经典的德卡斯特里奥算法De Casteljau‘s algorithm。这个算法理解起来很直观它通过递归的线性插值来求取曲线上点的位置非常适合编程实现并且天然支持任意数量的控制点即任意阶贝塞尔曲线。// BezierUtils.ts import { Vec2 } from cc; export class BezierUtils { /** * 根据德卡斯特里奥算法计算贝塞尔曲线上的点 * param points 控制点数组至少包含两个点起点和终点 * param t 曲线参数范围 [0, 1] * returns 曲线上对应参数t的点 */ public static getBezierPoint(points: Vec2[], t: number): Vec2 { if (points.length 2) { console.error(Bezier points must have at least 2 points (start and end).); return points[0] || Vec2.ZERO; } // 复制一份点数组避免修改原数组 let tmpPoints points.map(p Vec2.clone(p)); // 德卡斯特里奥算法核心步骤 for (let i 1; i points.length; i) { for (let j 0; j points.length - i; j) { // 线性插值tmpPoints[j] (1-t) * tmpPoints[j] t * tmpPoints[j1] tmpPoints[j].x (1 - t) * tmpPoints[j].x t * tmpPoints[j 1].x; tmpPoints[j].y (1 - t) * tmpPoints[j].y t * tmpPoints[j 1].y; } } // 经过层层插值最后tmpPoints[0]就是曲线上t位置的点 return tmpPoints[0]; } /** * 获取贝塞尔曲线上一系列等间隔的点用于绘制 * param points 控制点数组 * param segments 分段数量越大曲线越平滑 * returns 曲线上的一系列点 */ public static getBezierPoints(points: Vec2[], segments: number): Vec2[] { const result: Vec2[] []; if (segments 1) segments 1; for (let i 0; i segments; i) { const t i / segments; // 将[0, segments]等比例映射到[0, 1] result.push(this.getBezierPoint(points, t)); } return result; } }代码解读与注意事项算法理解想象你有4个点P0, P1, P2, P3。第一轮循环你计算出P0-P1线段上t位置的点AP1-P2上的点BP2-P3上的点C。第二轮计算A-B上的点DB-C上的点E。第三轮计算D-E上的点F。这个F就是最终三次贝塞尔曲线在t时刻的点。我们的循环就是模拟了这个过程。性能考虑这个算法的时间复杂度是O(n²)其中n是控制点数量。对于二次3点或三次4点贝塞尔曲线这个计算量微乎其微。但如果你异想天开要用几十个控制点那就得考虑性能了。不过游戏里99%的场景用不到那么高阶的曲线。Vec2.clone的重要性在函数开头我们复制了传入的点数组。这是因为Vec2是引用类型直接操作会修改外部传入的原始数据这通常不是调用者期望的。这是一个非常容易忽略的细节也是TypeScript强类型提醒不了你的“逻辑坑”。3.2 针对二次和三次贝塞尔曲线的优化版本虽然通用函数很强大但针对最常用的二次和三次曲线我们可以提供更高效、更语义化的专用函数。它们直接套用数学公式避免了递归循环效率更高。// 在BezierUtils类中继续添加 export class BezierUtils { // ... 之前的通用函数 /** * 计算二次贝塞尔曲线点 (P0, P1, P2) * param p0 起点 * param p1 控制点 * param p2 终点 * param t 参数 */ public static getQuadraticBezierPoint(p0: Vec2, p1: Vec2, p2: Vec2, t: number): Vec2 { const x (1 - t) * (1 - t) * p0.x 2 * (1 - t) * t * p1.x t * t * p2.x; const y (1 - t) * (1 - t) * p0.y 2 * (1 - t) * t * p1.y t * t * p2.y; return new Vec2(x, y); } /** * 计算三次贝塞尔曲线点 (P0, P1, P2, P3) * param p0 起点 * param p1 控制点1 * param p2 控制点2 * param p3 终点 * param t 参数 */ public static getCubicBezierPoint(p0: Vec2, p1: Vec2, p2: Vec2, p3: Vec2, t: number): Vec2 { const t2 t * t; const t3 t2 * t; const mt 1 - t; const mt2 mt * mt; const mt3 mt2 * mt; const x mt3 * p0.x 3 * mt2 * t * p1.x 3 * mt * t2 * p2.x t3 * p3.x; const y mt3 * p0.y 3 * mt2 * t * p1.y 3 * mt * t2 * p2.y t3 * p3.y; return new Vec2(x, y); } }在实际项目中我建议同时保留通用函数和专用函数。在组件内部可以根据传入的控制点数量自动选择最优的计算方式。这样既保证了灵活性又在常用场景下获得了最佳性能。4. DrawBezierLine组件的完整实现有了核心算法我们现在来构建主角——DrawBezierLine组件。这个组件将提供编辑器配置界面并负责在每一帧或当参数改变时重新绘制曲线。4.1 组件属性定义与编辑器配置首先我们定义组件需要哪些属性。一个好的组件应该让美术或策划同学也能在编辑器里轻松调整。// DrawBezierLine.ts import { _decorator, Component, Graphics, Color, Vec2, Node, Enum } from cc; import { BezierUtils } from ./BezierUtils; // 导入我们刚才写的工具类 const { ccclass, property, executeInEditMode } _decorator; // 定义一个枚举方便选择使用世界坐标还是本地坐标 enum CoordinateType { WORLD, LOCAL } ccclass(DrawBezierLine) executeInEditMode // 重要让组件在编辑器模式下也能执行方便实时预览 export default class DrawBezierLine extends Component { // 线条控制点 // 使用Node引用可以方便地在场景中拖拽控制点Node来改变曲线形状 property(Node) startNode: Node | null null; property(Node) endNode: Node | null null; property([Node]) // 数组属性用于多个控制点 controlNodes: Node[] []; // 或者也可以直接使用Vec2坐标对于固定位置或动态计算的曲线很有用 property(Vec2) startPos: Vec2 new Vec2(-200, 0); property(Vec2) endPos: Vec2 new Vec2(200, 0); property([Vec2]) controlPoints: Vec2[] [new Vec2(-100, 100), new Vec2(100, 100)]; property({ type: Enum(CoordinateType), tooltip: 控制点坐标是基于世界空间还是本地空间 }) coordinateType: CoordinateType CoordinateType.LOCAL; // 线条样式 property lineWidth: number 5; property(Color) strokeColor: Color Color.WHITE.clone(); property segments: number 20; // 曲线分段数影响平滑度 // 内部引用 private _graphics: Graphics | null null; private _dirty: boolean true; // 脏标记用于优化避免每帧都重绘 onLoad() { // 尝试获取当前节点上的Graphics组件如果没有则自动添加一个 this._graphics this.getComponent(Graphics); if (!this._graphics) { this._graphics this.addComponent(Graphics); } this._graphics.lineWidth this.lineWidth; this._graphics.strokeColor this.strokeColor; this.redraw(); // 初始绘制一次 } // 提供一个公共方法允许外部脚本触发重绘 public redraw() { this._dirty true; } }属性设计心得两种控制点定义方式提供了Node引用和Vec2坐标两种方式。Node引用在编辑器里拖拽非常直观适合需要跟随场景中其他物体运动的曲线如技能连线。Vec2坐标则更灵活适合通过代码动态计算的曲线。我们的绘制函数需要能同时处理这两种输入。executeInEditMode装饰器这是实现编辑器实时预览的关键。加上它update和onLoad等方法在编辑器状态下也会被调用。这样你在属性面板上修改任何一个值曲线都会立即更新所见即所得。脏标记_dirty这是一个重要的优化技巧。我们不一定需要在每一帧update都重绘曲线。只有当控制点位置改变、或者线条样式改变时才需要重绘。通过设置_dirty标记我们可以在update中检查它为真时才执行耗时的计算和绘制。4.2 核心绘制逻辑的实现接下来我们实现从收集控制点坐标到调用工具函数计算路径最后用Graphics画出来的完整流程。// 在DrawBezierLine类中继续添加方法 export default class DrawBezierLine extends Component { // ... 之前的属性和onLoad update(deltaTime: number) { // 如果在编辑器模式或者有动态节点检查控制点是否移动了 if (CC_EDITOR || this.startNode || this.endNode || this.controlNodes.length 0) { // 这里可以添加逻辑检查Node的位置是否发生变化如果变了就设置 this._dirty true // 为了简化示例我们假设在编辑器下或节点被引用时每帧都检查重绘 // 实际项目可以更精细地管理比如监听节点的position变化事件 } if (this._dirty this._graphics) { this._drawBezierLine(); this._dirty false; } } private _drawBezierLine() { const g this._graphics!; g.clear(); // 清除上一帧的画布 // 1. 收集所有控制点的最终坐标Vec2数组 const points: Vec2[] this._collectControlPoints(); if (points.length 2) { console.warn(DrawBezierLine: Need at least 2 points to draw a line.); return; } // 2. 计算贝塞尔曲线上的离散点 const curvePoints BezierUtils.getBezierPoints(points, this.segments); // 3. 使用Graphics绘制折线用足够多的分段模拟曲线 g.moveTo(curvePoints[0].x, curvePoints[0].y); for (let i 1; i curvePoints.length; i) { g.lineTo(curvePoints[i].x, curvePoints[i].y); } g.stroke(); // 执行描边绘制 } private _collectControlPoints(): Vec2[] { const points: Vec2[] []; // 优先使用Node引用的坐标 let startPos this.startPos; let endPos this.endPos; let controlVectors [...this.controlPoints]; if (this.startNode) { startPos this._getNodePosition(this.startNode); } if (this.endNode) { endPos this._getNodePosition(this.endNode); } if (this.controlNodes.length 0) { controlVectors this.controlNodes.map(node this._getNodePosition(node)); } // 组装点数组[起点, ...控制点, 终点] points.push(startPos); points.push(...controlVectors); points.push(endPos); // 如果指定了本地坐标需要将所有点转换到当前节点的本地空间 if (this.coordinateType CoordinateType.LOCAL this.node.parent) { // 这里简化处理假设我们收集的点已经是相对于某个参考系。 // 更严谨的做法是如果startPos/endPos是世界坐标则需要用this.node.parent.inverseTransformPoint进行转换。 // 本例中我们约定直接使用Vec2属性时输入的就是本地坐标。 // 对于Node我们获取的已经是世界坐标需要转换。 const worldToLocal (worldPoint: Vec2) { const p this.node.parent!.inverseTransformPoint(new Vec3(worldPoint.x, worldPoint.y, 0)); return new Vec2(p.x, p.y); }; if (this.startNode) points[0] worldToLocal(points[0]); if (this.endNode) points[points.length - 1] worldToLocal(points[points.length - 1]); for (let i 1; i points.length - 1; i) { if (this.controlNodes[i - 1]) { points[i] worldToLocal(points[i]); } } } return points; } private _getNodePosition(node: Node): Vec2 { // 获取节点的世界坐标并返回Vec2 const worldPos node.worldPosition; return new Vec2(worldPos.x, worldPos.y); } // 添加一些属性变化的回调以便在编辑器里修改属性时实时重绘 property set _lineWidth(value: number) { this.lineWidth value; if (this._graphics) { this._graphics.lineWidth value; } this.redraw(); } get _lineWidth() { return this.lineWidth; } // 类似地可以为strokeColor, segments等属性添加setter来触发重绘 // 注意在编辑器中数组controlPoints和Node引用的变化无法通过简单的setter捕获 // 需要依赖executeInEditMode和update中的检查或者使用属性监视器。 }绘制逻辑的关键点clear()的必要性cc.Graphics的绘制是叠加的。如果你不调用clear()那么每一帧画的新线条都会和旧的叠加在一起最终变成一团乱麻。所以每次重绘前必须清除。用折线模拟曲线cc.Graphics没有直接画参数化曲线的APIquadraticCurveTo和bezierCurveTo是特例。我们的策略是先用BezierUtils.getBezierPoints计算出曲线上足够多的点然后用moveTo和lineTo把这些点连起来。当segments足够大比如20以上时人眼就看不出折线感了。坐标空间转换这是最容易出错的地方。我们的组件挂载在某个节点A上Graphics的绘图原点(0,0)是节点A的本地原点。如果我们传入的控制点是世界坐标比如来自其他节点的worldPosition直接画出来肯定不对。所以_collectControlPoints方法里根据coordinateType做了转换。务必理解Graphics.draw的坐标是相对于其所在节点的本地坐标。4.3 在编辑器中的使用与实时预览现在你可以将DrawBezierLine.ts脚本拖到场景中的一个空节点上。这个节点最好有一个干净的变换位置为0缩放为1旋转为0这样本地坐标更容易理解。基本使用在属性面板你可以直接修改startPos,endPos和controlPoints数组点击数组旁边的号添加元素的x,y值。修改后因为executeInEditMode的作用曲线会立即在场景中显示出来。使用Node控制在场景中创建几个空节点分别命名为StartPoint, ControlPoint1, ControlPoint2, EndPoint。然后将这些节点分别拖拽到组件属性的startNode,controlNodes,endNode对应的插槽中。这时你拖动这些节点曲线就会实时变化这对于调整曲线形状来说极其方便。调整样式修改lineWidth线宽、strokeColor颜色、segments分段数试试调到5和50看看区别。实操心得编辑器下的性能开启executeInEditMode后组件的update会在编辑器下每帧执行。如果你的场景中有很多这样的曲线可能会拖慢编辑器。一个优化技巧是在update里加一个判断if (CC_EDITOR !this.node.activeInHierarchy) return;或者提供一个editorPreview的布尔属性让用户选择是否在编辑器下实时更新。5. 高级功能与性能优化一个基础的绘制组件已经完成了。但在实际项目中我们往往需要更多功能和更好的性能。5.1 添加虚线、渐变与动画效果cc.Graphics的能力不止于画实线。通过一些技巧我们可以实现更丰富的效果。绘制虚线原理是不再连续地lineTo而是沿着计算好的曲线点间隔一段距离画一小段实线。private _drawDashedLine(curvePoints: Vec2[], dashLength: number 10, gapLength: number 5) { const g this._graphics!; g.moveTo(curvePoints[0].x, curvePoints[0].y); let accumulatedLength 0; let isDrawing true; // 当前是否在画线阶段 for (let i 1; i curvePoints.length; i) { const prev curvePoints[i - 1]; const curr curvePoints[i]; const segmentDist Vec2.distance(prev, curr); const dir new Vec2(curr.x - prev.x, curr.y - prev.y).normalize(); let distCovered 0; while (distCovered segmentDist) { const need isDrawing ? (dashLength - accumulatedLength) : (gapLength - accumulatedLength); const canTake Math.min(need, segmentDist - distCovered); const endPoint new Vec2( prev.x dir.x * (distCovered canTake), prev.y dir.y * (distCovered canTake) ); if (isDrawing) { g.lineTo(endPoint.x, endPoint.y); } else { g.moveTo(endPoint.x, endPoint.y); // 移动到间隙的终点不画线 } distCovered canTake; accumulatedLength canTake; if (accumulatedLength (isDrawing ? dashLength : gapLength)) { isDrawing !isDrawing; // 切换画线/间隙状态 accumulatedLength 0; } } } g.stroke(); }然后在_drawBezierLine中根据一个isDashed属性来决定调用_drawDashedLine还是普通画线。实现线条动画流光效果一种常见效果是让线条看起来像有光点从头流到尾。这需要用到Graphics的stroke和fill结合或者更高级的使用cc.Material和着色器。这里提供一个简单的基于Graphics的模拟方法绘制一条粗的、半透明的底色线再在上面绘制一段不断变短的“高光线”。public progress: number 1.0; // 进度0到1 private _drawAnimatedLine(curvePoints: Vec2[]) { const g this._graphics!; g.clear(); // 1. 绘制完整的底色线 g.strokeColor this.strokeColor.clone(); g.strokeColor.a 100; // 设置较低的透明度 g.lineWidth this.lineWidth; this._drawSimpleLine(curvePoints); // 假设这个方法画整条线 g.stroke(); // 2. 绘制进度高光线 g.strokeColor this.strokeColor.clone(); g.strokeColor.a 255; g.lineWidth this.lineWidth * 0.8; g.moveTo(curvePoints[0].x, curvePoints[0].y); const totalPoints curvePoints.length; const endIndex Math.floor(this.progress * (totalPoints - 1)); for (let i 1; i endIndex; i) { g.lineTo(curvePoints[i].x, curvePoints[i].y); } g.stroke(); }在update中逐渐改变progress的值就可以实现流动效果。当然性能最好的流光效果还是通过自定义着色器Shader来实现但这超出了本篇的范围。5.2 性能优化与批量绘制如果你需要在同一帧绘制大量贝塞尔曲线比如成百上千条为每条曲线都创建一个DrawBezierLine组件和Graphics实例对性能是灾难性的。这时需要考虑批量绘制。思路创建一个专门的BatchBezierLineManager单例组件。所有需要画线的逻辑都向这个管理器提交绘制请求包含控制点、样式等数据。管理器在lateUpdate中收集所有请求然后在一个或少数几个Graphics组件上通过一次clear()和多次moveTo/lineTo/stroke调用来完成所有线条的绘制。这能极大减少Draw Call绘制调用。不过cc.Graphics的API调用本身也有开销。对于极度密集的曲线绘制如模拟毛发、流体可能需要直接使用引擎的cc.MeshRenderer和自定义顶点数据来生成线条网格这是最高效但也最复杂的方式。对于绝大多数游戏场景我们封装的这个组件已经绰绰有余。5.3 常见问题排查与调试技巧在实际使用中你可能会遇到以下问题曲线不显示/位置不对检查Graphics组件确保节点上确实有Graphics组件并且lineWidth 0strokeColor的alpha 0。检查坐标空间这是最常出问题的地方。确认你的控制点坐标是相对于哪个坐标系。如果你用了startNode画出来的线却在天边那很可能是世界坐标没有正确转换到本地坐标。在_collectControlPoints函数里打印一下计算出的points数组的坐标值看看是否在预期范围内比如在屏幕中心附近。检查控制点数量确保points数组至少有两个点。如果controlNodes数组为空且controlPoints数组也为空那么points里就只有起点和终点画出来是一条直线这是符合预期的。编辑器下修改属性不实时更新确认组件脚本头部有executeInEditMode。确认你修改的是脚本里用property声明的公共属性。直接修改私有属性_graphics是不会触发更新的。对于controlPointsVec2数组这样的复杂属性在编辑器中修改其子元素x,y值后可能需要点击一下属性面板其他地方或按回车键变更才会提交。你也可以在update里强制设置this._dirty true来让编辑器下每帧重绘性能会差些。线条锯齿严重增加segments分段数的值。20是基础对于长曲线或需要特写的曲线可以增加到50甚至100。检查游戏项目的抗锯齿设置。在Cocos Creator的项目设置里可以开启抗锯齿Anti-aliasing。性能开销大降低segments。对于远处的小曲线10段可能就够了。如果不是每帧都需要变化比如静态的背景装饰曲线可以在绘制完成后将_dirty设为false并只在控制点改变时例如在startNode节点的position变化事件回调中调用this.redraw()才重绘。对于完全静态的曲线还有一个“终极”优化绘制一次后将Graphics组件的enabled设为false。它渲染的网格数据会被保留并渲染但update里的逻辑不会再执行。当你需要改变时再启用并重绘。最后分享一个调试小技巧在_drawBezierLine函数的开头用g.circle(points[i].x, points[i].y, 5)把每个控制点画成一个小圆。这样你就能清晰地看到你定义的控制点到底在什么位置对于理解曲线形状和排查坐标问题非常有帮助。调试完后记得注释掉这些画圆的代码。

相关新闻