第16章:极坐标与图案重复
难度:⭐⭐⭐ | 预计学习时间:2天 | 前置章节:第13章(仿射变换)、第14章(片元着色器几何造型)
配套Demo:
akira-graphics/polar-shader/
本章导言
欢迎来到极坐标的奇妙世界!
在前面的章节里,我们一直都在用 笛卡尔坐标系(也就是你熟悉的 x、y 坐标)来画图。我们画过圆、画过线、做过各种几何造型。但你知道吗?有些图形,用笛卡尔坐标系画起来特别费劲,而换一种坐标系——极坐标——就会变得异常优雅。
想象一下:
- 雷达扫描屏幕上的目标,它不说"目标在左上方300米",而是说"目标在北偏东30度,距离300米"
- 一朵玫瑰花的形状,用 x、y 方程写起来极其复杂,但用一个简单的极坐标公式就能完美描述
- 色轮上的颜色分布,用角度表示色相,用半径表示饱和度,比用 RGB 三个数直观得多
这就是极坐标的魅力:换个角度看世界,问题会变得更简单。
本章我们将一起:
- 理解极坐标是什么,以及它和笛卡尔坐标的区别
- 学会在 Shader 中做坐标转换
- 用极坐标画出圆、玫瑰曲线、三叶草等美丽图案
- 制作圆锥渐变和 HSV 色轮
- 把极坐标和其他 Shader 技巧结合起来
准备好了吗?让我们开始这场"坐标变换"的冒险!
16.1 什么是极坐标?
16.1.1 生活类比:雷达与GPS
想象你是一名雷达操作员,屏幕上有一个光点在闪烁。你的同事问你:"那个目标在哪?"
你有两种回答方式:
方式一(笛卡尔坐标):"目标在屏幕中心往右200像素、往上150像素的位置。"
- 这就是
(x, y)坐标:需要两个垂直方向的数值
方式二(极坐标):"目标在东北方向,距离中心250像素。"
- 这就是
(r, θ)坐标:只需要一个距离 + 一个方向角度
两种方式描述的是同一个点,但方式二在某些场景下更自然、更直观。
再想想你手机上的指南针APP:
- 它不会告诉你"北在 x=0, y=1 的方向"
- 它直接告诉你"北在 0 度方向,距离无限远"
这就是极坐标的本质:用"距离 + 角度"来定位,而不是"横向 + 纵向"。
16.1.2 本质:两种描述空间的语言
笛卡尔坐标和极坐标就像两种语言:
| 笛卡尔坐标 (Cartesian) | 极坐标 (Polar) | |
|---|---|---|
| 表示方式 | (x, y) |
(r, θ) |
| x / r | 水平距离 | 到原点的直线距离 |
| y / θ | 垂直距离 | 与正X轴的夹角 |
| 适合描述 | 矩形、网格、直线 | 圆、螺旋、扇形、花朵 |
| 直观感受 | "走几步再转个弯" | "朝某个方向走多远" |
核心洞察:坐标系不是"真实存在"的东西,它只是我们用来描述位置的一种工具。就像你可以用中文或英文描述同一件事,你可以用笛卡尔或极坐标描述同一个点。
16.1.3 坐标转换公式推导
既然两种坐标描述的是同一个点,那它们之间必然可以互相转换。让我们一步一步推导。
笛卡尔 → 极坐标
给定一个点 (x, y),求 (r, θ):
第一步:求 r(距离)
根据勾股定理,点到原点的距离就是直角三角形的斜边:
r = √(x² + y²)
在 GLSL 中,这个函数叫做 length(vec2),它内部做的就是 sqrt(x*x + y*y)。
第二步:求 θ(角度)
角度是点与正X轴的夹角。根据三角函数的定义:
tan(θ) = y / x
所以:
θ = arctan(y / x)
但是!这里有个大坑:
⚠️ 常见误区 1:直接用 atan(y/x)
如果你直接用 atan(y / x),会遇到两个问题:
- 当
x = 0时,除零错误 - 无法区分第二象限和第四象限(比如
(-1, 1)和(1, -1)的y/x都是-1,但角度完全不同)
正确做法:用 atan(y, x)(双参数版本)
GLSL 提供了 atan(y, x),它能正确处理所有四个象限,返回的范围是 [-π, π](约 [-3.14, 3.14])。
完整转换公式:
vec2 cartesianToPolar(vec2 st) {
float r = length(st); // 距离原点的长度
float theta = atan(st.y, st.x); // 角度,范围 [-π, π]
return vec2(r, theta);
}
极坐标 → 笛卡尔
反过来,给定 (r, θ),求 (x, y):
根据三角函数定义:
cos(θ) = x / r → x = r * cos(θ)
sin(θ) = y / r → y = r * sin(θ)
完整转换公式:
vec2 polarToCartesian(vec2 polar) {
float r = polar.x;
float theta = polar.y;
float x = r * cos(theta);
float y = r * sin(theta);
return vec2(x, y);
}
16.1.4 可视化理解
让我们画个图来理解:
y
↑
| ● P(x, y)
| /|
| / |
| / | y
|/θ |
+----+----→ x
x
y
↑
| ● P(r, θ)
| /
| / r
| /
|/θ
+--------→ x
在笛卡尔坐标中,我们从原点出发,先沿X轴走 x,再沿Y轴走 y。
在极坐标中,我们从原点出发,朝 θ 方向走 r 的距离。
16.1.5 Try it yourself
练习 1.1:不借助计算器,手动计算以下点的极坐标:
(1, 0)→ r = ?, θ = ?(0, 1)→ r = ?, θ = ?(-1, 0)→ r = ?, θ = ?(1, 1)→ r = ?, θ = ?(提示:这是一个等腰直角三角形)
练习 1.2:为什么 atan(y, x) 比 atan(y/x) 更好?举一个 x < 0 的例子说明。
16.2 在 Shader 中转换到极坐标
16.2.1 类比:把地图从"方格网"变成"雷达屏"
想象你有一张方格纸地图,现在你想把它变成雷达屏幕的显示方式。
在 Shader 中,这个过程只需要两行代码:
- 把坐标原点移到屏幕中心(因为极坐标是以原点为中心的)
- 调用转换函数
16.2.2 核心代码
// 极坐标转换函数
vec2 polar(vec2 st) {
return vec2(length(st), atan(st.y, st.x));
}
void main() {
// Step 1: 将 UV 坐标从 [0,1] 范围移到以 (0.5, 0.5) 为中心
// 这样屏幕中心就是原点 (0, 0)
vec2 st = vUv - vec2(0.5);
// Step 2: 转换到极坐标
// st.x 变成了 r(距离中心的距离)
// st.y 变成了 θ(角度)
st = polar(st);
// 现在 st.x = r, st.y = θ
}
逐行解析:
| 代码 | 含义 |
|---|---|
vUv - vec2(0.5) |
把 UV 坐标平移,让屏幕中心变成 (0, 0) |
length(st) |
计算到中心的距离,即 r |
atan(st.y, st.x) |
计算与正X轴的夹角,即 θ,范围 [-π, π] |
⚠️ 常见误区 2:忘记把原点移到中心
如果你直接对 vUv 做极坐标转换,而不先减 0.5:
// ❌ 错误:原点在左上角
vec2 st = polar(vUv); // 原点 (0,0) 在左上角!
那么你的"极坐标原点"会在屏幕左上角,画出来的图案会偏到一边去。记住:极坐标是以原点为中心的,所以必须先让屏幕中心成为坐标原点。
16.2.3 为什么 Shader 特别适合极坐标?
在 CPU 上,如果你要画一个圆,通常需要遍历角度,计算每个点的 x、y,然后画点:
// CPU 方式:逐个像素计算
for (let angle = 0; angle < 2 * Math.PI; angle += 0.01) {
let x = centerX + radius * Math.cos(angle);
let y = centerY + radius * Math.sin(angle);
drawPixel(x, y);
}
但在 GPU/Shader 中,每个片元(像素)是并行执行的。每个像素自己判断"我在不在圆上":
// GPU 方式:每个像素自己判断
void main() {
vec2 st = vUv - vec2(0.5);
st = polar(st);
// st.x 就是当前像素到中心的距离
// 如果距离 ≈ 0.2,这个像素就在圆上!
}
这就是 Shader 的思维方式:不是"我去画圆",而是"每个像素判断自己在不在圆上"。
16.2.4 Try it yourself
练习 2.1:修改上面的代码,把原点移到 (0.3, 0.7) 而不是 (0.5, 0.5),观察效果。
练习 2.2:如果不做 vUv - vec2(0.5) 这一步,直接用 polar(vUv),画出来的圆会在屏幕的哪个位置?
16.3 用极坐标画圆
16.3.1 类比:圆规画圆
还记得小学用圆规画圆吗?
- 把圆规针尖固定在纸上(圆心)
- 保持半径不变,转一圈
在极坐标中,圆的定义简单得令人发指:
r = 常数
就这么简单!无论 θ 是多少,r 都是固定的。转一圈,画出来的就是一个完美的圆。
16.3.2 圆的 Shader 实现
来看我们的第一个 Demo:circle.html
vec2 polar(vec2 st) {
return vec2(length(st), atan(st.y, st.x));
}
void main() {
vec2 st = vUv - vec2(0.5);
st = polar(st);
// st.x 是 r(到中心的距离)
// 我们想画一个半径为 0.2 的圆
// smoothstep 创建一个从 1 到 0 的平滑过渡
gl_FragColor.rgb = smoothstep(st.x, st.x + 0.01, 0.2) * vec3(1.0);
gl_FragColor.a = 1.0;
}
公式推导:为什么用 smoothstep?
我们想判断:当前像素到中心的距离 r 是否小于圆的半径 R = 0.2。
理想情况下,这是一个阶跃函数:
如果 r < R: 白色 (1.0)
如果 r > R: 黑色 (0.0)
但直接用 step(R, r) 会产生锯齿(硬边),因为像素要么完全在圆内,要么完全在圆外。
smoothstep(edge0, edge1, x) 做的事情是:
- 当
x < edge0时,返回 0 - 当
x > edge1时,返回 1 - 中间平滑过渡
在我们的代码中:
smoothstep(st.x, st.x + 0.01, 0.2)
等等,这个参数顺序看起来有点奇怪?让我们仔细看:
smoothstep(a, b, x) 的标准定义是:当 x 在 a 和 b 之间时,返回 0~1 的平滑值。
但这里写的是 smoothstep(st.x, st.x + 0.01, 0.2),意思是:
edge0 = st.x(当前像素的 r)edge1 = st.x + 0.01x = 0.2(圆的半径)
当 0.2 接近 st.x 时,返回值从 1 渐变到 0,形成一个抗锯齿的圆边缘。
⚠️ 常见误区 3:smoothstep 参数顺序搞混
很多人一开始会写成:
// ❌ 错误:这样写逻辑反了
smoothstep(0.2, 0.21, st.x)
这表示"r 从 0.2 到 0.21 时,颜色从黑变白",也就是圆外变白、圆内变黑。虽然也能画圆,但颜色逻辑反了。注意检查你的颜色是否符合预期!
16.3.3 画圆环(空心圆)
如果想画一个圆环(甜甜圈形状),只需要两个条件:
float d = smoothstep(st.x, st.x + 0.01, 0.25) // 外半径
- smoothstep(st.x, st.x + 0.01, 0.15); // 内半径
原理:大圆减去小圆,中间留下的部分就是圆环。
16.3.4 Try it yourself
练习 3.1:修改 circle.html,画一个半径为 0.3 的红色圆。
练习 3.2:画一个圆环,外半径 0.3,内半径 0.2,颜色为蓝色。
练习 3.3:用 step 代替 smoothstep 画圆,观察边缘的锯齿现象,体会 smoothstep 抗锯齿的作用。
16.4 玫瑰曲线:r = a * cos(k * θ)
16.4.1 类比:花瓣的生长规律
想象一朵玫瑰花的花瓣是怎么排列的:
- 花瓣从中心向外辐射
- 每片花瓣占据一定的角度范围
- 花瓣的"长度"随着角度变化
玫瑰曲线(Rose Curve)就是描述这种形状的数学公式:
r = a * cos(k * θ)
其中:
a是花瓣的最大长度(振幅)k决定花瓣的数量和形状θ是角度
16.4.2 公式推导:为什么这个公式能画出花瓣?
让我们一步一步理解这个公式的行为。
第一步:理解 cos(θ) 的基本形状
cos(θ) 在 θ 从 0 到 2π 的过程中:
θ = 0时,cos(0) = 1(最大值)θ = π/2时,cos(π/2) = 0θ = π时,cos(π) = -1(最小值)θ = 3π/2时,cos(3π/2) = 0θ = 2π时,cos(2π) = 1
第二步:乘以 a
r = a * cos(θ) 把 cos 的值域从 [-1, 1] 放大到 [-a, a]。
但距离 r 不能为负数!在极坐标中,当 r < 0 时,点会画在相反方向。所以实际上:
- 当
cos(θ) > 0时,r 为正,画出花瓣 - 当
cos(θ) < 0时,r 为负,点被画到反方向,也形成花瓣
第三步:乘以 k
cos(k * θ) 让 cos 函数在 0 到 2π 的范围内振荡 k 次。
- 当
k = 1时,振荡 1 次,画出 1 个"花瓣"(其实是一个圆) - 当
k = 2时,振荡 2 次,画出 4 个花瓣 - 当
k = 3时,振荡 3 次,画出 3 个花瓣 - 当
k = 4时,振荡 4 次,画出 8 个花瓣
花瓣数量的规律:
| k 值 | 花瓣数 | 规律 |
|---|---|---|
| 1 | 1(圆) | - |
| 2 | 4 | 2 * k |
| 3 | 3 | k(奇数) |
| 4 | 8 | 2 * k |
| 5 | 5 | k(奇数) |
| 6 | 12 | 2 * k |
规律总结:
- 当
k为奇数时,花瓣数 =k - 当
k为偶数时,花瓣数 =2 * k
为什么奇偶不同?
因为当 k 是奇数时,cos(k * θ) 在 0 到 π 和 π 到 2π 画出的花瓣会重叠;而当 k 是偶数时,它们会错开,形成两倍的花瓣。
16.4.3 第一个玫瑰曲线 Demo:rose.html
vec2 polar(vec2 st) {
return vec2(length(st), atan(st.y, st.x));
}
void main() {
vec2 st = vUv - vec2(0.5);
st = polar(st);
// 玫瑰曲线:r = 0.5 * cos(3 * θ)
// 我们想判断:当前像素的 r 是否小于玫瑰曲线的 r
// 如果是,这个像素就在花瓣内部
float roseR = 0.5 * cos(st.y * 3.0); // 当前角度下,花瓣的边界半径
float d = roseR - st.x; // 如果 d > 0,像素在花瓣内
gl_FragColor.rgb = smoothstep(-0.01, 0.01, d) * vec3(1.0);
gl_FragColor.a = 1.0;
}
逐行解析:
float roseR = 0.5 * cos(st.y * 3.0);
st.y是当前像素的角度θ3.0是k值,决定 3 个花瓣0.5是a值,花瓣最大长度roseR是当前角度下,花瓣边界的半径
float d = roseR - st.x;
st.x是当前像素到中心的实际距离rroseR是花瓣在该角度下的"允许最大距离"- 如果
d > 0,说明实际距离 < 花瓣边界,像素在花瓣内
smoothstep(-0.01, 0.01, d)
- 对
d做平滑处理,产生抗锯齿边缘
16.4.4 可交互的玫瑰曲线:rose2.html
uniform float u_k; // 花瓣参数,由 JS 传入
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;
}
JS 端:
renderer.uniforms.u_k = 2; // 可以尝试 1, 2, 3, 4, 5, 6...
试试这些值,观察花瓣数量的变化:
u_k = 1:一个圆(1个"花瓣")u_k = 2:4个花瓣u_k = 3:3个花瓣u_k = 4:8个花瓣u_k = 5:5个花瓣u_k = 6:12个花瓣
16.4.5 ⚠️ 常见误区 4:混淆花瓣数与 k 值
误区:"k = 3 就应该有 3 个花瓣,那 k = 4 也应该有 4 个花瓣吧?"
正解:不对!奇偶性很重要:
- 奇数 k:花瓣数 = k
- 偶数 k:花瓣数 = 2 * k
这是因为 cos(k * θ) 的对称性导致的。当 k 为奇数时,负半径部分会重叠到已有花瓣上;当 k 为偶数时,负半径部分会创造新的花瓣。
16.4.6 Try it yourself
练习 4.1:在 rose2.html 中,把 u_k 分别设为 1 到 8,记录每个值对应的花瓣数,验证上面的规律。
练习 4.2:把公式改成 r = 0.5 * sin(k * θ)(用 sin 代替 cos),观察图形有什么变化。(提示:图形会旋转)
练习 4.3:尝试 r = 0.5 * abs(cos(k * θ))(加上 abs),观察负半径部分的行为。
16.5 可变玫瑰曲线与三叶草/花朵形状
16.5.1 类比:调制花朵的形状
想象你是一位花艺师,可以调整花朵的各种参数:
- 花瓣数量
- 花瓣长度
- 花瓣宽度
- 花瓣是否圆润
在数学上,我们可以通过调整公式中的参数来"调制"花朵形状。
16.5.2 三叶草形状:clover.html
uniform float u_k; // 花瓣参数
uniform float u_scale; // 缩放
uniform float u_offset; // 偏移
vec2 polar(vec2 st) {
return vec2(length(st), atan(st.y, st.x));
}
void main() {
vec2 st = vUv - vec2(0.5);
st = polar(st);
// 三叶草公式:r = scale * 0.5 * |cos(k * θ / 2)| + offset
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;
}
JS 端:
renderer.uniforms.u_k = 1.7;
renderer.uniforms.u_scale = 0.5;
renderer.uniforms.u_offset = 0.2;
关键变化解析:
| 变化 | 效果 |
|---|---|
abs(cos(...)) |
取绝对值,让所有花瓣朝外(没有负半径的重叠) |
* 0.5(在角度里) |
让 cos 的周期变长,改变花瓣的排列方式 |
u_scale |
整体缩放花瓣大小 |
u_offset |
给半径加一个偏移,让花瓣变胖或变瘦 |
16.5.3 花苞形状:bud.html
uniform float u_k;
uniform float u_scale;
uniform float u_offset;
void main() {
vec2 st = vUv - vec2(0.5);
st = polar(st);
// 花苞公式:用 smoothstep 调制 cos 的形状
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;
}
JS 端:
renderer.uniforms.u_k = 5;
renderer.uniforms.u_scale = 0.2;
renderer.uniforms.u_offset = 0.2;
关键变化解析:
smoothstep(-0.3, 1.0, u_scale * 0.5 * cos(st.y * u_k) + u_offset)
这里用 smoothstep 对 cos 的值做了"压缩":
- 当
cos值在-0.3以下时,输出 0 - 当
cos值在1.0以上时,输出 1 - 中间平滑过渡
这会产生一种"花苞"效果:花瓣的边缘变得圆润,像未完全开放的花蕾。
16.5.4 公式推导:为什么 abs(cos) 改变花瓣行为?
回顾标准玫瑰曲线:
r = a * cos(k * θ)
当 cos(k * θ) 为负时,r 为负。在极坐标中,负半径意味着"朝相反方向画"。这导致了奇偶 k 值的不同行为。
如果我们加上 abs:
r = a * |cos(k * θ)|
那么 r 永远不会为负。所有花瓣都朝外辐射,不再有"负半径重叠"的现象。这让花瓣的行为更加直观和可控。
16.5.5 ⚠️ 常见误区 5:以为所有参数都是独立的
误区:"我同时调整 u_k、u_scale、u_offset,它们应该各自独立影响不同的方面。"
正解:这些参数实际上是相互影响的。比如:
- 增大
u_scale会让花瓣变长,但如果u_offset不变,花瓣可能会"断掉" - 改变
u_k会改变花瓣数量,但也会影响每个花瓣的宽度
调试时建议一次只改一个参数,观察它的独立效果,然后再组合使用。
16.5.6 Try it yourself
练习 5.1:在 clover.html 中,把 u_k 从 1 改到 5,观察三叶草形状的变化。
练习 5.2:在 bud.html 中,调整 u_scale 和 u_offset,尝试画出:
- 一个很瘦的星形
- 一个很胖的圆形花朵
- 一个只有边缘有花瓣的形状
练习 5.3:把 clover.html 中的 abs(cos(...)) 改成 cos(...),观察有什么不同。
16.6 圆锥渐变(Conic Gradient)
16.6.1 类比:彩虹色的披萨
想象你有一个披萨,你想给它上色:
- 从 0 度到 180 度,从红色渐变到绿色
- 从 180 度到 360 度,从绿色渐变到蓝色
这就是圆锥渐变:颜色沿着角度方向变化,而不是沿着半径方向(那是径向渐变)。
16.6.2 核心思路
圆锥渐变的关键是:
- 把角度
θ从[-π, π]映射到[0, 1] - 用这个值作为颜色插值的参数
16.6.3 圆锥渐变 Demo:conic.html
vec2 polar(vec2 st) {
return vec2(length(st), atan(st.y, st.x));
}
void main() {
vec2 st = vUv - vec2(0.5);
st = polar(st);
// Step 1: 画一个圆(限制在圆内)
float d = smoothstep(st.x, st.x + 0.01, 0.2);
// Step 2: 处理角度范围
// atan 返回 [-π, π],负数角度不方便做渐变
// 把它转换到 [0, 2π]
if (st.y < 0.0) st.y += 6.28; // 6.28 ≈ 2π
// Step 3: 把角度映射到 [0, 1]
float p = st.y / 6.28;
// Step 4: 根据 p 的值选择颜色
// p < 0.45: 红 → 绿
// p >= 0.45: 绿 → 蓝
if (p < 0.45) {
gl_FragColor.rgb = d * mix(vec3(1.0, 0, 0), vec3(0, 0.5, 0), p / 0.45);
} else {
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;
}
逐行解析:
if (st.y < 0.0) st.y += 6.28;
atan返回的角度范围是[-π, π](约[-3.14, 3.14])- 负角度不方便做渐变,所以我们把负角度加上
2π,转换到[0, 2π] 6.28是2 * π的近似值
float p = st.y / 6.28;
- 把
[0, 2π]映射到[0, 1] p = 0对应角度 0(正右方)p = 0.5对应角度 π(正左方)p = 1对应角度 2π(回到正右方)
mix(vec3(1.0, 0, 0), vec3(0, 0.5, 0), p / 0.45)
mix(a, b, t)在a和b之间线性插值- 当
p = 0时,颜色是纯红(1, 0, 0) - 当
p = 0.45时,颜色是(0, 0.5, 0)(深绿)
16.6.4 更简洁的彩虹圆锥渐变
如果你想做一个完整的彩虹色轮,可以用 mod 函数:
// 把角度映射到 [0, 1],然后用 HSV 转换
float hue = st.y / 6.28;
vec3 color = hsv2rgb(vec3(hue, 1.0, 1.0));
这就是我们下一节要讲的 HSV 色轮!
16.6.5 ⚠️ 常见误区 6:忘记处理负角度
误区:"atan 返回的角度直接用就行。"
正解:atan(y, x) 返回 [-π, π]。如果你不做 if (st.y < 0.0) st.y += 6.28 这个转换,你的渐变会在 -π 到 0 之间"反向",导致色轮在左侧出现不连续的跳变。
16.6.6 Try it yourself
练习 6.1:修改 conic.html,让渐变从蓝色开始,经过绿色,到红色结束。
练习 6.2:做一个"温度计"效果:圆的上半部分是红色(热),下半部分是蓝色(冷),中间渐变。
练习 6.3:去掉圆的半径限制(去掉 d),让整个屏幕都显示角度渐变,观察效果。
16.7 交互式 HSV 色轮
16.7.1 类比:画家的调色盘
想象你是一位画家,你的调色盘是这样的:
- 角度(Hue/色相):决定是什么颜色(红、橙、黄、绿、青、蓝、紫)
- 到中心的距离(Saturation/饱和度):决定颜色有多"纯"
- 中心是灰色(饱和度 = 0)
- 边缘是纯彩色(饱和度 = 1)
- 亮度(Value):决定颜色有多亮
HSV 色轮就是把这三个维度可视化在一个圆盘上,非常直观。
16.7.2 HSV 转 RGB 公式
HSV 到 RGB 的转换是图形学中的经典算法。这里直接给出 GLSL 实现:
vec3 hsv2rgb(vec3 c) {
// c.x = H (色相, 0~1)
// c.y = S (饱和度, 0~1)
// c.z = V (明度, 0~1)
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);
}
这个实现比较紧凑,用了一些数学技巧。核心思想是:
- 根据色相 H 确定 RGB 三个通道的"基础比例"
- 根据饱和度 S 在"灰色"和"彩色"之间插值
- 根据明度 V 整体缩放亮度
16.7.3 交互式色轮 Demo:hsvwheel.html
uniform vec2 uMouse; // 鼠标位置,x=饱和度, y=明度
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 hue = st.y / 6.28;
// 鼠标控制饱和度和明度
float saturation = uMouse.x; // 0~1
float value = uMouse.y; // 0~1
gl_FragColor.rgb = d * hsv2rgb(vec3(hue, saturation, value));
gl_FragColor.a = 1.0;
}
JS 端:
// 初始饱和度和明度都是 0.5
renderer.uniforms.uMouse = [0.5, 0.5];
// 鼠标移动时更新
canvas.addEventListener('mousemove', (e) => {
const {x, y, width, height} = e.target.getBoundingClientRect();
renderer.uniforms.uMouse = [
(e.x - x) / width, // x 方向控制饱和度
1.0 - (e.y - y) / height // y 方向控制明度(翻转Y轴)
];
});
交互效果:
- 鼠标左右移动:改变饱和度(左 = 灰,右 = 纯彩色)
- 鼠标上下移动:改变明度(上 = 亮,下 = 暗)
- 色轮本身:角度决定色相(红→绿→蓝→红)
16.7.4 为什么 HSV 比 RGB 更直观?
| RGB | HSV | |
|---|---|---|
| 人脑理解 | "多一点红,少一点蓝" | "更红一点,更淡一点" |
| 调色直觉 | 差(三个通道互相影响) | 好(三个维度独立) |
| 色轮表示 | 困难 | 自然(角度 = 色相) |
| 取互补色 | 需要计算 | 色相 + 0.5 即可 |
这就是为什么所有设计软件(Photoshop、Figma、Cocos 的颜色选择器)都提供 HSV 模式的原因。
16.7.5 ⚠️ 常见误区 7:HSV 的 H 范围搞混
误区:"色相 H 是 0~360 度。"
正解:在 Shader 中,H 通常归一化到 0~1。所以:
- 红色 = 0(或 1)
- 绿色 = 1/3 ≈ 0.333
- 蓝色 = 2/3 ≈ 0.667
如果你习惯 0~360,记得除以 360:hue = angle_in_degrees / 360.0。
16.7.6 Try it yourself
练习 7.1:修改 hsvwheel.html,让半径也影响饱和度(中心饱和度低,边缘饱和度高),去掉鼠标控制。
练习 7.2:在色轮中心显示当前选中颜色的 RGB 值(用 rgb2hsv 的逆转换)。
练习 7.3:做一个"色温"调节器:只改变色相的"暖度"(红-黄 范围),不改变饱和度和明度。
16.8 极坐标与其他 Shader 技巧的结合
16.8.1 类比:乐高积木的组合
极坐标就像一种特殊的乐高积木。单独用它已经很强大,但和其他技巧组合起来,能创造出更惊人的效果:
- 极坐标 + 噪声 = 有机的花朵、星云
- 极坐标 + 时间动画 = 旋转的螺旋、绽放的花朵
- 极坐标 + 图案重复 = 万花筒、曼陀罗
- 极坐标 + SDF = 复杂的几何造型
16.8.2 极坐标 + 时间动画
让玫瑰曲线旋转起来:
uniform float u_time;
void main() {
vec2 st = vUv - vec2(0.5);
st = polar(st);
// 让角度随时间变化,产生旋转效果
float theta = st.y + u_time;
float d = 0.5 * cos(theta * 3.0) - st.x;
gl_FragColor.rgb = smoothstep(-0.01, 0.01, d) * vec3(1.0);
gl_FragColor.a = 1.0;
}
JS 端:
function render() {
renderer.uniforms.u_time = performance.now() / 1000;
renderer.render();
requestAnimationFrame(render);
}
render();
原理:在角度上加上时间,相当于让整个图案绕中心旋转。
16.8.3 极坐标 + 图案重复(万花筒效果)
把角度做取模操作,可以创造出"万花筒"效果:
void main() {
vec2 st = vUv - vec2(0.5);
st = polar(st);
// 把角度重复 N 次
float segments = 6.0;
st.y = mod(st.y, 6.28 / segments);
// 现在只画一个"扇形"内的图案
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;
}
原理:mod(st.y, 6.28 / segments) 把完整的角度圆分成 segments 个扇区,每个扇区显示相同的图案,形成对称的万花筒效果。
16.8.4 极坐标 + 噪声
用噪声调制花瓣的长度,创造有机感:
uniform float u_time;
// 简单的 value noise 函数(详见第15章)
float noise(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}
void main() {
vec2 st = vUv - vec2(0.5);
st = polar(st);
// 用噪声调制花瓣长度
float n = noise(vec2(st.y * 2.0, u_time * 0.5));
float a = 0.5 + 0.1 * n; // 基础长度 0.5,噪声扰动 ±0.1
float d = a * cos(st.y * 5.0) - st.x;
gl_FragColor.rgb = smoothstep(-0.01, 0.01, d) * vec3(1.0);
gl_FragColor.a = 1.0;
}
效果:花瓣边缘不再光滑,而是有自然的起伏,像真实的花朵。
16.8.5 极坐标 + SDF(距离场)
用极坐标定义 SDF,然后做布尔运算:
float roseSDF(vec2 st, float k, float a) {
vec2 p = polar(st);
return a * cos(p.y * k) - p.x;
}
void main() {
vec2 st = vUv - vec2(0.5);
// 两个玫瑰曲线的并集
float d1 = roseSDF(st, 3.0, 0.3);
float d2 = roseSDF(st, 5.0, 0.2);
float d = max(d1, d2); // 并集:取最大值
gl_FragColor.rgb = smoothstep(-0.01, 0.01, d) * vec3(1.0);
gl_FragColor.a = 1.0;
}
16.8.6 ⚠️ 常见误区 8:认为极坐标只能画"圆的东西"
误区:"极坐标只能画圆、花这些圆形图案,画方形的就不行。"
正解:极坐标可以描述任何形状!比如一个正方形在极坐标中的方程是:
r = min(1/|cos(θ)|, 1/|sin(θ)|) / 2
只是有些形状在笛卡尔坐标中更简单,有些在极坐标中更简单。选择坐标系的原则是:哪个坐标系让问题更简单,就用哪个。
16.8.7 Try it yourself
练习 8.1:给 rose.html 加上时间动画,让花瓣缓慢旋转。
练习 8.2:实现一个万花筒效果,把玫瑰曲线重复 8 次。
练习 8.3:用噪声调制 clover.html 的 u_offset 参数,让花朵有"呼吸"效果。
练习 8.4:尝试用极坐标画一个正方形(提示:正方形的极坐标方程涉及 max(|cos(θ)|, |sin(θ)|))。
16.9 完整代码参考
以下是本章所有 Demo 的完整代码汇总,方便你复制和实验。
16.9.1 基础模板(所有 Demo 共用)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>极坐标 Demo</title>
</head>
<body>
<script src="../common/lib/gl-renderer.js"></script>
<canvas width="512" height="512"></canvas>
<script>
const vertex = `
attribute vec2 a_vertexPosition;
attribute vec2 uv;
varying vec2 vUv;
void main() {
gl_PointSize = 1.0;
vUv = uv;
gl_Position = vec4(a_vertexPosition, 1, 1);
}
`;
// ===== 在这里插入不同的 fragment shader =====
const fragment = `...`;
// =============================================
const canvas = document.querySelector('canvas');
const renderer = new GlRenderer(canvas);
const program = renderer.compileSync(fragment, vertex);
renderer.useProgram(program);
renderer.setMeshData([{
positions: [[-1, -1], [-1, 1], [1, 1], [1, -1]],
attributes: {
uv: [[0, 0], [0, 1], [1, 1], [1, 0]],
},
cells: [[0, 1, 2], [2, 0, 3]],
}]);
renderer.render();
</script>
</body>
</html>
16.9.2 各 Demo 的 Fragment Shader
画圆(circle.html):
#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;
}
玫瑰曲线(rose.html):
#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);
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;
}
可变玫瑰曲线(rose2.html):
#ifdef GL_ES
precision highp float;
#endif
varying vec2 vUv;
uniform float u_k;
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 = 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;
}
三叶草(clover.html):
#ifdef GL_ES
precision highp float;
#endif
varying vec2 vUv;
uniform float u_k;
uniform float u_scale;
uniform float u_offset;
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 = 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;
}
花苞(bud.html):
#ifdef GL_ES
precision highp float;
#endif
varying vec2 vUv;
uniform float u_k;
uniform float u_scale;
uniform float u_offset;
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(-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;
}
圆锥渐变(conic.html):
#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);
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;
if(p < 0.45) {
gl_FragColor.rgb = d * mix(vec3(1.0, 0, 0), vec3(0, 0.5, 0), p / 0.45);
} else {
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;
}
HSV 色轮(hsvwheel.html):
#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;
}
16.10 Q&A 常见问题
Q1:极坐标和笛卡尔坐标哪个更好?
A:没有绝对的"更好",只有"更适合"。
- 画矩形、处理网格、做像素操作 → 笛卡尔坐标更方便
- 画圆、做旋转对称图案、做角度渐变 → 极坐标更方便
在实际开发中,你甚至可以先转换到极坐标做处理,再转回笛卡尔坐标。坐标系只是工具,不是限制。
Q2:atan(st.y, st.x) 和 atan(st.y / st.x) 到底有什么区别?
A:区别非常大!
atan(y / x):单参数版本,只能处理[-π/2, π/2],无法区分象限,且x=0时除零atan(y, x):双参数版本,GLSL 会自动根据x和y的符号判断象限,返回[-π, π],且没有除零问题
永远用 atan(y, x),不要用 atan(y/x)!
Q3:玫瑰曲线的 k 值可以是小数吗?
A:可以!当 k 是小数时,花瓣不会完美闭合,会产生有趣的"螺旋花瓣"效果。试试 k = 1.5、k = 2.5、k = 3.7,你会发现全新的图案。
Q4:为什么我的色轮在左侧有一条明显的分界线?
A:因为你没有处理 atan 返回的负角度。atan 返回 [-π, π],在左侧(θ = π 和 θ = -π 的交界处)会有跳变。解决方案:
if (st.y < 0.0) st.y += 6.28; // 把负角度转到正范围
Q5:极坐标在 3D 中有用吗?
A:非常有用!3D 中的球坐标就是极坐标的扩展:
(r, θ, φ):距离 + 水平角度 + 垂直角度- 用于天空盒、球面映射、全景图、环境光照等
我们会在第17章(3D渲染基础)中详细讲解。
Q6:如何把极坐标效果应用到 Cocos Creator 中?
A:把本章的 fragment shader 代码移植到 Cocos 的 Effect 文件中即可。主要区别:
- Cocos 中使用
v_uv而不是vUv - Uniform 需要通过材质属性声明
- 需要创建一个全屏 Quad 或 Sprite 来显示效果
参考第11章(Shader 深度入门)和第12章(WebGL 与着色器模式)中的 Cocos Effect 写法。
Q7:为什么 smoothstep 的参数有时候看起来"反直觉"?
A:smoothstep(edge0, edge1, x) 的定义是:
x <= edge0→ 0x >= edge1→ 1- 中间平滑过渡
所以 smoothstep(st.x, st.x + 0.01, 0.2) 的意思是:
- 当
0.2 < st.x时(在圆外),返回 0 - 当
0.2 > st.x + 0.01时(在圆内),返回 1
如果搞混了,就画一个数轴,标出 edge0、edge1 和 x 的位置,就能理清逻辑。
16.11 本章小结
恭喜你完成了极坐标的学习!让我们回顾一下核心要点:
| 概念 | 核心公式/代码 | 关键记忆点 |
|---|---|---|
| 笛卡尔 → 极坐标 | r = length(st); θ = atan(st.y, st.x) |
用双参数 atan! |
| 极坐标 → 笛卡尔 | x = r*cos(θ); y = r*sin(θ) |
三角函数定义 |
| 画圆 | r = 常数 |
极坐标中最简单的形状 |
| 玫瑰曲线 | r = a * cos(k * θ) |
奇数 k → k 瓣;偶数 k → 2k 瓣 |
| 圆锥渐变 | 角度映射到 [0,1] 再插值 |
记得处理负角度 |
| HSV 色轮 | 角度 = 色相,鼠标 = 饱和度/明度 | HSV 比 RGB 更直观 |
最重要的思维转变:
坐标系不是"真实"的,它只是描述位置的一种语言。学会在不同坐标系之间切换,是 Shader 编程的核心能力之一。
课后挑战
曼陀罗生成器:结合极坐标 + 图案重复 + 多层玫瑰曲线,生成一个复杂的曼陀罗图案。
动态花朵:用
u_time驱动u_k、u_scale、u_offset,让花朵随时间绽放、旋转、变色。极坐标迷宫:用极坐标 + 噪声生成一个螺旋迷宫。
极坐标文字:尝试在极坐标中"弯曲"文字(提示:把文字的笛卡尔坐标转换到极坐标,加上角度偏移,再转回来)。
下一章预告:第17章《3D渲染基础》——我们将从 2D 进入 3D,学习旋转矩阵、透视投影、法向量,为光照和阴影打下基础。