第16章:极坐标与图案重复

难度:⭐⭐⭐ | 预计学习时间:2天 | 前置章节:第13章(仿射变换)、第14章(片元着色器几何造型)

配套Demo:akira-graphics/polar-shader/


本章导言

欢迎来到极坐标的奇妙世界!

在前面的章节里,我们一直都在用 笛卡尔坐标系(也就是你熟悉的 x、y 坐标)来画图。我们画过圆、画过线、做过各种几何造型。但你知道吗?有些图形,用笛卡尔坐标系画起来特别费劲,而换一种坐标系——极坐标——就会变得异常优雅。

想象一下:

  • 雷达扫描屏幕上的目标,它不说"目标在左上方300米",而是说"目标在北偏东30度,距离300米"
  • 一朵玫瑰花的形状,用 x、y 方程写起来极其复杂,但用一个简单的极坐标公式就能完美描述
  • 色轮上的颜色分布,用角度表示色相,用半径表示饱和度,比用 RGB 三个数直观得多

这就是极坐标的魅力:换个角度看世界,问题会变得更简单

本章我们将一起:

  1. 理解极坐标是什么,以及它和笛卡尔坐标的区别
  2. 学会在 Shader 中做坐标转换
  3. 用极坐标画出圆、玫瑰曲线、三叶草等美丽图案
  4. 制作圆锥渐变和 HSV 色轮
  5. 把极坐标和其他 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),会遇到两个问题:

  1. x = 0 时,除零错误
  2. 无法区分第二象限和第四象限(比如 (-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 中,这个过程只需要两行代码:

  1. 把坐标原点移到屏幕中心(因为极坐标是以原点为中心的)
  2. 调用转换函数

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) 的标准定义是:当 xab 之间时,返回 0~1 的平滑值。

但这里写的是 smoothstep(st.x, st.x + 0.01, 0.2),意思是:

  • edge0 = st.x(当前像素的 r)
  • edge1 = st.x + 0.01
  • x = 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 到 的过程中:

  • θ = 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 的范围内振荡 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ππ 画出的花瓣会重叠;而当 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.0k 值,决定 3 个花瓣
  • 0.5a 值,花瓣最大长度
  • roseR 是当前角度下,花瓣边界的半径
float d = roseR - st.x;
  • st.x 是当前像素到中心的实际距离 r
  • roseR 是花瓣在该角度下的"允许最大距离"
  • 如果 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)

这里用 smoothstepcos 的值做了"压缩":

  • 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_ku_scaleu_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_scaleu_offset,尝试画出:

  • 一个很瘦的星形
  • 一个很胖的圆形花朵
  • 一个只有边缘有花瓣的形状

练习 5.3:把 clover.html 中的 abs(cos(...)) 改成 cos(...),观察有什么不同。


16.6 圆锥渐变(Conic Gradient)

16.6.1 类比:彩虹色的披萨

想象你有一个披萨,你想给它上色:

  • 从 0 度到 180 度,从红色渐变到绿色
  • 从 180 度到 360 度,从绿色渐变到蓝色

这就是圆锥渐变:颜色沿着角度方向变化,而不是沿着半径方向(那是径向渐变)。

16.6.2 核心思路

圆锥渐变的关键是:

  1. 把角度 θ[-π, π] 映射到 [0, 1]
  2. 用这个值作为颜色插值的参数

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]
  • 负角度不方便做渐变,所以我们把负角度加上 ,转换到 [0, 2π]
  • 6.282 * π 的近似值
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)ab 之间线性插值
  • 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);
}

这个实现比较紧凑,用了一些数学技巧。核心思想是:

  1. 根据色相 H 确定 RGB 三个通道的"基础比例"
  2. 根据饱和度 S 在"灰色"和"彩色"之间插值
  3. 根据明度 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.htmlu_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 会自动根据 xy 的符号判断象限,返回 [-π, π],且没有除零问题

永远用 atan(y, x),不要用 atan(y/x)

Q3:玫瑰曲线的 k 值可以是小数吗?

A:可以!当 k 是小数时,花瓣不会完美闭合,会产生有趣的"螺旋花瓣"效果。试试 k = 1.5k = 2.5k = 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 文件中即可。主要区别:

  1. Cocos 中使用 v_uv 而不是 vUv
  2. Uniform 需要通过材质属性声明
  3. 需要创建一个全屏 Quad 或 Sprite 来显示效果

参考第11章(Shader 深度入门)和第12章(WebGL 与着色器模式)中的 Cocos Effect 写法。

Q7:为什么 smoothstep 的参数有时候看起来"反直觉"?

Asmoothstep(edge0, edge1, x) 的定义是:

  • x <= edge0 → 0
  • x >= 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 编程的核心能力之一。


课后挑战

  1. 曼陀罗生成器:结合极坐标 + 图案重复 + 多层玫瑰曲线,生成一个复杂的曼陀罗图案。

  2. 动态花朵:用 u_time 驱动 u_ku_scaleu_offset,让花朵随时间绽放、旋转、变色。

  3. 极坐标迷宫:用极坐标 + 噪声生成一个螺旋迷宫。

  4. 极坐标文字:尝试在极坐标中"弯曲"文字(提示:把文字的笛卡尔坐标转换到极坐标,加上角度偏移,再转回来)。


下一章预告:第17章《3D渲染基础》——我们将从 2D 进入 3D,学习旋转矩阵、透视投影、法向量,为光照和阴影打下基础。