# 15 | 如何用极坐标系绘制有趣图案? 你好,我是月影。 在前面的课程中,我们一直是使用直角坐标系来绘图的。但在图形学中,除了直角坐标系之外,还有一种比较常用的坐标系就是极坐标系。 [![](https://static001.geekbang.org/resource/image/b6/31/b62312e2af6385ffcdb1d3dab4fdd731.jpeg "极坐标示意图")](http://zh.wikipedia.org) 你对极坐标系应该也不陌生,它是一个二维坐标系。与二维直角坐标系使用x、y分量表示坐标不同,极坐标系使用相对极点的距离,以及与x轴正向的夹角来表示点的坐标,如(3,60°)。 在图形学中,极坐标的应用比较广泛,它不仅可以简化一些曲线方程,甚至有些曲线只能用极坐标来表示。不过,虽然用极坐标可以简化许多曲线方程,但最终渲染的时候,我们还是需要转换成图形系统默认支持的直角坐标才可以进行绘制。在这种情况下,我们就必须要知道直角坐标和极坐标是怎么相互转换的。两个坐标系具体转换比较简单,我们可以用两个简单的函数,toPolar和fromPolar来实现,函数代码如下: ``` // 直角坐标影射为极坐标 function toPolar(x, y) { const r = Math.hypot(x, y); const θ= Math.atan2(y, x); return [r, θ]; } // 极坐标映射为直角坐标 function fromPolar(r, θ) { const x = r * cos(θ); const y = r * sin(θ); return [x, y]; } ``` 那今天,我们就通过参数方程结合极坐标,来绘制一些不太好用直角坐标系绘制的曲线,让你认识极坐标的优点,从而帮助你掌握极坐标的用法。 ## 如何用极坐标方程绘制曲线 在[第6节课](https://time.geekbang.org/column/article/256827)中,为了更方便地绘制曲线,我们用parametric.js函数实现了一个参数方程的绘图模块,它非常方便。所以在使用极坐标方程绘制曲线的时候,我们也要用到parametric.js函数。不过,在使用之前,我们还要对它进行扩展,让它支持坐标映射。这样,我们就可以写出对应的坐标映射函数,从而将极坐标映射为绘图需要的直角坐标了。 具体的操作就是,给parametric增加一个参数**rFunc**。rFunc是一个坐标映射函数,通过它我们可以将任意坐标映射为直角坐标,修改后的代码如下: ``` export function parametric(sFunc, tFunc, rFunc) { return function (start, end, seg = 100, ...args) { const points = []; for(let i = 0; i <= seg; i++) { const p = i / seg; const t = start * (1 - p) + end * p; const x = sFunc(t, ...args); const y = tFunc(t, ...args); if(rFunc) { points.push(rFunc(x, y)); } else { points.push([x, y]); } } return { draw: draw.bind(null, points), points, }; }; } ``` 看到这里,你可能想问,直角坐标和极坐标转换的函数,我们在一开始不是已经讲过了吗?为什么这里又要拓展一个rFunc参数呢?其实啊,开头我给出的函数虽然足够简单,但不够灵活,也不便于扩展。而先使用rFunc来抽象坐标映射,再把其他函数作为rFunc参数传给parametric,是一种更通用的坐标映射方法,它属于函数式编程思想。 说到这,我再多说几句。虽然函数式设计思想不是我们这个课程的核心,但它对框架和库的设计很重要,所以,我讲它也是希望你能通过这个例子,尽可能地理解代码中的精髓,学会使用最佳的设计方法和思路去解决问题,获得更多额外的收获,而不只是去理解眼前的基本概念。 那接下来,我们用极坐标参数方程画一个半径为200的半圆。在这里,我们把fromPolar作为rFunc参数传给parametric,就可以使用极坐标的参数方程来绘制图形了,代码如下所示。 ``` const fromPolar = (r, θ) => { return [r * Math.cos(θ), r * Math.sin(θ)]; }; const arc = parametric( t => 200, t => t, fromPolar, ); arc(0, Math.PI).draw(ctx); ``` 此外,我们还可以添加其他的极坐标参数方程来绘制更多曲线,比如玫瑰线、心形线或者双纽线。因为这些操作都比较简单,我就直接在下面给出代码了。 ``` const rose = parametric( (t, a, k) => a * Math.cos(k * t), t => t, fromPolar, ); rose(0, Math.PI, 100, 200, 5).draw(ctx, {strokeStyle: 'blue'}); const heart = parametric( (t, a) => a - a * Math.sin(t), t => t, fromPolar, ); heart(0, 2 * Math.PI, 100, 100).draw(ctx, {strokeStyle: 'red'}); const foliumRight = parametric( (t, a) => Math.sqrt(2 * a ** 2 * Math.cos(2 * t)), t => t, fromPolar, ); const foliumLeft = parametric( (t, a) => -Math.sqrt(2 * a ** 2 * Math.cos(2 * t)), t => t, fromPolar, ); foliumRight(-Math.PI / 4, Math.PI / 4, 100, 100).draw(ctx, {strokeStyle: 'green'}); foliumLeft(-Math.PI / 4, Math.PI / 4, 100, 100).draw(ctx, {strokeStyle: 'green'}); ``` 最终,我们能够绘制出如下的效果: ![](https://static001.geekbang.org/resource/image/47/fa/475905a6708e51yy234c640f292833fa.jpeg) 总的来说,我们看到,使用极坐标系中参数方程来绘制曲线的方法,其实和我们学过的直角坐标系中参数方程绘制曲线差不多,唯一的区别就是在具体实现的时候,我们需要额外增加一个坐标映射函数,将极坐标转为直角坐标才能完成最终的绘制。 ## 如何使用片元着色器与极坐标系绘制图案? 在前面的例子中,我们主要还是通过参数方程来绘制曲线,用Canvas2D进行渲染。那如果我们使用shader来渲染,又该怎么使用极坐标系绘图呢? 这里,我们还是以圆为例,来看一下用shader渲染,再以极坐标画圆的做法。你可以先尝试自己理解下面的代码,然后再看我后面的讲解。 ``` #ifdef GL_ES precision highp float; #endif varying vec2 vUv; vec2 polar(vec2 st) { return vec2(length(st), atan(st.y, st.x)); } void main() { vec2 st = vUv - vec2(0.5); st = polar(st); gl_FragColor.rgb = smoothstep(st.x, st.x + 0.01, 0.2) * vec3(1.0); gl_FragColor.a = 1.0; } ``` 在上面的代码中,我们先通过坐标转换公式实现polar函数。这个函数作用是将直角坐标转换为极坐标,相当于课程一开始,我们用JavaScript写的toPolar函数。这里有一个细节需要注意,我们使用的是GLSL内置的float atan(float, float)方法,对应的方法是Math.atan,而在JavaScript版本的toPolar函数中,对应的方法是Math.atan2。 然后,我们将像素坐标转换为极坐标:st = polar(st); ,转换后的st.x实际上是极坐标的r分量,而st.y就是极坐标的θ分量。 我们知道,对于极坐标下过极点的圆,实际上的r值就是一个常量值,对应圆的半径,所以我们取smoothstep(st.x, st.x + 0.01, 0.2),就能得到一个半径为0.2的圆了。这一步,我们用的还是上节课的**距离场**方法。只不过,在直角坐标系下,点到圆心的距离d需要用x、y平方和的开方来计算,而在极坐标下,点的极坐标r值正好表示了点到圆心的距离d,所以计算起来就比直角坐标系简单了很多。 其实,我们无论是用直角坐标还是极坐标来画图,方法都差不多。但是,一些其他的曲线用极坐标绘制会很方便。比如说,要绘制玫瑰线,我们就可以用以下代码: ``` void main() { vec2 st = vUv - vec2(0.5); st = polar(st); float d = 0.5 * cos(st.y * 3.0) - st.x; gl_FragColor.rgb = smoothstep(-0.01, 0.01, d) * vec3(1.0); gl_FragColor.a = 1.0; } ``` 这样,在画布上绘制出来的结果是三瓣玫瑰线: ![](https://static001.geekbang.org/resource/image/f7/8a/f73a97f5f742dd5d9c2c53b8ecf5908a.jpeg) 可能你还是会有疑问,为什么d = 0.5 \* cos(st.y \* 3.0) - st.x; 绘制出的图形就是三瓣玫瑰线的图案呢? 这是因为玫瑰线的极坐标方程r = a \* cos(k \* θ),所以玫瑰线上的所有点都满足0 = a \* cos(k \* θ) - r 这个方程式。如果我们再把它写成距离场的形式:d = a \* cos(k \* θ) - r。这个时候就有三种情况:玫瑰线上点的 d 等于 0;玫瑰线围出的图形外的点的 d 小于0,玫瑰线围出的图形内的点的 d 大于 0。 ![](https://static001.geekbang.org/resource/image/72/a9/7244ff9e7d36b8dd5ayy04e42430a5a9.jpeg) 因此,smoothstep(-0.01, 0.01, d) 能够将 d >= 0,也就是玫瑰线内的点选出来,这样也就绘制出了三瓣图形。 那玫瑰线有什么用呢?它是一种很有趣图案,我们只要修改u\_k的值,并且保证它是正整数,就可以绘制出不同瓣数的玫瑰线图案。 ``` uniform float u_k; void main() { vec2 st = vUv - vec2(0.5); st = polar(st); float d = 0.5 * cos(st.y * u_k) - st.x; gl_FragColor.rgb = smoothstep(-0.01, 0.01, d) * vec3(1.0); gl_FragColor.a = 1.0; } renderer.uniforms.u_k = 3; setInterval(() => { renderer.uniforms.u_k += 2; }, 200); ``` ![](https://static001.geekbang.org/resource/image/0f/18/0fa365713c9676219e72cd55073f7318.gif) 类似的图案还有花瓣线: ``` void main() { vec2 st = vUv - vec2(0.5); st = polar(st); float d = 0.5 * abs(cos(st.y * u_k * 0.5)) - st.x; gl_FragColor.rgb = smoothstep(-0.01, 0.01, d) * vec3(1.0); gl_FragColor.a = 1.0; } ``` 在u\_k=3的时候,我们可以得到如下图案: ![](https://static001.geekbang.org/resource/image/e5/47/e51fc1ca89f103b3f949477424f18047.jpeg) 有趣的是,它和玫瑰线不一样,u\_k的取值不一定要是整数。这让它能绘制出来的图形更加丰富,比如说我们可以取u\_k=1.3,这时得到的图案就像是一个横放的苹果。 ![](https://static001.geekbang.org/resource/image/ff/96/ff98434df974b610078f76aab6c96896.jpeg) 在此基础上,我们还可以再添加几个uniform变量,如u\_scale、u\_offset作为参数,来绘制出更多图形。代码如下: ``` varying vec2 vUv; uniform float u_k; uniform float u_scale; uniform float u_offset; void main() { vec2 st = vUv - vec2(0.5); st = polar(st); float d = u_scale * 0.5 * abs(cos(st.y * u_k * 0.5)) - st.x + u_offset; gl_FragColor.rgb = smoothstep(-0.01, 0.01, d) * vec3(1.0); gl_FragColor.a = 1.0; } ``` 当我们取u\_k=1.7,u\_scale=0.5,u\_offset=0.2时,就能得到一个横置的葫芦图案。 ![](https://static001.geekbang.org/resource/image/5b/74/5b303d4e6e7afd2f2bb61f10e9717574.jpeg) 如果我们继续修改 d 的计算方程,还能绘制出其他有趣的图形。 ``` void main() { vec2 st = vUv - vec2(0.5); st = polar(st); float d = smoothstep(-0.3, 1.0, u_scale * 0.5 * cos(st.y * u_k) + u_offset) - st.x; gl_FragColor.rgb = smoothstep(-0.01, 0.01, d) * vec3(1.0); gl_FragColor.a = 1.0; } ``` 比如,当继续修改 d 的计算方程时,我们可以绘制出花苞图案: ![](https://static001.geekbang.org/resource/image/8e/e9/8e87d4e5c76a06645860819474a25fe9.jpeg) 方法已经知道了,你可以在课后结合三角函数、abs、smoothstep,来尝试绘制一些更有趣的图案。如果有什么特别好玩的图案,你也可以分享出来。 ## 极坐标系如何实现角向渐变? 除了绘制有趣的图案之外,极坐标的另一个应用是**角向渐变**(Conic Gradients)。那角向渐变是什么呢?如果你对CSS比较熟悉,一定知道角向渐变就是以图形中心为轴,顺时针地实现渐变效果。而且新的 [CSS Image Values and Replaced Content](https://www.w3.org/TR/css-images-4/#conic-gradients) 标准 level4 已经添加了角向渐变,我们可以使用它来创建一个基于极坐标的颜色渐变,代码如下: ``` div.conic { width: 150px; height: 150px; border-radius: 50%; background: conic-gradient(red 0%, green 45%, blue); } ``` ![](https://static001.geekbang.org/resource/image/6b/3e/6bdeda39bcbff4b2269d641df8f9d33e.jpeg) 我们可以通过角向渐变创建一个颜色由角度过渡的元素。在WebGL中,我们可以通过极坐标用片元着色器实现类似的角向渐变效果,代码如下: ``` void main() { vec2 st = vUv - vec2(0.5); st = polar(st); float d = smoothstep(st.x, st.x + 0.01, 0.2); // 将角度范围转换到0到2pi之间 if(st.y < 0.0) st.y += 6.28; // 计算p的值,也就是相对角度,p取值0到1 float p = st.y / 6.28; if(p < 0.45) { // p取0到0.45时从红色线性过渡到绿色 gl_FragColor.rgb = d * mix(vec3(1.0, 0, 0), vec3(0, 0.5, 0), p / 0.45); } else { // p超过0.45从绿色过渡到蓝色 gl_FragColor.rgb = d * mix(vec3(0, 0.5, 0), vec3(0, 0, 1.0), (p - 0.45) / (1.0 - 0.45)); } gl_FragColor.a = 1.0; } ``` 如上面代码所示,我们将像素坐标转变为极坐标之后,st.y就是与x轴的夹角。因为polar函数里计算的atan(y, x)的取值范围是-π到π,所以我们在st.y小于0的时候,将它加上2π,这样就能把取值范围转换到0到2π了。 然后,我们根据角度换算出对应的比例对颜色进行线性插值。比如,比例在0%~45%之间,我们让颜色从红色过渡为绿色,那在45%到100%之间,我们让颜色从绿色过渡到蓝色。这样,我们最终就会得到如下效果: ![](https://static001.geekbang.org/resource/image/67/19/678161af2d8ff7ee9029bc9116cc0219.jpeg) 这个效果与CSS角向渐变得到的基本上一致,除了CSS角向渐变的起始角度是与Y轴的夹角,而shader是与X轴的夹角以外,没有其他的不同。这样,我们就可以在WebGL中利用极坐标系实现与CSS角向渐变一致的视觉效果了。 ## 极坐标如何绘制HSV色轮? 想要实现丰富的视觉效果离不开颜色,通过前面的课程,我们已经知道各种颜色的表示方法,为了更方便地调试颜色,我们可以进一步来实现色轮。什么是色轮呢?色轮可以帮助我们,把某种颜色表示法所能表示的所有颜色方便、直观地显示出来。 那在WebGL中,我们该怎么绘制HSV色轮呢?我们可以用极坐标结合HSV颜色来绘制它。 接下来,就让我们一起在片元着色器中实现它吧。实现的过程其实并不复杂,我们只需要将像素坐标转换为极坐标,再除以2π,就能得到HSV的H值。然后我们用鼠标位置的x、y坐标来决定S和V的值,完整的片元着色器代码如下: ``` #ifdef GL_ES precision highp float; #endif varying vec2 vUv; uniform vec2 uMouse; vec3 hsv2rgb(vec3 c){ vec3 rgb = clamp(abs(mod(c.x*6.0+vec3(0.0,4.0,2.0), 6.0)-3.0)-1.0, 0.0, 1.0); rgb = rgb * rgb * (3.0 - 2.0 * rgb); return c.z * mix(vec3(1.0), rgb, c.y); } vec2 polar(vec2 st) { return vec2(length(st), atan(st.y, st.x)); } void main() { vec2 st = vUv - vec2(0.5); st = polar(st); float d = smoothstep(st.x, st.x + 0.01, 0.2); if(st.y < 0.0) st.y += 6.28; float p = st.y / 6.28; gl_FragColor.rgb = d * hsv2rgb(vec3(p, uMouse.x, uMouse.y)); gl_FragColor.a = 1.0; } ``` 最终的效果如下图所示: ![](https://static001.geekbang.org/resource/image/39/bf/3998fd1107b9c234a28eeee1bb11fabf.gif) ## 圆柱坐标与球坐标 最后,我还想和你说说极坐标和圆柱坐标系以及球坐标系之间的关系。我们知道极坐标系是二维坐标系,如果我们将极坐标系延z轴扩展,可以得到圆柱坐标系。圆柱坐标系是一种三维坐标系,可以用来绘制一些三维曲线,比如螺旋线、圆内螺旋线、费马曲线等等。 [![](https://static001.geekbang.org/resource/image/1d/10/1d697208453d1a9557a659b1f9c5db10.jpeg "圆柱坐标系")](https://zh.wikipedia.org/) 因为极坐标系可以和直角坐标系相互转换,所以直角坐标系和圆柱坐标系也可以相互转换,公式如下: ![](https://static001.geekbang.org/resource/image/86/ef/86a5b3052493841f8ec648eb260b17ef.jpg) 从上面的公式中你会发现,我们只转换了x、y的坐标,因为它们是极坐标,而z的坐标因为本身就是直角坐标不用转换。因此圆柱坐标系又被称为**半极坐标系。** 在此基础上,我们还可以进一步将圆柱坐标系转为球坐标系。 [![](https://static001.geekbang.org/resource/image/a3/c1/a3ba1a1bb31090ffa90887907ee65ec1.jpeg "球坐标系")](https://zh.wikipedia.org) 同样地,圆柱坐标系也可以和球坐标系相互转换,公式如下: ![](https://static001.geekbang.org/resource/image/82/a9/8262a74b379f8433f1326e851ed579a9.jpg) 球坐标系在三维图形绘制、球面定位、碰撞检测等等可视化实现时都很有用,在后续的课程中,我们会有机会用到球坐标系,在这里你需要先记住它的转换公式。 ## 要点总结 这一节课,我们学习了一个新的坐标系统也就是极坐标系,并且理解了直角坐标系与极坐标系的相互转换。 极坐标系是使用相对极点的距离,以及与x轴正向的夹角来表示点的坐标。极坐标系想要转换为直角坐标系需要用到fromPolar函数,反过来需要用到toPolar函数。 那在具体使用极坐标来绘制曲线的时候,有两种渲染方式。第一种是用Cavans渲染,这时候,我们可以用到之前学过的parametric高阶函数,将极坐标参数方程和坐标映射函数fromPolar传入,得到绘制曲线的函数,再用它来执行绘制。这样,极坐标系就能实现直角坐标系不太好描述的曲线了,比如,玫瑰线、心形线等等。 第二种是使用shader渲染,一般的方法是先将像素坐标转换为极坐标,然后使用极坐标构建距离场并着色。它能实现更多复杂的图案。 除了绘图,使用极坐标还可以实现角向渐变和HSV色轮。角向渐变通常可以用在构建饼图,而HSV色轮一般用在颜色可视化和择色交互等场合里。 此外,你还需要了解圆柱坐标、球坐标与直角坐标系的相互转换。在后续课程里,我们会使用圆柱坐标或球坐标来处理三维图形,到时候它们会非常有用。 ## 小试牛刀 1. 用极坐标绘制小图案时,我们绘制了苹果和葫芦的图案,但它们是横置的。你可以试着修改它们,让它们的方向变为正向吗?具体怎么做呢? 2. 在角向渐变的例子中,CSS角向渐变是与Y轴的夹角,而使用着色器绘制的版本是与X轴的夹角。那如果要让着色器绘制版本的效果与CSS角向渐变效果完全一致,我们该怎么做呢? 3. 我们已经学过了随机数、距离场以及极坐标,你是不是可以利用它们绘制出一个画布,并且呈现随机的剪纸图案,类似的效果如下所示。 ![](https://static001.geekbang.org/resource/image/d8/8e/d899fd39482d17ff720c3f86d5d5858e.jpeg) 欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课再见! * * * ## 源码 [parametric-shader](https://github.com/akira-cn/graphics/tree/master/parametric-polar) [ploar-shader](https://github.com/akira-cn/graphics/tree/master/polar-shader)