1. 心形曲线的三种数学来源:先弄清我们要画什么
总有人拿着Matlab心形绘图的问题来问我,尤其是情人节前后和每学期图形学课设开始的时候。很多人的第一反应是上网抄一段代码,plot出来一个红彤彤的桃心就交差了,但代码为什么这么写、公式从哪来,基本说不上来。这篇整理一下我实际用过的、也是社区里最常见的几种Matlab心形绘图方案,从二维到三维、从静态到动画都有,每一步都配上可以直接运行验证的完整代码和参数说明,方便刚接触Matlab的同学直接照着复现,也方便想深入理解绘图原理的人知道每一行在干什么。
先统一交代一件事:所谓“心形曲线”在数学上并不是只有一种标准定义。我见过最多的有三类来源:一类是参数方程构造的闭合曲线,一类是隐式方程f(x, y)=0 描述的点集,还有一类是极坐标下的心形线(Cardioid)。这三类对应到 Matlab 里分别是 plot、fimplicit、polarplot(或转为直角坐标再 plot),它们的绘制逻辑、适用场景和细节坑都不一样。我这里先放下结论表,后面逐个拆开讲。
| 方法 | 数学形式 | 对应 Matlab 工具 | 特点 |
|---|---|---|---|
| 参数方程法 | x=16sin³t, y=13cost−5cos2t−2cos3t−cos4t | plot / fill | 轮廓饱满,最接近日常“爱心”图案 |
| 隐函数法 | (x²+y²−1)³=x²y³ | fimplicit | 一行函数句柄出图,适合微积分课堂演示 |
| 极坐标法 | r=a(1−cosθ) | polarplot / plot | 经典心脏线,数学味道浓,和爱心略有差异 |
| 三维曲面法 | 隐式曲面方程 | isosurface + patch | 立体感强,配合光影效果最好看 |
1.1 参数方程:一条曲线如何“设计”出心形
先看最常见的那组参数方程:
x(t) = 16·sin³(t)
y(t) = 13·cos(t) − 5·cos(2t) − 2·cos(3t) − cos(4t)
t ∈ [0, 2π]
这组公式不是拍脑袋写的。它的本质是:用一个绕着圆心走一圈的参数 t,分别控制横坐标和纵坐标的变化,从而在平面里勾勒出封闭轮廓。x 这一路用 sin³(t),保证了左右两侧在 t=π/2 和 t=3π/2 附近出现明显的峰谷,形成心形顶部那两个圆弧。y 这一路则是一组不同频率的余弦函数叠加,有点类似傅里叶级数逼近的思路——用不同频率的谐波组合出一个“顶部圆润、底部收尖”的纵剖面。系数的具体数值决定了整个图形的比例和弧度。
很多资料直接把这组方程丢给你,却不说为什么不能随便把 16 和 13 换成别的数。我实测下来,如果只做等比例缩放,图形形状基本不变;但如果单独改动其中一个系数,比如把 16 改成 10,心形顶部那两个“瓣”就会相对变窄,整体比例失衡。所以在做个性化调整时,尽量整体缩放,不要单独动单项系数。
这里还有一个新手高频翻车点:在 Matlab 里写 sin(t).^3,而不是 sin(t)^3。因为 t 是向量,sin(t) 也还是向量,向量做乘方必须加“点”,否则 Matlab 会直接报“数组运算”相关的维度错误。后面讲到具体代码时我会再强调一遍。
1.2 隐式方程与极坐标:另外两条路线
隐式方程 (x²+y²−1)³ = x²y³ 是另一种经典写法。它不告诉你 y 怎么由 x 算出来,而是用一个“x 和 y 满足某种关系”的方程来定义点集。Matlab 处理这类方程时不会去解 y=f(x),而是利用等值线算法在给定区域里追踪 f(x, y)=0 的轨迹,这就是 fimplicit 的底层逻辑。
极坐标心形线 r=a(1−cosθ) 则是数学分析教材里常见的心脏线(Cardioid),这个名字本身就有“心”的意思。它属于外摆线的一个特例——一个圆在另一个大小相同的圆外侧滚动时,圆周上某一点的轨迹。用极坐标画起来非常简洁,只要让半径 r 随角度 θ 变化即可。
三种数学来源对应三种“画法哲学”:参数方程适合想精确控制轮廓的场景;隐式方程适合展示隐函数图像、微积分相关教学;极坐标适合处理角度相关的物理或几何问题。接下来我就按这四条路线,把实际可运行的代码和参数细节逐一展开。
2. 方法一:参数方程绘图,Matlab里最稳的入门做法
2.1 核心代码与逐行拆解
先给出一段我实测过很多次、几乎不会出错的版本:
% 心形参数方程绘图 t = linspace(0, 2*pi, 300); % 参数 t 取 300 个点 x = 16 * sin(t).^3; % 横坐标 y = 13*cos(t) - 5*cos(2*t) - 2*cos(3*t) - cos(4*t); % 纵坐标 figure; plot(x, y, 'r', 'LineWidth', 2.5); % 红色曲线,线宽 2.5 axis equal; % 纵横比设为相等 grid on; title('Heart by Parametric Equation');这段代码里最容易被忽略的是linspace(0, 2*pi, 300)中的 300。它表示把 0 到 2π 均匀切成 300 个采样点。如果改成 30,画出来的就不是一条光滑曲线,而是一个 30 边形折线,看起来像多边形爱心;如果改成 3000,图形会更顺滑,但绘制负担也稍微变大。我自己的经验是 200 到 500 之间足够,300 是视觉和性能都很平衡的值。
plot 里我习惯显式指定 'r' 和 'LineWidth'。新版 Matlab 默认的配色是自动的,不指定的话可能是蓝色或其他颜色,达不到红心效果;LineWidth 默认只有 0.5,直线细得可怜,视觉冲击力差很多。通常 2 到 3 的线宽在导出图片后依然清晰。
如果想得到填充的爱心,把 plot 换成 fill 即可:
figure; fill(x, y, [0.85 0.25 0.35], 'EdgeColor', 'none'); axis equal;fill 的第一个和第二个参数同样是 x、y 向量,第三个参数是填充色,用 RGB 三元组表示。我这里选了偏柔和的中国红,不会像纯红 [1 0 0] 那么刺眼。'EdgeColor', 'none' 会去掉轮廓线,让填充区域干净利落。加上 alpha 还能实现半透明效果:
alpha(0.85);2.2 为什么必须坚持“点运算”和 axis equal
先谈点运算。t 是一个 1×300 的行向量,sin(t) 也是 1×300,sin(t).^3 的作用是对向量里的每一个元素分别求三次方。如果写成 sin(t)^3,Matlab 会尝试把向量当成矩阵做乘方,报错信息通常是“错误使用 ^,输入必须为标量”。这是新手最容易踩的坑之一,本质上是把矩阵运算和数组运算搞混了。记住一句口诀:对向量、矩阵做乘方或乘除法,绝大多数时候都要带点运算。这里不是“点”越多越好,比如 sin(t).^3 的“点”加在 ^ 之前,而不是 sin 之前。
再看 axis equal。Matlab 默认的坐标轴比例不是 1:1,它会根据数据范围自动拉伸,来填满 figure 窗口。对于心形这种对纵横比例极敏感的图形,如果不加 axis equal,x 和 y 的刻度间距不一致,心形就会变成扁扁的“鸭蛋形”。我见过很多同学问“为什么我的爱心是椭圆的”,十有八九就是漏了这一句。axis equal 的含义是让 x 轴和 y 轴上的单位长度一致,绘图区域按数据真实比例呈现。
还有一个经常被忽视的点:坐标轴范围。参数方程中 x 的范围约在 ±16 之间,y 的范围约在 ±17 之间。如果后面想叠加文字、图形或者做动画,建议手动加上:
xlim([-20 20]); ylim([-20 20]);保证可见区域留有余量,避免图形边缘被裁剪。
3. 方法二:隐函数fimplicit,一行画出方程心
3.1 从方程到图形:fimplicit的使用逻辑
隐函数法最大的优势是代码极短,不需要手动算点,把方程原封不动交给 Matlab 即可:
% 隐函数心形 f = @(x, y) (x.^2 + y.^2 - 1).^3 - x.^2 .* y.^3; figure; fimplicit(f, [-1.5 1.5 -1.5 1.5], 'LineWidth', 2, 'Color', [0.85 0.2 0.3]); axis equal;先解释匿名函数 @(x, y)。它定义了一个关于 x、y 的函数句柄,fimplicit 需要的正是这种“给两个坐标,返回一个值”的输入形式。方程 (x²+y²−1)³ = x²y³ 被改写成了 f(x, y) = (x²+y²−1)³ − x²y³,也就是把右边的项挪到左边,画出来的就是 f(x, y)=0 的等值线。
fimplicit 的第二个参数是绘图区间,[−1.5 1.5 −1.5 1.5] 分别表示 x 和 y 的范围。这个区间不能给太小,否则图形会被截断;也不能给太大,否则心形只占图中间一小块,视觉上很空。我实测 ±1.5 是刚好能完整容纳这个经典心形的最小范围。
很多人不知道的是,fimplicit 并不是把所有点代入方程去验证是否严格等于 0,而是采用等值线追踪算法:先把区域划分成网格,计算每个网格顶点处的函数值,再用插值找到函数值为 0 的等值线。所以它画出的线条天然就是光滑的,不需要你手动采样,这也让它在“想快速看一个隐函数长什么样”的场景下非常顺手。
3.2 深色背景、颜色与坐标轴细节
隐函数画爱心,我习惯把背景调成深色,视觉冲击力更强:
figure('Color', [0.1 0.1 0.15]); fimplicit(f, [-1.5 1.5 -1.5 1.5], 'LineWidth', 3, 'Color', [1 0.3 0.4]); axis equal; set(gca, 'Color', [0.1 0.1 0.15], 'GridColor', [0.5 0.5 0.5]);这段的效果是深色底、亮红心,很适合直接当壁纸或海报素材。set(gca) 里的 'Color' 是坐标区背景色,'GridColor' 是网格线颜色,避免网格线和背景融为一体。
这里也要提醒:fimplicit 是从 R2016b 开始引入的函数,如果你还在用很老的版本(比如 R2014a 或更早),需要改用 ezplot:
ezplot('(x^2+y^2-1)^3 - x^2*y^3', [-1.5 1.5 -1.5 1.5]);但 ezplot 的线条控制和显示效果都偏旧,现在新版本里它几乎被 fimplicit 完全取代了,所以新写的代码我还是建议直接 fimplicit。
对于隐函数方程本身,还有个小细节值得知道:方程右侧的 x²y³ 在 y 为负时是负值,这意味着曲线并非只分布在上半平面,图像底部也会出现一个小尖角收敛点,这是这类方程自带的特点。有些同学看到心形下方多出了一小段线条担心代码错了,其实那是方程数学性质的正常表现,不是渲染 bug。
4. 方法三:极坐标心形线,换个坐标系看同一件事
4.1 从极坐标方程到极坐标系绘图
极坐标心形线的标准形式是 r = a(1 − cos θ)。这个方程里 r 是极径,θ 是极角,a 控制图形整体大小。用 Matlab 的 polarplot 画起来非常直观:
a = 3; theta = linspace(0, 2*pi, 500); r = a * (1 - cos(theta)); figure; polarplot(theta, r, 'r', 'LineWidth', 2);polarplot 会直接画出极坐标系下的曲线,背景自带角度格线,数学味道很浓。这里 theta 取 500 个点,同样是为了让曲线光滑。a 取 3 只是我习惯的大小,你取 1、2、5 都会得到相似形状,只是整体缩放不同。
要特别说明的是:极坐标心形线(Cardioid)和我们日常看到的“爱心”还是有一点点区别的。Cardioid 的尖端朝右或朝左(取决于用的是 1−cosθ 还是 1+cosθ),整体像一颗心脏侧影,而参数方程那个心形是标准的“桃心”,顶部有两个对称的圆弧。很多数学教材里说的“心形线”其实是 Cardioid,如果你交作业或做展示时要先明确自己的目标是哪一种,避免被老师或同学追问“你的心形怎么不对称”。
4.2 极坐标转直角坐标:更自由的组合方式
polarplot 有一个限制:它独占一个坐标区,想在同一张图里叠加网格线、文字、其他曲线会有点别扭。我更喜欢把极坐标方程转成直角坐标后用 plot 绘制:
a = 3; theta = linspace(0, 2*pi, 500); r = a * (1 - cos(theta)); x = r .* cos(theta); % 注意这里是点乘 y = r .* sin(theta); figure; plot(x, y, 'r', 'LineWidth', 2); axis equal; grid on;转换公式就是极坐标与直角坐标的基本关系:x = r cosθ,y = r sinθ。这里的“点乘”又是关键:r 和 cos(theta) 都是 1×500 的向量,必须用 .* 做逐元素乘法,否则维度错误会直接中断运行。axis equal 依然不能少——Cardioid 如果横纵比例不相等,会变成被拉长的畸形图案。
转换后的好处是你可以自由控制坐标范围、叠加其他图形、设置标题和图例,甚至把它和参数方程的心形放在同一张图对比。我经常做的一件小事是给两张图加上不同透明度叠加一起看,比较两种心形轮廓的差异。
如果想给极坐标心形填充颜色,同样用 fill:
fill(x, y, [0.9 0.3 0.4], 'EdgeColor', 'none');因为极坐标方程是逐角度生成的闭合曲线,首尾正好相接,满足 fill 的闭合填充条件。
5. 方法四:三维心形曲面,isosurface与光影渲染
5.1 三维心形的核心代码与等值面原理
二维画腻了,接下来是立体感最强的三维心形。三维心形最常见的隐式曲面方程为:
(x² + 2.25y² + z² − 1)³ = x²z³ + 0.1125·y²z³
Matlab 中推荐的绘制方式是 isosurface + patch 组合:
% 三维心形曲面 f = @(x, y, z) (x.^2 + (9/4)*y.^2 + z.^2 - 1).^3 - x.^2 .* z.^3 - (9/80)*y.^2 .* z.^3; n = 120; [x, y, z] = meshgrid(linspace(-1.5, 1.5, n)); v = f(x, y, z); figure; p = isosurface(x, y, z, v, 0); patch(p, 'FaceColor', [0.85 0.25 0.35], 'EdgeColor', 'none', 'FaceAlpha', 0.9); view(3); camlight('headlight'); lighting gouraud; axis equal;这段代码的核心在 isosurface 的“等值面”概念。f(x, y, z) 是一个三维标量场,isosurface 要做的是找出所有满足 f = 0 的空间点,并把它们连接成三角网格曲面。你可以把它想象成在空间中切出一张“函数值恰好为零”的薄膜。正是这种机制,避开了“解方程得到 z=f(x,y)”的困难,直接用体数据提取出隐式曲面。
patch 的作用是把 isosurface 返回的三角网格模型渲染出来。'FaceColor' 设置表面颜色,'EdgeColor', 'none' 去除三角形网格的边线,这样曲面看起来才是光滑的;'FaceAlpha', 0.9 设置了 90% 的不透明度,稍微透一点,层次感更好。view(3) 等价于设置一个三维视角。camlight('headlight') 在镜头位置添加光源,lighting gouraud 选择 Gouraud 光照模型,让曲面明暗渐变更自然。
5.2 网格密度与内存的平衡:一个比你想的更现实的坑
三维心形代码里最值得调整的参数是 n。n=120 意味着 meshgrid 在三个维度上各生成 120 个点,总共有 120³ ≈ 172.8 万个格点。x、y、z、v 四个数组都是 double 类型,每个数组约 13.8 MB,合计约 55 MB。这在现代电脑上完全没问题,但如果把 n 改成 250,总数会变成 250³ ≈ 1562.5 万个格点,四个数组加起来超过 500 MB,很多低配机器就会开始卡顿甚至报内存不足。
我实测过:n=100 时曲面已经足够平滑,肉眼几乎看不出三角形网格痕迹;n=150 是出图质量比较稳的选择;n=200 以上只有在你打算把图放得特别大(比如做海报)时才值得考虑。所以我的建议是:先 n=80 快速验证代码有没有跑通,再按需要逐步提高到 120 或 150。不要一上来就 n=300,把自己电脑跑成幻灯片。
另外,patch 出的三维图形默认背景是白色。如果想做出那种深邃的星空感或深色背景,可以加两行:
set(gcf, 'Color', [0.05 0.05 0.1]); lighting phong;lighting phong 比 gouraud 高一级,高光更锐利,适合表面比较光滑的模型。但如果电脑性能一般,phong 渲染可能比 gouraud 略慢,建议出图时二选一即可。
三维心形的坐标轴也该注意:虽然方程本身会“隐形”约束点在空间中的位置,但 patch 出来之后坐标轴范围如果没有设置合适,视角会忽远忽近。稳妥做法是:
xlim([-1.5 1.5]); ylim([-1.5 1.5]); zlim([-1.5 1.5]);这样每次运行出来的都在同一视角范围内,看起来更规整。
6. 进阶玩法:会跳动的心,从二维缩放到三维呼吸
6.1 二维心跳动画:循环更新坐标数据
心形图如果只是静态显示,趣味性有限。动画版才是真正能让人眼前一亮的东西。二维心形动画的核心思路非常简单:在循环里不断改变曲线坐标,然后刷新画面。
t = linspace(0, 2*pi, 300); x0 = 16 * sin(t).^3; y0 = 13*cos(t) - 5*cos(2*t) - 2*cos(3*t) - cos(4*t); figure; h = plot(x0, y0, 'r', 'LineWidth', 3); axis equal; xlim([-20 20]); ylim([-20 20]); grid on; % 心跳动画 for k = 1:120 s = 1 + 0.06 * sin(2*pi * k / 30); % 心跳缩放因子 set(h, 'XData', s * x0, 'YData', s * y0); drawnow; pause(0.03); end这个动画的“心跳感”来自缩放因子 s。sin(2π·k/30) 会让 s 在 0.94 到 1.06 之间周期性变化,周期约 30 帧。结合每帧 pause 0.03 秒,整个心跳周期约 0.9 秒,比较接近真实心跳节奏。你也可以把 30 改小一点,心跳会变快;改大一点则更舒缓。
在动画循环中,有几个细节很重要。第一,必须用 set 更新已有图形对象的 XData 和 YData,而不是反复调用 plot。plot 每次都会创建新的图形对象,循环 120 次就会出现 120 根线叠在一起,又卡又乱。第二,drawnow 强制 Matlab 把绘图队列里的内容推到屏幕上,这是动画的关键;没有 drawnow,所有更新会堆积到循环结束后一次性显示,看不到动画效果。第三,pause 的数值控制帧率,0.03 秒左右人眼会觉得很顺滑,太小则动画过快反而显得急促。
6.2 三维心跳动画与GIF导出
三维心形同样可以做成“跳动”效果。因为 patch 图形对象保存着曲面网格的顶点坐标,直接对顶点坐标做整体缩放即可:
f = @(x, y, z) (x.^2 + (9/4)*y.^2 + z.^2 - 1).^3 - x.^2 .* z.^3 - (9/80)*y.^2 .* z.^3; n = 100; [x, y, z] = meshgrid(linspace(-1.5, 1.5, n)); v = f(x, y, z); figure; p = patch(isosurface(x, y, z, v, 0), 'FaceColor', [0.85 0.25 0.35], ... 'EdgeColor', 'none', 'FaceAlpha', 0.9); view(3); camlight('headlight'); lighting gouraud; axis equal; xlim([-1.8 1.8]); ylim([-1.8 1.8]); zlim([-1.8 1.8]); verts = p.Vertices; % 保存原始顶点坐标 for k = 1:120 s = 1 + 0.07 * sin(2*pi * k / 40); % 缩放因子 p.Vertices = verts * s; % 所有顶点整体缩放 drawnow; pause(0.04); endp.Vertices 是一个 N×3 的矩阵,每一行代表一个顶点的 (x, y, z) 坐标。整体乘以 s 相当于以原点为中心缩放模型,心形会一鼓一鼓地膨胀收缩,效果很真实。注意缩放幅度不要太大,0.05 到 0.08 之间比较自然,太大就像气球快吹爆了。
如果想把这个动画保存成 GIF 发出去,可以在循环里加几行抓帧代码:
for k = 1:120 s = 1 + 0.07 * sin(2*pi * k / 40); p.Vertices = verts * s; drawnow; frame = getframe(gcf); [im, map] = rgb2ind(frame.cdata, 256); if k == 1 imwrite(im, map, 'heart.gif', 'gif', 'LoopCount', inf, 'DelayTime', 0.05); else imwrite(im, map, 'heart.gif', 'gif', 'WriteMode', 'append', 'DelayTime', 0.05); end endgetframe 抓取当前图形窗口内容,rgb2ind 把真彩色图像索引化为 GIF 支持的格式,imwrite 第一次写入时设置 LoopCount 为无限循环,之后追加写入每一帧。这里要注意 GIF 文件会随着帧数增加而变大,如果导出的文件太大,可以减小帧数(把 120 改成 60)或减小 figure 窗口尺寸。
7. 高频踩坑与我的实测心得
7.1 数组运算的“点”与线条细节
二维、三维代码里我最常帮人排查的问题,几乎都集中在数组运算和坐标轴比例上。下面这张表基本覆盖了我遇到的高频错误:
| 报错或异常表现 | 常见原因 | 解决办法 |
|---|---|---|
| 错误使用 ^,输入必须为标量 | sin(t)^3 少了点运算 | 改成 sin(t).^3 |
| 矩阵维度必须一致 | r .* cos(theta) 写成了 r * cos(theta) | 改成 .* |
| 心形变成扁椭圆 | 缺少 axis equal | 加上 axis equal |
| 曲线像多边形折线 | linspace 采样点数太少 | 增加到 200 以上 |
| 线条太细不显眼 | LineWidth 默认 0.5 | 设为 2 或 3 |
| 图形边缘被裁剪 | 坐标轴范围太小 | 手动设置 xlim、ylim |
关于线条颜色,我多说一句:不要执着于纯红 [1 0 0]。在白色背景下,纯红线条的视觉感受偏“刺眼”,我用 [0.85 0.25 0.35] 这种偏温柔的红色系会更耐看。三维曲面同理,FaceColor 用 [0.85 0.25 0.35] 配合深色背景,整体观感是高级的。
7.2 内存、版本与兼容性:老版本用户的应对方案
三维心形对电脑内存的敏感度,比很多人想象的高。n=120 已经够用,但在真正发布或展示之前,建议先看一下自己机器的内存占用。任务管理器里如果 Matlab 进程内存占用接近 1GB,不要再往上加 n 了。另外,isosurface 生成的网格三角形数量通常很大,patch 渲染时如果滚动旋转视角明显掉帧,说明模型面数偏高,可以下调 n 到 100 或者 90,视觉差别微小,流畅度提升明显。
版本兼容性方面,fimplicit 是 R2016b 引入的,polarplot 是 R2016a 引入的,isosurface 则是比较老的函数,早期版本也能用。如果你在一台版本很旧的 Matlab 上跑,遇到函数不存在的情况,优先考虑升级到新版本,而不是反过来改代码适配旧版本。毕竟从 R2016b 到现在这么多年,函数接口已经稳定,新版兼容旧代码的负担远小于旧版迁新语法。
绘制完成后如果要输出图片,我推荐用 exportgraphics 而不是老式的 saveas:
exportgraphics(gcf, 'heart.png', 'Resolution', 300);300 DPI 的分辨率足够印刷或投屏,且导出内容严格按照当前图形窗口的比例,不会出现边框缺失。
7.3 我的几点实际使用体会
这几个心形绘图方法,我实际用下来的感受可以浓缩成三句话:
第一,如果只是“想要一张好看的红心图片”,参数方程 + fill 是最短路径,代码短、形状标准、性能最好。第二,fimplicit 方法更适合教学场景,因为它直接展示隐函数方程和曲线之间的联系,学生能直观理解“等值线”到底是什么。第三,三维心形最有表现力,但一定要控制好网格密度,否则不是电脑卡死就是渲染出来一堆锯齿。
我经常遇到的一个场景是:有人跑完二维心形之后觉得“不过瘾”,然后直接上三维心形代码,结果笔记本风扇狂转。我的建议是先跑 n=80,确认自己喜欢这个三维效果,再逐步提高分辨率。这不仅适用于心形,所有 isosurface 类的绘图任务都可以遵循这个“先粗后精”的策略。
最后一个非常实用的技巧:如果在 Windows 上用了高分辨率显示器,Matlab 窗口里的图放大后线条会很细,导出 PNG 前先把 figure 窗口最大化,再用 exportgraphics 导出,线条粗细比例会更协调。这个细节我试过很多次,导出前后的观感差别相当明显。