原作:Keith Peters https://www.bit-101.com/blog/2022/11/coding-curves/
译者:池中物王二狗(sheldon)
blog: http://cnblogs.com/willian/
源码:github: https://github.com/willian12345/coding-curves
曲线艺术编程系列第三章
在这一篇中我们将关注如何绘制圆弧,圆和椭圆。(结束前再聊聊正切相关的)。
很可能已经内建了一些这样的绘制功能。比如,虽然 HTML Canvas api 没有直接画圆的函数, 但它有一个 arc 和 ellipse 椭圆的方法间接实现。
如何手动实现这些功能很有用。在某时,某地,某平台上总会用到的。
首先,我们先聚焦于圆弧与圆。可以说圆弧是圆的一部分,也可以说圆是圆弧延展 360 度而成。从哪个方向开始探索都可以,但对我更钟意从圆开始再进入圆弧部分。
我使用的绘图 api 内 y 轴与标准笛卡尔坐标系是相反的。负的向上,正的下向。
有别于数学和科学中使用的笛卡尔坐标系, 这在图形绘制 api 中很常见。它与 Processing, HTML Canvas, Cairographics, .net graphics 以及其它很多图形库一样。
有些 api 确实使用笛卡尔坐标系,当角度值为正向增长时代表逆时针方向。
但有些如 pygame, 是混用,y 是为正时是向下,但正向角度旋转时却是逆时针的。
这会影响角度的测量。0 度指向东方。在笛卡尔坐标系中,正向角度转动是逆时针,负向角度转动移动是顺时针。在 Y 轴反转的系统中,正好相反,在这章中我不会再强调这里的差别。我们这里会重新实现一些已内建的绘图 api 函数。
在你学完圆弧这一章节后,如果你想在笛卡尔坐标系下创建圆弧,圆和椭圆的函数也会很简单。
圆的定义一般来说会像这样:“与给定的一个中心点等距的一堆点的集合” ,但当你真的想画一个圆的时候,发现这并没什么卵用。我并不需要一堆无限的点。我们仅需要足够多的点用短线串起来形成圆。
你也见过“圆方程”类似 x2+y2=r^2(译者注:这里是x平方+y平方= r 平方,可表示圆周上任意一点)。 当你尝试想画圆,这好好像也没啥用。
然后你得到了下面这样的参数方程
x = a + r * cos(t) y = b + r * sin(t)
这里 a 和 b 是圆的中心点, r 是半径, t 是范围参数变量从 0 到 2 * PI。
这里才开始有点儿用。我们可以定义一个圆心点和半径然后跑一个循环,从 0 至 2 * PI 从而得到一堆点,然后用线将这些点连接起来。
还是得提醒你,这里展示的是伪代码。关于伪代码的问题请参考第一章内的说明内容。
width = 600 height = 600 canvas(width, height) cx = width / 2 cy = height / 2 radius = 250 for (t = 0; t < PI * 2; t += 0.01) { lineTo(cx + cos(t) * radius, cy + sin(t) * radius) } closePath() stroke()
取决于你所使用的绘图 api, 在你使用 lineTo 之前,你很可能需要用 moveTo 来开头。我信你自己能搞定。这很简单。cx, cy 和 radius 就是上面公式中的 a, b 和 r 。 t 是用于循环的弧度。
注意,最后需要用 closePath 闭合一下。大多数绘图 api 都有这一特性。它会将最后一点与路径最起始点相连,将圆闭合起来。可能在你的平台上有一丢丢不一样,但大概应该如下图所示:
有一个问题,循环中 0.01 是我猜的一个大概值。如果你定的太大,比如 0.2 ,那么相当于你大踏步绕圆跳一圈,得到的结果将会是下面这样很粗糙的圆,看起来可不怎么润:
但你如果将增长值设的太小,那么系统将会做太多无用的绘制。圆周越大你就需要更多的线段让它看起来丝滑,半径越小需要的线段就越少。如果你用 0.01 这个常量用于递增,你将在每个圆上绘制 628 条线段。这在小圆上面可就太浪费了。
我上下而求索,找到了一个可用的方案,大至是 4.0 / radius。 在半径 5 至 200 范围内,比直接使用 0.01 这个递增值绘制时的线断少了一半,但看起来依然不错。
可能因系统而异,你自己尝试一下不同值看看。
有了这些, 我们可以把绘制圆封装成一个函数:
function circle(x, y, r) { res = 4 / r for (t = 0; t < PI * 2; t += res) { lineTo(x + cos(t) * r, y + sin(t) *r) } closePath() }
注意:我将 stroke 方法移在函数内移徐掉了,这样你可以用函数创建圆,可以选择描边或填充,或两者都用。如果你愿意,你可以进一步封装 strokeCircle 函数和 fillCircle 函数。下面是函数使用演示
width = 600 height = 600 canvas(width, height) circle(width / 2, height / 2, 200) stroke()
已经完成圆这部分了,现在我们可以在此基础上创建 arc 函数。你的编程语言可能有现成的 api ,但这不重要,我们再实现一遍。很简单和圆函数一样,但我们用 start 代表开始位置 0 和 end 代替结束位置的 2 * PI, 我们让调用者决定开始与结束位置。
不再解释了因为过于简单我直接抛出伪代码吧
function arc(x, y, r, start, end) { res = 4 / r for (t = start; t < end; t += res) { lineTo(x + cos(t) * r, y + sin(t) *r) } lineTo(x + cos(end) * r, y + sin(end) *r) }
你看简单吧,仅仅是将硬编码的开始与结束角度替换成参数传入的形式。当然,我移除了 closePath() 调用,取而代之的是最终 lineTo,这样更精确一点。
像下面一样使用它:
width = 600 height = 600 canvas(width, height) arc(width / 2, height / 2, 250, 0.5, 3.5) stroke()
结果会是:
有一丢丢问题。如果我将输入的开始与结束对调呢?
arc(width / 2, height / 2, 250, 3.5, 0.5)
它会立即结束循环,因为 3.5 已经大于 0.5了。啥也不会画出来。 我想要的是开始在 3.5 度,又绕回到 0.5 度像下面这样:
一种方式是我们只要保证结束的度数大于开始度数。我们可以判断如果结束度数小于开始度数,那么直接加上 2*PI,直到结束度数大于开始度数。
function arc(x, y, r, start, end) { while (end < start) { end += 2 * PI } res = 4 / r for (t = start; t < end; t += res) { lineTo(x + cos(t) * r, y + sin(t) *r) } lineTo(x + cos(e) * r, y + sin(e) *r) }
现在应该可以正常展示成上面期望的那样了。
还有一件事儿,我们总是假定用户绘制是顺时针的。我们应该让用户自己决定。
幸运地是这很容易实现,我们只需要传另一个参数 anticlockwise,如果值为 true,我们只需要交替 start 与 end 就可以了。
function arc(x, y, r, start, end, anticlockwise) { if (anticlockwise) { start, end = end, start } while (end < start) { end += 2 * PI } res = 4 / r for (t = start; t < end; t += res) { lineTo(x + cos(t) * r, y + sin(t) *r) } lineTo(x + cos(e) * r, y + sin(e) *r) }
如果你足够幸运,你使用的编程语言支持像这样变量交换:
start, end = end, start
如果不能直接像上面这样交替,那就只能用老方法了:
temp = start start = end end = temp
这样调用
arc(width / 2, height / 2, 250, 3.5, 0.5, false) stroke()
会给你期望的圆弧了:
下面这段代码
arc(width / 2, height / 2, 250, 3.5, 0.5, true) stroke()
则给你这样的圆弧:
两者都是开始于 3.5 度结束到 0.5,一条正的,一条按反的方式画。
正如开始时在圆形处提到过,这里我选择了正向角度顺时针为默认,这与笛卡尔坐标系不同。现在你知道在不同方向上如何绘制圆弧,你可以选择你一个喜欢的作为默认方向。
现在我们有了一个强大的圆弧函数,我们其实可以用它替换原来圆函数内的一些重复代码,像下面这样
function circle(x, y, r) { arc(x, y, r, 0, 2 * PI, true) }
从 0- 2*PI 可不就是一个圆么。
这里还有几个可以创建的函数如果你觉得它们有用的话。将圆弧首尾相连起(一条弦)形成的一个圆弧片。我们可以这样实现它,在绘制圆弧完毕时直接调用 closePath 函数,你使用的编程语言中肯定也有类似 closePath 的函数。
function segment(x, y, r, start, end, anticlockwise) { arc(x, y, r, start, end, anticlockwise) closePath() }
这个圆弧片,就是从 2.5 度至 4.5 度
一个扇形就是将圆弧用线段从中心点连接起来,我们可以调用 lineTo 至中心点,然后再 closePath
function sector(x, y, r, start, end, anticlockwise) { arc(x, y, r, start, end, anticlockwise) lineTo(x, y) closePath() }
用上面圆弧片一样的参数画的扇形:
现在你可以自己画饼图了。
在介绍椭圆之前,我想先奖励个正多边型。 这倒不是我认为多边形是曲线,但数学上来讲它可能真的是。 无论如何,反正来都来了,把它学了吧。
最开始我们讨论过分辨率,我们看到过低分辨率的圆边上看起来是一段一段的。你能看到圆是由单独一条条线段组成的。我们可以把这个 bug 点转化成一个可用的特征。如果我们将分辨率降低到足够低直到只有6个片段组成一个圆,我们就得到了一个六边形, 5 条线段就是 五边形,4 条就是方形,3 条就是三角形。 我们仅需要特别处理我们希望有多少条边,除以 2*PI 当作分辨率就可以成形了。
实现如下:
function polygon(x, y, radius, sides) { res = PI * 2 / sides for (i = 0; i < PI * 2; i+= res) { lineTo(x + cos(i) * radius, y + sin(i) * radius) } closePath() }
像下面这样调用:
polygon(300, 300, 250, 5) stroke()
得到一个五边形:
也许你想为这个多边形初始化一个特别的角度,你可以这样做
function polygon(x, y, radius, sides, rotation) { res = PI * 2 / sides for (i = 0; i < PI * 2; i+= res) { lineTo(x + cos(i + rotation) * radius, y + sin(i + rotation) * radius) } closePath() }
现在你可以这样使用
polygon(300, 300, 250, 5, 0.5) stroke()
得到了一个转了一点角度的多边形
试试传入不同的边数。
一个有趣的效果是创建一系列大小不同的多边形,每个多边形相应旋转一丢丢的角度:
angle = 0 for (r = 5; r <= 255; r += 10) { polygon(300, 300, r, 5, angle) stroke() angle += 0.05 }
效果如下:
也许有一点点离题,但你看图形里突然形成了5条新的曲线,能接受,能接受。
本文最后一部分,椭圆。
好的让我们来看看椭圆在维基百科中的定义...
环绕两个焦点的平面曲线,对于曲线上的所有点,到焦点的两个距离之和为常数
https://en.wikipedia.org/wiki/Ellipse
Em... 完全搞不懂,太数学化了。再看看这条解释...
椭圆是圆锥截面的封闭类型:沿圆锥与平面相交的平面曲线
https://en.wikipedia.org/wiki/Ellipse
还是一样不好理解,好吧,继续...
一个椭圆也可以用一个焦点和椭圆外一条叫做准线的线来定义:对于椭圆上的所有点,到焦点的距离和到准线的距离之比是一个常数。
https://en.wikipedia.org/wiki/Ellipse
好吧,还是不好懂,但就如之前那样,最终我们可以找到可用的参数方程,和之前的圆参数方程差不多
x = a + rx * cos(t) y = b + ry * sin(t)
这里,除了用 a 和 b 表示圆心点之外,还有 rx 和 ry 最简单就是把它们想象成 “radius x” 和“radius y”, 尽管这些名字可能让数学家鄙视。但对于一个未旋转的椭圆 rx 就是等于一半的椭圆宽,ry 等于一半的椭圆高。
所以我们可以编写下面这样一个函数
function ellipse(x, y, rx, ry) { res = 4.0 / max(rx, ry) for (t = 0; t < 2 * PI; t += res) { lineTo(x + cos(t) * rx, y + sin(t) * ry) } closePath() }
值得提醒的一点,是分辨率值, 我将 4.0 除以 rx 和 ry 中的最大值。你可以想想有没有更好的,但这对于我来说足够用了。现在你可以像下面这样调用它
ellipse(300, 300, 250, 150) stroke()
And get:
得到
有时候我写了就停不下来。接下来的这部分与创建曲线关系不大…,或者说也有一定相关。你看完后再看想想是不是有关系吧。 比起在圆周(或圆弧,多边形,椭圆)上每个点之间用线段相连,我们可以在这些点上画一些其它的形状。我们将增加曲线点之间的间隔以拥有足够空间容纳其它形状,不至于挤在一起,不然看起来太乱。事实上,多边形函数正好适用在这里。它可以让我们画一个由多个圆组成的圆形环。对于代码我就不解释了,你应该可以理解。
width = 600 height = 600 canvas(width, height) cx = width / 2 cy = height / 2 res = PI * 2 / 20 // to draw 20 circles for (t = 0; t < PI * 2; t += res) { x = cx + cos(t) * 200 y = cy + sin(t) * 200 circle(x, y, 20) stroke() }
有想过要不要写这一部分,跑题已经跑的够远的了,这一篇也足够的长了。
到目前为止都很基础,但希望足够有趣。从这章之后,我们将慢慢接触一点复杂的东西希望内容更加的有趣。
本章 Javascript 源码 https://github.com/willian12345/coding-curves/tree/main/examples/ch03
博客园: http://cnblogs.com/willian/
github: https://github.com/willian12345/