ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Processing 绘制李萨如曲线:参数调优、相位动画与性能优化

Processing 绘制李萨如曲线:参数调优、相位动画与性能优化

1. 先搞清楚李萨如曲线到底在画什么

很多朋友第一次听说“用 Processing 画李萨如曲线”,第一反应是“这名字听起来挺唬人”。其实拆开看,它就是两个方向的简谐运动叠在一起之后的轨迹:一个点沿着 X 轴来回摆,另一个点沿着 Y 轴来回摆,两个摆动周期不一样,最后描出来的那条线就叫李萨如曲线。你在示波器上调过两个正弦信号的那种交叉图形,就是它;老式示波器上那种慢慢旋转变形的图案,也是它。

我用 Processing 画这条曲线断断续续有几年了,从最早画出第一团糊成一圈的东西,到后面能稳定产出拿来当壁纸、当动态背景、当音画可视化的素材,中间踩过的坑主要是两类:一是数学没理清楚,曲线不闭合或者画重复了;二是渲染参数没配好,明明公式对,出来就是锯齿、卡顿、飞出屏幕。

这篇内容面向三类人:刚接触 Processing、想找一个不算太简单也不至于劝退的练手项目的人;做视觉/生成艺术,想批量出图形素材的人;还有搞音乐可视化,想把音频参数映射到几何图形上的人。你不需要有多强的数学背景,高中正弦函数那点印象够用,我会把每个参数的物理意义和视觉效果都掰开讲。看完你应该能做到:一行参数不改就能跑出静态图形,改三个数字就能控制形状,加七八行代码就能让曲线动起来,最后还能导出成序列帧拼成视频。

2. 为什么这件事值得用 Processing 来做

2.1 李萨如曲线的本质其实只有两行公式

先回到数学。李萨如曲线的标准参数方程是长这样的:

x(t) = A * sin(a * t + δ) y(t) = B * sin(b * t)

四个参数各管一件事,理解了它们你就掌握了全部控制权:

  • A、B是两个方向上的振幅,决定曲线最宽能到多少、最高能到多少。A 不等于 B 的时候,图形会被“压扁”,从正圆形的对称变成椭圆形的拉伸。
  • a、b是两个方向的频率系数,它们的最简整数比决定了曲线的基本骨架。这是最关键的一组参数。
  • δ是相位差,它不改变曲线的形状种类,但会让曲线在同样的骨架下“扭”出不同的形态,从一条线扭成一个椭圆,再扭成更复杂的交叉。
  • t是参数,从 0 开始扫一圈,曲线就画完一遍。

我用一个生活化的类比:想象一个孩子在沙地上推一个玩具,玩具被两根橡皮筋分别拴在左右墙和上下墙上,两根橡皮筋的松紧不同(频率不同),玩具就跑出一条固定的轨迹。你推的时间足够长,轨迹会重复;如果两根橡皮筋的松紧是“有理数关系”,轨迹就是闭合的;如果没法用简单比例描述,轨迹就永远不会严丝合缝地回到起点,而是慢慢填满一片区域。

这里有个容易混淆的点:很多人以为 a、b 必须取整数,其实不必。真正决定闭合性的是 a 与 b 的比值是否为有理数。0.5 : 1.5 也是有理比,等价于 1 : 3,形状完全一样。

2.2 频率比的三种情况:闭合、退化和稠密

把 a:b 的比值分三类看,你能提前预判出画面效果,不用瞎试。

第一类是互质整数比,这是最常用的一类。比如 1:2、2:3、3:4、5:4。曲线会闭合成一个漂亮的、有明确对称性的图形。比值越“悬殊”或数字越大,交叉越多,图形越复杂。1:1 是个特例,退化成一条直线或者一个椭圆(取决于相位差),初学者最容易一头雾水地发现“我怎么只画出一条斜线”,原因就在这。

第二类是非互质的整数比,比如 2:4、6:9。因为 2:4 约分就是 1:2,你画出来的图形和 1:2 一模一样,只是同一个轨迹被重复描了更多遍。这在视觉上没区别,但会白白消耗两三倍的渲染性能。这是新手最常犯的“隐形浪费”。

第三类是无理数比,比如 1:√2、1:π。曲线永远不闭合,t 扫得越久,线条越密,最后会填充出一个类似黎曼曲面的致密纹理。这类效果做背景特别好看,但要注意渲染时长和点数,不然容易糊成一团黑。

频率比类型示例视觉效果使用建议
互质整数比3:4闭环、对称、交叉清晰静态图形、海报素材首选
非互质整数比4:6与 2:3 相同,重复描线先约分,省性能
无理比值1:1.414不闭合,逐渐填充致密纹理做背景纹理、长时间累积效果好
1:1 且 δ=0 或 π1:1退化成直线想画椭圆请把 δ 设成 π/2

2.3 Processing 在这件事上的天然优势

为什么不用 Python 的 matplotlib,不用 p5.js,非要用 Processing?我从实际使用角度说三个理由。

一是即时预览。Processing 的draw()循环天生适合做动态图形,改一个数字,点运行,一秒内看到结果。李萨如曲线的魅力在于参数敏感性——δ 差 0.1 弧度,图形气质就变了。这种“调一下看一眼”的循环,Processing 的手感是最顺的。

二是坐标系简单到粗暴。屏幕就是画布,width、height两个变量直接可用,不需要建坐标系、不需要处理 DPI、不需要管布局。你要做的唯一一件事就是translate(width/2, height/2)把原点挪到中心。

三是导出能力省事。saveFrame()一行代码就能按帧导出 PNG,配几张序列帧拼视频是几分钟的事。做图形素材积累的时候,这个流程比在别的环境里折腾截图靠谱得多。

需要说明的是:Processing 4 和 Processing 3 在语法层面兼容性很好,本文的代码在两者上都能跑。如果你用的是 2.x 版本,注意size()的参数和smooth()的行为略有差异,建议升到 3 以上。

3. 开工前的三个关键决策

3.1 坐标系与原点:别让图形长出屏幕

Processing 的坐标系原点在左上角,X 向右为正,Y 向下为正。这个设置会让第一次画图的人懵三秒:明明公式是y = B * sin(b*t),图形却像是上下颠倒的。

要不要翻转?我的建议是:默认不翻转,先跑起来看效果。因为李萨如曲线大多数情况下是上下对称的,Y 轴翻转与否看不出区别。只有当你设置了个性化的相位差、或者曲线本身不对称时,翻转才有意义。真要翻转就两行:

translate(width / 2, height / 2); scale(1, -1); // 让 Y 轴朝上,跟数学课本一致

但注意,一旦你scale(1, -1),后续所有文字绘制、描边粗细的视觉效果都可能受影响(虽然线宽本身不受缩放影响,但坐标正负号要自己心里有数)。我的习惯是不翻转,接受“屏幕坐标”这个设定,反正图形好不好看跟上下颠倒没太大关系。

真正必须做的是把原点挪到画布中心。如果不挪,原点在左上角,你的曲线只有右下四分之一在屏幕内,其余全被裁掉,看起来像“缺了一角”。这一步很多人写完公式忘了加,然后怀疑自己数学错了。

振幅 A、B 的取值也有讲究。稳妥的写法是:

A = width * 0.4; B = height * 0.4;

留出 10% 边距,保证strokeWeight加粗时线条也不被裁。如果你的画布不是正方形,A、B 应该分开取,否则曲线会被拉成椭圆状——有时候这是想要的效果,有时候不是,自己要清楚。

3.2 采样点数怎么定:一条曲线该给多少个顶点

这是实操里第二个高频困惑:为什么我的曲线边缘是毛刺状的?

李萨如曲线是连续的,但屏幕上你只能用有限个点连起来。点太稀,弦就切出角,看着像锯齿;点太密,性能浪费,动态渲染时会掉帧。

采样点数的本质是:在不牺牲视觉平滑度的前提下,用最少的顶点。判断依据是曲线的“弯曲程度”,而弯曲程度跟频率系数直接相关。a 和 b 越大,曲线在单位参数区间内扭的次数越多,需要的点也越多。

我实测下来比较好用的经验公式是:

int steps = 200 * (a + b); steps = constrain(steps, 800, 12000);

几个验证过的数据点:a=1, b=2 时 steps=600,肉眼看不出锯齿;a=3, b=4 时 steps=1400,同样顺滑;a=9, b=10 时 steps=3800,放大到 4K 分辨率才勉强看出一点点折线感。超过 12000 之后继续加点,视觉收益基本为零,但帧率会明显往下掉。

还有一个隐藏的坑:t 的取值范围不是固定 0 到 2π。当 a、b 是整数时,公共周期是T = 2π / gcd(a, b)。比如 a=2, b=4,最大公约数是 2,周期是 π,画 0 到 2π 会把同一条曲线描两遍。描两遍在静态图上没影响,但如果你在动画里靠 t 的推进做“生长效果”,就会看到线先画完一遍再叠一遍,很怪。

处理方式很简单,写个求最大公约数的小函数,在 setup 里先把周期算出来:

int gcd(int m, int n) { return n == 0 ? m : gcd(n, m % n); } float period = TWO_PI / gcd(int(a), int(b));

如果 a、b 不是整数(做无理比图案时常见),这条公式失效,你的选择要么是直接画 0 到 2π 的若干倍,要么是不追求闭合、让 t 一直推进做“无限描绘”。两种都是合理做法,看你要什么效果。

3.3 渲染方式选型:beginShape、line 还是点阵

Processing 里画一条曲线至少有三种写法,性能上和视觉上都不同,选错了会很痛苦。

写法一:beginShape()/vertex()/endShape()。这是我最推荐的默认方案。它把整条曲线当做一个多边形路径来渲染,Processing 内部会做顶点连接优化,效率比逐段画线高不少。缺点是顶点数过多时(比如超过两万)会有明显的构建开销,但正常范围内完全够用。

写法二:循环里用line(x1, y1, x2, y2)。新手最爱用,因为逻辑最直白。但每调用一次line()就是一次独立的绘制指令,2000 段就是 2000 次状态切换,动态渲染时帧率掉得肉眼可见。我做过对比,同样的参数下,line()版本的帧率大约是beginShape()版本的一半左右。

写法三:只用point()或者干脆只画顶点。适合做“散点轨迹”“粒子化”的视觉风格,或者有理比图案但想让它看起来更“轻”。这时候采样点数反而要更多,因为点与点之间的空隙本身就是视觉的一部分。

渲染方式相对性能适用场景注意事项
beginShape + vertex高绝大多数情况,静态和动态都行顶点数建议控制在 1.5 万以内
循环 line中低需要每段独立颜色或粗细段数多时明显卡顿
point 点阵高粒子风、散点风、纹理感需要更多点数才能连成曲线感
curveVertex中点数极少但要平滑会“抹平”真实拐点,慎用于高频率比

还有一个渲染器层面的选择:用默认渲染器还是P2D。默认渲染器抗锯齿质量好,线条干净;P2D走 OpenGL,点多了之后帧率优势明显,但细线的抗锯齿表现会略糙一点。做静态导出图,我一般用默认渲染器;做动态动画、尤其点数上万的时候,切P2D。

4. 第一版静态曲线:完整代码与逐行拆解

4.1 骨架与参数区

先给一版能直接跑的完整代码,然后我们再一行行抠。这段是静态版本,跑起来你会看到一条闭合曲线定在屏幕中央。

float A, B; // 两个方向的振幅 float a, b; // 两个方向的频率系数 float delta; // 相位差 int steps; // 采样点数 void setup() { size(900, 900); smooth(8); A = width * 0.4; B = height * 0.4; a = 3; b = 2; delta = HALF_PI; int g = gcd(int(a), int(b)); steps = constrain(200 * (int(a) + int(b)), 800, 12000); noLoop(); // 静态图,画一次就够了 } int gcd(int m, int n) { return n == 0 ? m : gcd(n, m % n); } void draw() { background(12, 14, 20); translate(width / 2, height / 2); noFill(); stroke(120, 200, 255); strokeWeight(1.6); float period = TWO_PI / gcd(int(a), int(b)); beginShape(); for (int i = 0; i <= steps; i++) { float t = map(i, 0, steps, 0, period); float x = A * sin(a * t + delta); float y = B * sin(b * t); vertex(x, y); } endShape(); }

把 a 改成 5、b 改成 4,再跑一次,你会看到明显更复杂的交叉结构。把 delta 从HALF_PI(π/2)改成 0,图形会塌缩成一条斜线。这两个小实验做完,你对参数的感觉就有了。

4.2 三个让图形“画歪”的坑

坑一:忘了 translate,曲线卡在右下角。这个前面提过,但还是要强调,因为它太常见了。判断方法很简单:如果你看到的图形只占屏幕的四分之一并且贴着右下边缘,百分之百是这个原因。

坑二:整数除法把频率比吃掉了。有些人图省事,写a = 1/2;,结果 a 变成 0,曲线退化成一条水平线。Processing 里1/2是整数除法,结果是 0。要写1.0/2.0或者0.5。这个坑在动态改参数的版本里特别阴险,因为一开始用整数常量跑得好好的,一改成变量就崩了。

坑三:周期算错,曲线“多画了一圈”。表面上看起来没问题,但如果你在做逐点生长动画,就会看到线条画到底之后又从头描一遍。用period = TWO_PI / gcd(a, b)就对了。注意当 a、b 是浮点数时这个公式不适用,需要另想办法,我在后面进阶部分会说。

一个自检小技巧:把steps临时改成 20,你会看到一条折线骨架。用它来检查起点、终点是否重合、形状是否闭合,比看成品图直观得多。改完记得改回来。

4.3 逐行解释里最容易被忽略的两处

第一处是for (int i = 0; i <= steps; i++)里的<=。用小于等于意味着会画 steps+1 个点,首尾两个点重合(因为 t 从 0 到 period 正好一整圈)。这对闭合曲线是好事,路径首尾无缝衔接,不会有缺口。但如果你在做一个“从无到有生长”的动画,末尾这个重复点会让最后一帧停一下,感觉怪怪的。这时候改成<更合适。

第二处是map(i, 0, steps, 0, period)里的映射。很多人习惯直接写t = i * 0.01之类,然后调节 0.01 这个系数来控制密度——能跑,但很难维护,因为你改 a、b 之后密度就不对了。用map把 i 归一化到周期上,点数和周期就解耦了,改频率不影响采样密度,这是更干净的做法。

另外提一句,smooth(8)里的 8 是抗锯齿采样档位,取值 2、4、8。数值越高边缘越细腻,代价是渲染开销增加。做静态导出图时开着,做动态高帧率动画时降到 2 甚至关掉,肉眼几乎看不出差别但帧率差别明显。

5. 让曲线动起来:相位驱动与视觉调优

5.1 用相位差做动画:最省事的动态化路径

静态图看久了会腻,加上动画立刻不一样。李萨如曲线有一个天然适合做动画的参数,就是相位差 δ。你只要让 δ 随时间缓慢增加,整条曲线就会像被慢慢拧动的橡皮圈一样连续变形,从椭圆拧成 8 字,再拧成三叶,循环往复。这个效果非常耐看,而且实现成本极低。

改法就是把noLoop()去掉,然后在draw()里递增 δ:

float phase = 0; void draw() { background(12, 14, 20); translate(width / 2, height / 2); noFill(); stroke(120, 200, 255); strokeWeight(1.6); float period = TWO_PI / gcd(int(a), int(b)); beginShape(); for (int i = 0; i <= steps; i++) { float t = map(i, 0, steps, 0, period); float x = A * sin(a * t + phase); float y = B * sin(b * t); vertex(x, y); } endShape(); phase += 0.012; }

0.012这个步长我试过几档:0.005 太慢,一个完整的形变循环要十几秒,观众会走神;0.03 太快,形变还没看清就过去了,有点像故障艺术。0.01 到 0.015 之间是比较舒服的“呼吸感”区间。如果你要配乐,可以按 BPM 反推:想要 8 秒走完一个 2π 循环,步长就是TWO_PI / (8 * 60) ≈ 0.0131。这个计算过程值得写下来,因为动态视觉跟音乐对拍,靠感觉调是调不准的。

这里有个细节要提醒:phase会一直累加,跑几个小时之后浮点数精度会有损失,表现形式是动画开始出现细微的抖动。解决办法是加一行phase %= TWO_PI;,让它循环回来。这个坑很隐蔽,因为短时间测试根本发现不了。

5.2 拖尾、渐变与多层叠加

单线条有一点单调,视觉上想再往前走一步,有三个成本不高但效果明显的做法。

第一个是拖尾。把background()换成带透明度的版本:

noStroke(); fill(12, 14, 20, 28); rect(0, 0, width, height);

这会在画面上盖一层半透明的底色,上一帧的线条没有被完全擦掉,形成淡淡的残影。alpha 值越小,拖尾越长越柔;28 是偏短的效果,想让残影更明显就调到 10 到 15。但要注意,这种累积式拖尾跑久了画面会越来越“脏”,因为残影永远不会完全消失。我的做法是每隔几百帧强制来一次background(12, 14, 20)彻底清屏,视觉上会有一次轻微跳变,但能保持画面干净。如果你追求无跳变,就得用离屏缓冲区手动做淡出,复杂度上一个台阶,一般场景不值得。

第二个是沿曲线的颜色渐变。让线条颜色跟着参数 t 变化,曲线会自然呈现出一种“流动感”。用lerpColor()配合HSB模式最好调:

colorMode(HSB, 360, 100, 100); // 在循环内部,每画一段换一次色 float hue = map(i, 0, steps, 0, 360); stroke(hue, 70, 100);

注意colorMode的切换要在setup里做好,循环里频繁切换模式是性能杀手。另外 HSB 的色相是环形的,0 和 360 是同色,所以渐变能无缝衔接回起点,这点比 RGB 方便。

第三个是多层叠加。用同一组 a、b,但把 delta 偏移一点点,画三到五条曲线,透明度都降到 30% 左右,叠出来会有类似“光晕”或被风吹动的丝绸质感。层数别太多,超过 6 层就开始糊成一团了,反而看不出曲线结构。

5.3 导出序列帧到视频

做素材的人最关心的一步。Processing 的saveFrame()按顺序编号保存 PNG,一行代码:

void draw() { // ... 绘制部分 ... if (frameCount <= 240) { saveFrame("frames/liss-####.png"); } else { noLoop(); } }

####会被自动替换成四位数字编号,从 0001 开始,顺序不会乱。frameCount <= 240是限制总帧数,避免忘记停止把硬盘塞满——我最初就干过这事,中途去吃饭,回来发现写了几万张图。

更稳妥的做法是不用frameCount判断,而是用frameRate(30)固定帧率再限制帧数。因为saveFrame本身有 IO 开销,帧率不稳会导致视频时长对不上。导出前先跑一遍不保存的版本,确认流畅度,再开保存。

序列帧到视频用 ffmpeg,命令行一行:

ffmpeg -framerate 30 -i frames/liss-%04d.png -c:v libx264 -pix_fmt yuv420p out.mp4

参数说明:-framerate 30要和你的导出帧率一致,否则会快放或慢放;-pix_fmt yuv420p是兼容性最好的像素格式,不加的话某些播放器会打不开。

一个提升成片质感的技巧:导出分辨率开大一点,比如size(1920, 1920),但导出帧率保持 30。大画幅加低帧率,出来的视频有一种静帧般的精致感,比小尺寸高帧率更适合做展示素材。

6. 进阶玩法与批量产出

6.1 音频驱动:把振幅和频率映射到参数

把音频映射到几何图形上,是李萨如曲线最出彩的用法之一,因为它天生就是“波形”的视觉化。Processing 官方 Sound 库提供了现成的分析工具,思路是取音频的音量包络控制振幅,取频谱分布控制频率系数。

import processing.sound.*; AudioIn input; Amplitude amp; void setup() { size(900, 900); Sound s = new Sound(this); input = new AudioIn(this, 0); input.start(); amp = new Amplitude(this); amp.input(input); } void draw() { float level = amp.analyze(); // 0.0 ~ 1.0 A = width * (0.15 + level * 0.35); B = height * (0.15 + level * 0.35); // ... 后面的绘制逻辑不变 }

amp.analyze()返回的是当前音量 RMS 的归一化值。实测下来,直接用会让图形抖动得太厉害,因为音频的瞬时音量波动很剧烈。加一层平滑就好:

smoothedA = lerp(smoothedA, targetA, 0.12);

lerp的第三个参数是平滑系数,越小越跟手软,0.1 到 0.2 之间是比较舒服的响应速度——像被弹簧拉着,不是硬跟随。

再上一层,可以用FFT取低频段和高频段的能量,分别去调制 a 和 b。这时候会出现一个很有意思的现象:重低音重的段落图形会突然变得“繁复”,高频密集的段落线条会变“碎”。这种映射关系不是物理正确的,但视觉上很说得通。

提醒一下:音频输入需要系统麦克风权限,在部分环境下可能拿不到数据。做演示或者输出成品时,我更推荐用预录好的音频文件播放、再对播放流做分析,稳定性好得多,也方便反复调参。

6.2 参数网格批量生成壁纸

做素材的人不会满足于一张图。李萨如曲线的参数空间很大,可以写个批量生成器:遍历 a、b、delta 的组合,每生成一张就saveFrame然后改参数。

关键是控制组合规模。a 从 1 到 9、b 从 1 到 9、delta 取 8 个均匀值,一共 9 × 9 × 8 = 648 张。这个量级跑一遍大概十几分钟,能挑出几十张能看的。我的筛选经验是:跳过 gcd > 1 的组合(会重复),跳过 a 等于 b 的组合(退化),剩下的命中率会高很多。

配色也可以批量。写一个调色板数组,每张图随机挑一组,避免全是同一种蓝。但别随机过头,相邻两张图配色差异太大,放到一起就没有系列感了。我一般固定两三个色相区间,只随机明度和饱和度,出来的图成套好看。

参数建议取值范围说明
a2 ~ 9太大之后线条过密,缩小后糊成黑块
b2 ~ 9与 a 尽量互质
delta0 ~ π,取 8 等分超过 π 的变化与 0~π 重复
strokeWeight1.2 ~ 2.0配合导出分辨率,4K 下要加粗
背景明度8 ~ 15太亮会抢线条,太暗导出后发灰

6.3 性能优化清单

动态版本跑起来之后,第一个遇到的问题往往是掉帧。按我踩坑的顺序列几条,按性价比排序:

第一,降低不必要的采样点数。这是收益最大的一条。12000 个顶点降到 4000 个,视觉上可能只损失一点点细节,帧率能翻倍。判断方法:把线宽加到 3,如果锯齿明显到影响观感了才需要加点;线条细的时候大量顶点是纯浪费。

第二,把beginShape()换掉line()。前面说过了,这一条能把帧率拉高不少。

第三,切到P2D渲染器。在size()里加第三个参数:size(900, 900, P2D)。点数上万时效果明显。代价是线条的抗锯齿质量下降,做静态导出就别切了。

第四,减少stroke()调用次数。如果每条曲线只有一个颜色,把stroke()移到循环外面。如果要做渐变,考虑每隔 8 到 10 个顶点才换一次颜色,而不是每点都换。这个优化很反直觉但确实有效,因为状态切换的开销比你想象的大。

第五,关掉smooth()。在P2D下smooth()的收益不大,关掉能省一些。如果一定要抗锯齿,考虑导出高分辨率、降采样显示。

还有一种“作弊”式的优化:动态渲染时用低分辨率画布(比如 450×450),导出前再改成高分辨率。视觉体验差不多,性能省四分之三。做展示的时候很实用。

7. 踩坑实录与问题速查

7.1 现象对照表

这些年被问得最多、自己也反复踩的问题,整理成一张表,方便对照排查。

现象最可能的原因解决方式
只画出一条斜线a 等于 b,且 delta 为 0 或 πdelta 改成 π/2 得到椭圆,或改频率比
图形只有四分之一在屏幕里忘记 translate 到画布中心加translate(width/2, height/2)
曲线首尾没接上t 的范围不是完整周期用TWO_PI / gcd(a,b)算周期
线条有明显的折角采样点数太少steps 提到 200*(a+b) 以上
曲线看起来重复描了好几遍a、b 不互质先约分,或接受重复只做静态图
动画跑久了开始抖phase 无限累加导致精度损失加phase %= TWO_PI;
拖尾越跑越脏半透明覆盖累积而不清零定期background()彻底清屏
帧率掉到个位数采样点数过多,或用了循环line()降点数、换beginShape、切P2D
导出视频速度快一倍-framerate和实际帧率不一致两边统一成 30
曲线边缘发虚、有重影开了smooth(8)又叠加了拖尾拖尾场景把smooth降到 2

7.2 几条文档里不会写的经验

关于相位差的取值范围。数学上 delta 可以是任何实数,但视觉上它有周期性——delta 增加 π,曲线变成原来关于原点对称的样子;增加 2π 完全复原。所以做静态图批量生成时,delta 只需要在 0 到 π 之间取点,取 8 到 12 个值就能覆盖所有视觉变化。我一开始老老实实从 0 试到 2π,做完一半才发现全是重复的,白白浪费了半天。

关于“好看”的参数区间。频率系数不是越大越好看。a、b 超过 12 之后,曲线交叉密到看不出结构,缩小看就是一团灰色。真正出片的参数集中在 2 到 7 之间,而且互质组合里 3:4、4:5、5:6、3:5 这几个基本是万能选项,配什么相位差都不会太难看。这种“经验值”是靠大量试错攒出来的,比任何理论推导都直接。

关于颜色和背景的搭配。深色背景配高饱和亮色线条,是最稳妥的搭配。我试过浅色背景,同样的曲线立刻显得“廉价”,因为细线条在浅底上对比度不足,看着像线稿不像成品。如果非要做浅色,线宽得加到 2.5 以上,并且颜色要压暗、降低饱和度,往灰调走。

关于分辨率。做壁纸别用 900×900 那只适合调试。手机壁纸至少 1080×1920,桌面壁纸 2560×1440 起。放大画布之后所有参数都要跟着调:振幅按 width/height 比例算、线宽乘以缩放系数、采样点数也要加(否则在高分辨率下锯齿暴露无遗)。我一般写一个float scaleFactor = width / 900.0;统一缩放,比一个个改参数省心。

最后一条,也是最实用的一条:先保存参数组合再调视觉。我早期经常遇到“刚才那个很好看的形状被我改参数改没了,也记不住原来是啥”。后来养成习惯,一旦觉得某个形状可以,立刻在代码里注释记下 a、b、delta 三个值,或者打印到控制台。李萨如曲线的参数敏感性太高了,差 0.02 的相位差就是另一张图,靠记忆是找不回来的。现在我干脆写了个按键保存的小功能,按一下空格就把当前参数写进一个 txt 文件,回头慢慢挑,这比任何调参技巧都管用。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进